@4fu/arbor-environment-dir 0.0.0-stage → 0.1.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.
@@ -0,0 +1,830 @@
1
+ /**
2
+ * The default `Environment`: one shared directory, mounted read-only into every
3
+ * window at `/env` (docs/environment.md).
4
+ *
5
+ * ```text
6
+ * <root>/env/skills/<name>/ a skill, with its scripts beside it
7
+ * <root>/env/mcp/<name>.json an MCP server's configuration
8
+ * <root>/env/mcp/<name>/<name>.json an MCP server that brought its own code
9
+ * <root>/env/services/<name>/svc.json a service, beside its files
10
+ * <root>/env/settings.json identity, rules, judge, model tiers
11
+ * <root>/revisions/<kind>/<name>/<n>/ one copy of every installed revision
12
+ * <root>/index.json the items and their revisions
13
+ * ```
14
+ *
15
+ * Each item has its own revision, so two windows changing different items never
16
+ * conflict. A draft is staged and validated before anything is replaced, so a
17
+ * refused `reload` changes nothing: an item is replaced as a whole, and a
18
+ * service that fails its health check is rolled back to the revision it had.
19
+ *
20
+ * The built-in skills ship with the framework and are seeded on first use. They
21
+ * are read-only: the model writes its own skills beside them.
22
+ */
23
+ import { cp, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
24
+ import { dirname, isAbsolute, join, resolve } from "node:path";
25
+ import { fileURLToPath } from "node:url";
26
+ import { arborError } from "@4fu/arbor-errors";
27
+ import { StdioMcpServer } from "./mcp.js";
28
+ /** What a window reads the environment at, which is mounted read-only. */
29
+ const ENV_PREFIX = "/env";
30
+ /** The built-in skills, seeded read-only on first use. */
31
+ export const BUILTIN_SKILLS = ["arbor-skill", "arbor-mcp", "arbor-service", "arbor-settings"];
32
+ /** The built-in skill that covers each kind, which every refusal names. */
33
+ export const BUILTIN_SKILL_PATH = {
34
+ skill: "/env/skills/arbor-skill/SKILL.md",
35
+ mcp: "/env/skills/arbor-mcp/SKILL.md",
36
+ service: "/env/skills/arbor-service/SKILL.md",
37
+ settings: "/env/skills/arbor-settings/SKILL.md",
38
+ };
39
+ /** The one line an MCP server's item carries when its configuration says nothing. */
40
+ const MCP_DESCRIPTION = "一个 MCP 服务器,它的函数在 code_exec 中调用。";
41
+ /** The one line the settings item carries. */
42
+ const SETTINGS_DESCRIPTION = "身份、通用规则、judge、模型分层和各项阈值。";
43
+ /** The prefix an item's ref carries, one per kind. */
44
+ const KIND_PREFIX = {
45
+ skill: "sk",
46
+ mcp: "mcp",
47
+ service: "svc",
48
+ settings: "set",
49
+ };
50
+ /** The spellings a model may use for a kind: `skill:deploy`, `sk:deploy`. */
51
+ const KIND_ALIASES = {
52
+ skill: "skill",
53
+ sk: "skill",
54
+ mcp: "mcp",
55
+ server: "mcp",
56
+ service: "service",
57
+ svc: "service",
58
+ settings: "settings",
59
+ set: "settings",
60
+ };
61
+ export class DirectoryEnvironment {
62
+ /** What a window reads the environment at. */
63
+ mount;
64
+ /** The directory the environment lives in, which the host reads. */
65
+ directory;
66
+ root;
67
+ supervisor;
68
+ secretOf;
69
+ clock;
70
+ index = { revision: 0, items: [] };
71
+ /** Every environment revision, oldest first. */
72
+ log = [];
73
+ ready;
74
+ constructor(options) {
75
+ this.root = options.root;
76
+ this.mount = options.mount ?? ENV_PREFIX;
77
+ this.directory = join(options.root, "env");
78
+ this.supervisor = options.supervisor;
79
+ this.secretOf = options.secretOf;
80
+ this.clock = options.now ?? (() => Date.now());
81
+ }
82
+ async revision() {
83
+ return `e${(await this.read()).revision}`;
84
+ }
85
+ async list() {
86
+ return (await this.read()).items.map((entry) => itemOf(entry));
87
+ }
88
+ async history(item) {
89
+ await this.read();
90
+ if (item === undefined)
91
+ return [...this.log];
92
+ const wanted = itemKey(item);
93
+ return this.log.filter((revision) => revision.changes.some((change) => itemKey(change.item.ref) === wanted));
94
+ }
95
+ async settings() {
96
+ const index = await this.read();
97
+ const entry = index.items.find((one) => one.kind === "settings");
98
+ if (entry === undefined)
99
+ return undefined;
100
+ const text = await readText(join(this.root, entry.target));
101
+ if (text === null)
102
+ return undefined;
103
+ return JSON.parse(text);
104
+ }
105
+ async validate(drafts) {
106
+ await this.read();
107
+ const staged = await this.stage(drafts);
108
+ const items = [];
109
+ for (const draft of staged) {
110
+ const outcome = await this.check(draft);
111
+ items.push({ name: draft.name, ok: outcome.ok, ...(outcome.ok ? {} : { error: outcome.error }) });
112
+ await discard(draft);
113
+ }
114
+ return { ok: items.every((item) => item.ok), items };
115
+ }
116
+ async install(drafts, remove) {
117
+ const index = await this.read();
118
+ const removals = [...(remove ?? [])];
119
+ if (drafts.length === 0 && removals.length === 0) {
120
+ throw arborError({
121
+ code: "invalid_input",
122
+ rule: "env.empty",
123
+ message: "reload:install 和 remove 至少要给一个",
124
+ nextAction: "给出 install 或 remove 之一。",
125
+ });
126
+ }
127
+ // Everything is staged and checked before anything is replaced, so a refusal
128
+ // leaves the environment exactly as it was.
129
+ const staged = await this.stage(drafts);
130
+ const checked = await this.checkAll(staged);
131
+ const leaving = this.leaving(removals, index);
132
+ this.checkRevisions(checked, index);
133
+ const time = this.clock();
134
+ const changes = [];
135
+ for (const draft of checked) {
136
+ const previous = index.items.find((entry) => entry.kind === draft.draft.kind && entry.name === draft.draft.name);
137
+ const entry = await this.apply(draft, previous, time);
138
+ const at = index.items.findIndex((one) => one.kind === entry.kind && one.name === entry.name);
139
+ if (at === -1)
140
+ index.items.push(entry);
141
+ else
142
+ index.items[at] = entry;
143
+ changes.push({
144
+ change: previous === undefined ? "added" : "changed",
145
+ item: itemOf(entry),
146
+ });
147
+ }
148
+ for (const entry of leaving) {
149
+ await this.removeItem(entry);
150
+ index.items = index.items.filter((one) => one.ref !== entry.ref);
151
+ changes.push({ change: "removed", item: itemOf(entry) });
152
+ }
153
+ index.revision += 1;
154
+ const revision = {
155
+ ref: `e${index.revision}`,
156
+ previous: this.log.at(-1)?.ref ?? null,
157
+ time,
158
+ window: null,
159
+ changes,
160
+ };
161
+ this.log.push(revision);
162
+ await this.write(index);
163
+ return revision;
164
+ }
165
+ async remove(items) {
166
+ return this.install([], items);
167
+ }
168
+ async delta(since) {
169
+ const index = await this.read();
170
+ const from = revisionNumber(since);
171
+ const current = `e${index.revision}`;
172
+ if (from >= index.revision)
173
+ return { revision: current, previous: since, items: [] };
174
+ // What the window had when it opened, walked from the log rather than
175
+ // guessed: an item revision and an environment revision are different
176
+ // numbers.
177
+ const had = new Set();
178
+ const latest = new Map();
179
+ for (const revision of this.log) {
180
+ const at = revisionNumber(revision.ref);
181
+ for (const change of revision.changes) {
182
+ const key = itemKey(change.item.ref);
183
+ if (at <= from) {
184
+ if (change.change === "removed")
185
+ had.delete(key);
186
+ else
187
+ had.add(key);
188
+ continue;
189
+ }
190
+ latest.set(key, { change: change.change, item: change.item });
191
+ }
192
+ }
193
+ const items = [];
194
+ for (const key of [...latest.keys()].sort()) {
195
+ const change = latest.get(key);
196
+ if (change === undefined)
197
+ continue;
198
+ const now = index.items.find((entry) => itemKey(entry.ref) === key);
199
+ const was = had.has(key);
200
+ // An item added and removed again inside the window never reached it, so
201
+ // it is not named at all.
202
+ if (now === undefined && !was)
203
+ continue;
204
+ if (now === undefined) {
205
+ items.push({ change: "removed", item: change.item });
206
+ continue;
207
+ }
208
+ if (was && change.change === "added")
209
+ continue;
210
+ items.push({ change: was ? "changed" : "added", item: itemOf(now) });
211
+ }
212
+ return { revision: current, previous: since, items };
213
+ }
214
+ /** Start every installed service again, after a host restart. */
215
+ async resume() {
216
+ const index = await this.read();
217
+ if (this.supervisor === undefined)
218
+ return;
219
+ for (const entry of index.items) {
220
+ if (entry.kind !== "service" || entry.spec === undefined)
221
+ continue;
222
+ await this.supervisor.start(entry.spec).catch(() => { });
223
+ }
224
+ }
225
+ async call(server, fn, args) {
226
+ const index = await this.read();
227
+ const entry = index.items.find((one) => one.kind === "mcp" && one.name === server);
228
+ if (entry === undefined) {
229
+ throw arborError({
230
+ code: "not_found",
231
+ rule: "env.notfound",
232
+ message: `环境:没有 MCP 服务器 ${server}`,
233
+ nextAction: "用 env.list 查看当前安装的服务器。",
234
+ details: { server },
235
+ });
236
+ }
237
+ const spec = entry.spec;
238
+ if (spec === undefined || spec.command === "" || spec.dir === undefined) {
239
+ throw arborError({
240
+ code: "unavailable",
241
+ rule: "mcp.url.unsupported",
242
+ message: `环境:MCP 服务器 ${server} 是一个远程地址,本实现只调用 stdio 服务器`,
243
+ nextAction: "把该服务器换成本地 command 形式,或给主机换一个 Environment 实现。",
244
+ details: { server },
245
+ });
246
+ }
247
+ // A server is started for the call and stopped after it: an MCP server is a
248
+ // capability, not a resident service, so it holds no pipe open on the host
249
+ // between calls. A server that died is started again by the next call.
250
+ const running = await this.serve(entry.name, spec);
251
+ try {
252
+ return await running.call(fn, args);
253
+ }
254
+ finally {
255
+ await running.close();
256
+ }
257
+ }
258
+ /** Start one MCP server for one call. */
259
+ async serve(name, spec) {
260
+ return StdioMcpServer.start({
261
+ name,
262
+ dir: spec.dir,
263
+ command: spec.command,
264
+ ...(spec.args === undefined ? {} : { args: spec.args }),
265
+ ...(spec.secrets === undefined ? {} : { secrets: spec.secrets }),
266
+ ...(spec.env === undefined ? {} : { env: spec.env }),
267
+ ...(this.secretOf === undefined ? {} : { secretOf: this.secretOf }),
268
+ });
269
+ }
270
+ // ---- staging and validation --------------------------------------------
271
+ /** Copy every draft into the staging area, so nothing is read in place. */
272
+ async stage(drafts) {
273
+ const staged = [];
274
+ for (const draft of drafts) {
275
+ const from = isAbsolute(draft.path) ? draft.path : resolve(process.cwd(), draft.path);
276
+ const info = await stat(from).catch(() => null);
277
+ if (info === null) {
278
+ throw arborError({
279
+ code: "not_found",
280
+ rule: "env.draft.missing",
281
+ message: `环境:草稿 ${draft.path} 不存在`,
282
+ nextAction: "用 workspace.ls 查看工作区里的草稿路径。",
283
+ details: { path: draft.path },
284
+ });
285
+ }
286
+ const target = join(this.root, "staging", `${staged.length}-${draft.kind}-${safeName(draft.name)}`);
287
+ await rm(target, { recursive: true, force: true });
288
+ if (info.isDirectory()) {
289
+ await cp(from, target, { recursive: true });
290
+ staged.push({ ...draft, path: target, directory: true });
291
+ continue;
292
+ }
293
+ await mkdir(target, { recursive: true });
294
+ await cp(from, join(target, `${draft.name}.json`));
295
+ staged.push({ ...draft, path: target, directory: false });
296
+ }
297
+ return staged;
298
+ }
299
+ /** Validate every staged draft, or refuse with the first one that failed. */
300
+ async checkAll(staged) {
301
+ const checked = [];
302
+ for (const draft of staged) {
303
+ const outcome = await this.check(draft);
304
+ if (outcome.ok) {
305
+ checked.push({ draft, ...outcome.value });
306
+ continue;
307
+ }
308
+ for (const one of staged)
309
+ await discard(one);
310
+ throw arborError({
311
+ code: outcome.error.code,
312
+ rule: outcome.error.rule,
313
+ message: outcome.error.message,
314
+ ...(outcome.error.nextAction === undefined ? {} : { nextAction: outcome.error.nextAction }),
315
+ ...(outcome.error.details === undefined ? {} : { details: outcome.error.details }),
316
+ });
317
+ }
318
+ return checked;
319
+ }
320
+ /** One staged draft: its description, and what its kind needs beyond that. */
321
+ async check(draft) {
322
+ try {
323
+ if (draft.kind === "skill")
324
+ return { ok: true, value: { description: await this.checkSkill(draft) } };
325
+ if (draft.kind === "settings") {
326
+ await this.checkSettings(draft);
327
+ return { ok: true, value: { description: SETTINGS_DESCRIPTION } };
328
+ }
329
+ if (draft.kind === "mcp")
330
+ return { ok: true, value: await this.checkMcp(draft) };
331
+ return { ok: true, value: await this.checkService(draft) };
332
+ }
333
+ catch (error) {
334
+ return { ok: false, error: errorInfo(error, draft) };
335
+ }
336
+ }
337
+ /** A skill parses: a title, then one line that says what it is for. */
338
+ async checkSkill(draft) {
339
+ const text = await readText(join(draft.path, "SKILL.md"));
340
+ if (text === null) {
341
+ throw arborError({
342
+ code: "invalid_input",
343
+ rule: "env.draft.skill",
344
+ message: `环境:草稿 ${draft.name} 中没有 SKILL.md`,
345
+ nextAction: "读 /env/skills/arbor-skill/SKILL.md 的「结构」一节,按它补上 SKILL.md。",
346
+ details: { name: draft.name },
347
+ });
348
+ }
349
+ const lines = text.split("\n").map((line) => line.trim());
350
+ if (lines[0]?.startsWith("# ") !== true) {
351
+ throw arborError({
352
+ code: "invalid_input",
353
+ rule: "env.draft.skill",
354
+ message: `环境:技能 ${draft.name} 的第一行不是一级标题`,
355
+ nextAction: "读 /env/skills/arbor-skill/SKILL.md 的「结构」一节,把第一行写成 # 标题。",
356
+ details: { name: draft.name },
357
+ });
358
+ }
359
+ const description = lines.slice(1).find((line) => line !== "") ?? "";
360
+ if (description === "") {
361
+ throw arborError({
362
+ code: "invalid_input",
363
+ rule: "env.draft.skill",
364
+ message: `环境:技能 ${draft.name} 没有一行说明`,
365
+ nextAction: "读 /env/skills/arbor-skill/SKILL.md 的「结构」一节,在标题下写一行说明。",
366
+ details: { name: draft.name },
367
+ });
368
+ }
369
+ return description;
370
+ }
371
+ /** The settings parse into the settings shape. */
372
+ async checkSettings(draft) {
373
+ const text = await readText(join(draft.path, `${draft.name}.json`));
374
+ if (text === null) {
375
+ throw arborError({
376
+ code: "invalid_input",
377
+ rule: "env.draft.settings",
378
+ message: `环境:草稿 ${draft.path} 中没有 ${draft.name}.json`,
379
+ nextAction: "读 /env/skills/arbor-settings/SKILL.md 的「结构」一节,按它写设置文件。",
380
+ details: { name: draft.name },
381
+ });
382
+ }
383
+ let parsed;
384
+ try {
385
+ parsed = JSON.parse(text);
386
+ }
387
+ catch (error) {
388
+ throw arborError({
389
+ code: "invalid_input",
390
+ rule: "env.draft.settings",
391
+ message: `环境:设置文件解析失败:${error instanceof Error ? error.message : String(error)}`,
392
+ nextAction: "读 /env/skills/arbor-settings/SKILL.md 的「结构」一节,修正 JSON 后重新调用 reload。",
393
+ });
394
+ }
395
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
396
+ throw arborError({
397
+ code: "invalid_input",
398
+ rule: "env.draft.settings",
399
+ message: "环境:设置文件必须是一个 JSON 对象",
400
+ nextAction: "读 /env/skills/arbor-settings/SKILL.md 的「结构」一节,按字段写成一个对象。",
401
+ });
402
+ }
403
+ }
404
+ /**
405
+ * An MCP server starts and lists its functions. It runs from the staged
406
+ * directory, so a draft that brought its own code is validated exactly as it
407
+ * will run once installed.
408
+ */
409
+ async checkMcp(draft) {
410
+ const config = await readJson(join(draft.path, `${draft.name}.json`));
411
+ if (config === null) {
412
+ throw arborError({
413
+ code: "invalid_input",
414
+ rule: "env.draft.mcp",
415
+ message: `环境:MCP 服务器 ${draft.name} 的配置缺失或不是 JSON`,
416
+ nextAction: "读 /env/skills/arbor-mcp/SKILL.md 的「结构」一节,按它写配置文件。",
417
+ details: { name: draft.name },
418
+ });
419
+ }
420
+ const command = stringField(config, "command");
421
+ const description = stringField(config, "description") || MCP_DESCRIPTION;
422
+ if (command === "" && stringField(config, "url") === "") {
423
+ throw arborError({
424
+ code: "invalid_input",
425
+ rule: "env.draft.mcp",
426
+ message: `环境:MCP 服务器 ${draft.name} 的配置中没有 command 或 url`,
427
+ nextAction: "读 /env/skills/arbor-mcp/SKILL.md 的「结构」一节,补上 command 或 url。",
428
+ details: { name: draft.name },
429
+ });
430
+ }
431
+ const spec = {
432
+ name: draft.name,
433
+ command,
434
+ ...(stringsField(config, "args") === undefined ? {} : { args: stringsField(config, "args") }),
435
+ ...(stringsField(config, "secrets") === undefined ? {} : { secrets: stringsField(config, "secrets") }),
436
+ ...(objectField(config, "env") === undefined ? {} : { env: objectField(config, "env") }),
437
+ };
438
+ if (command === "") {
439
+ // A server already running elsewhere: its shape is all this
440
+ // implementation can check without reaching the network.
441
+ return { description, spec };
442
+ }
443
+ const server = await StdioMcpServer.start({
444
+ dir: draft.path,
445
+ ...spec,
446
+ ...(this.secretOf === undefined ? {} : { secretOf: this.secretOf }),
447
+ }).catch((error) => {
448
+ throw arborError({
449
+ code: "unavailable",
450
+ rule: "env.draft.mcp",
451
+ message: `环境:MCP 服务器 ${draft.name} 启动失败`,
452
+ nextAction: "读 /env/skills/arbor-mcp/SKILL.md 的「自测」一节,把服务器在本窗口工作区里跑通后再调用 reload。",
453
+ details: { name: draft.name, upstream: error instanceof Error ? error.message : String(error) },
454
+ cause: error,
455
+ });
456
+ });
457
+ try {
458
+ return { description, functions: await server.functions(), spec };
459
+ }
460
+ finally {
461
+ await server.close();
462
+ }
463
+ }
464
+ /** A service's configuration parses, and it is started when it installs. */
465
+ async checkService(draft) {
466
+ const config = await readJson(join(draft.path, "svc.json"));
467
+ if (config === null) {
468
+ throw arborError({
469
+ code: "invalid_input",
470
+ rule: "env.draft.service",
471
+ message: `环境:服务 ${draft.name} 的配置缺失或不是 JSON`,
472
+ nextAction: "读 /env/skills/arbor-service/SKILL.md 的「结构」一节,按它写 svc.json。",
473
+ details: { name: draft.name },
474
+ });
475
+ }
476
+ const command = stringField(config, "command");
477
+ if (command === "") {
478
+ throw arborError({
479
+ code: "invalid_input",
480
+ rule: "env.draft.service",
481
+ message: `环境:服务 ${draft.name} 的配置中没有 command`,
482
+ nextAction: "读 /env/skills/arbor-service/SKILL.md 的「结构」一节,补上 command。",
483
+ details: { name: draft.name },
484
+ });
485
+ }
486
+ if (this.supervisor === undefined) {
487
+ throw arborError({
488
+ code: "unavailable",
489
+ rule: "env.supervisor.absent",
490
+ message: `环境:本主机没有配置服务监督器,无法安装服务 ${draft.name}`,
491
+ nextAction: "移除该服务项,或在配置了监督器的主机上安装它。",
492
+ details: { name: draft.name },
493
+ });
494
+ }
495
+ const healthCheck = config.healthCheck;
496
+ const restart = config.restart;
497
+ const quota = config.quota;
498
+ const args = stringsField(config, "args");
499
+ const ports = numbersField(config, "ports");
500
+ const secrets = stringsField(config, "secrets");
501
+ const env = objectField(config, "env");
502
+ return {
503
+ description: stringField(config, "description") || `一个常驻服务:${command}`,
504
+ spec: {
505
+ name: draft.name,
506
+ command,
507
+ ...(args === undefined ? {} : { args }),
508
+ ...(ports === undefined ? {} : { ports }),
509
+ ...(healthCheck === undefined ? {} : { healthCheck }),
510
+ ...(secrets === undefined ? {} : { secrets }),
511
+ ...(restart === undefined ? {} : { restart }),
512
+ ...(quota === undefined ? {} : { quota }),
513
+ ...(env === undefined ? {} : { env }),
514
+ },
515
+ };
516
+ }
517
+ // ---- applying a change --------------------------------------------------
518
+ /**
519
+ * Replace one item as a whole, then start a service and wait for its health
520
+ * check. A service that never becomes healthy is rolled back to the revision
521
+ * it had, so a refused `reload` changes nothing.
522
+ */
523
+ async apply(draft, previous, time) {
524
+ const revision = (previous?.revision ?? 0) + 1;
525
+ const entry = {
526
+ ref: `${KIND_PREFIX[draft.draft.kind]}:${draft.draft.name}@${revision}`,
527
+ kind: draft.draft.kind,
528
+ name: draft.draft.name,
529
+ description: draft.description,
530
+ revision,
531
+ time,
532
+ ...(draft.functions === undefined ? {} : { functions: draft.functions }),
533
+ target: targetOf(draft.draft),
534
+ };
535
+ if (draft.draft.kind === "skill")
536
+ entry.path = `${this.mount}/skills/${draft.draft.name}`;
537
+ if (draft.draft.kind === "mcp") {
538
+ entry.path = draft.draft.directory
539
+ ? `${this.mount}/mcp/${draft.draft.name}/${draft.draft.name}.json`
540
+ : `${this.mount}/mcp/${draft.draft.name}.json`;
541
+ }
542
+ if (draft.draft.kind === "service") {
543
+ entry.path = `${this.mount}/services/${draft.draft.name}/svc.json`;
544
+ entry.spec = { ...draft.spec, dir: join(this.root, entry.target) };
545
+ }
546
+ if (draft.draft.kind === "settings")
547
+ entry.path = `${this.mount}/settings.json`;
548
+ if (draft.draft.kind === "mcp" && draft.spec !== undefined) {
549
+ entry.spec = {
550
+ ...draft.spec,
551
+ // The command runs in the item's own directory, so a server that brought
552
+ // its code finds it where it installed it.
553
+ dir: draft.draft.directory
554
+ ? join(this.root, entry.target)
555
+ : dirname(join(this.root, entry.target)),
556
+ };
557
+ }
558
+ // The revision the item had is kept before it is replaced, so a failed
559
+ // install can put it back.
560
+ if (previous !== undefined)
561
+ await this.keepRevision(previous);
562
+ await copyTree(configOf(draft.draft), join(this.root, entry.target));
563
+ if (draft.draft.kind === "service" && this.supervisor !== undefined && entry.spec !== undefined) {
564
+ try {
565
+ await this.supervisor.start(entry.spec);
566
+ }
567
+ catch (error) {
568
+ await this.rollback(entry, previous);
569
+ throw arborError({
570
+ code: "unavailable",
571
+ rule: "env.draft.service",
572
+ message: `环境:服务 ${draft.draft.name} 没有通过健康检查`,
573
+ nextAction: "用 services.logs 查看服务的输出,修好后重新调用 reload。",
574
+ details: { name: draft.draft.name, upstream: error instanceof Error ? error.message : String(error) },
575
+ cause: error,
576
+ });
577
+ }
578
+ }
579
+ await this.keepRevision(entry);
580
+ return entry;
581
+ }
582
+ /** Put the revision an item had back, after a failed install. */
583
+ async rollback(entry, previous) {
584
+ const target = join(this.root, entry.target);
585
+ await rm(target, { recursive: true, force: true });
586
+ if (previous === undefined)
587
+ return;
588
+ await copyTree(join(this.root, "revisions", KIND_PREFIX[previous.kind], previous.name, `${previous.revision}`), target);
589
+ }
590
+ /** Stop a service and remove its files. */
591
+ async removeItem(entry) {
592
+ if (entry.kind === "service" && this.supervisor !== undefined) {
593
+ await this.supervisor.stop(entry.name).catch(() => { });
594
+ }
595
+ await rm(join(this.root, entry.target), { recursive: true, force: true });
596
+ }
597
+ /** Keep one copy of an installed revision, so a revert can install it again. */
598
+ async keepRevision(entry) {
599
+ await copyTree(join(this.root, entry.target), join(this.root, "revisions", KIND_PREFIX[entry.kind], entry.name, `${entry.revision}`));
600
+ }
601
+ /**
602
+ * The revision check (docs/environment.md#changing-the-environment): a draft
603
+ * is based on the revision its window read, and another window that installed
604
+ * a newer one since refuses the call, so the model merges and calls again.
605
+ */
606
+ checkRevisions(checked, index) {
607
+ for (const draft of checked) {
608
+ const installed = index.items.find((entry) => entry.kind === draft.draft.kind && entry.name === draft.draft.name);
609
+ const read = draft.draft.copiedFrom;
610
+ if (installed === undefined || read === undefined || installed.revision <= read)
611
+ continue;
612
+ throw arborError({
613
+ code: "conflict",
614
+ rule: "env.draft.stale",
615
+ message: `环境:${installed.ref} 已被其他窗口更新到第 ${installed.revision} 版,草稿基于旧的 ${read} 版`,
616
+ nextAction: `读 ${BUILTIN_SKILL_PATH[draft.draft.kind]} 的「修订冲突」一节,把新旧内容合并进草稿,再重新调用 reload。`,
617
+ details: { item: installed.ref, revision: installed.revision, read },
618
+ });
619
+ }
620
+ }
621
+ /** The items a `reload({ remove })` names, or a refusal naming what is missing. */
622
+ leaving(removals, index) {
623
+ const leaving = [];
624
+ for (const wanted of removals) {
625
+ const key = itemKey(wanted);
626
+ const entry = index.items.find((one) => itemKey(one.ref) === key);
627
+ if (entry === undefined) {
628
+ throw arborError({
629
+ code: "not_found",
630
+ rule: "env.notfound",
631
+ message: `环境:没有环境项 ${wanted}`,
632
+ nextAction: "用 env.list 查看当前的环境项和它们的引用。",
633
+ details: { item: wanted },
634
+ });
635
+ }
636
+ if (entry.builtin === true) {
637
+ throw arborError({
638
+ code: "refused",
639
+ rule: "env.readonly",
640
+ message: `环境:${entry.ref} 是框架内置的技能,只读`,
641
+ nextAction: "内置技能只读;要改就写一个自己的技能,用 env.list 看它的路径。",
642
+ details: { item: entry.ref },
643
+ });
644
+ }
645
+ if (!leaving.includes(entry))
646
+ leaving.push(entry);
647
+ }
648
+ return leaving;
649
+ }
650
+ // ---- the index ----------------------------------------------------------
651
+ /** The index, seeded with the built-in skills on first use. */
652
+ async read() {
653
+ if (this.ready === undefined) {
654
+ this.ready = this.load().catch((error) => {
655
+ this.ready = undefined;
656
+ throw error;
657
+ });
658
+ }
659
+ await this.ready;
660
+ return this.index;
661
+ }
662
+ async load() {
663
+ const text = await readText(join(this.root, "index.json"));
664
+ if (text !== null) {
665
+ const parsed = JSON.parse(text);
666
+ this.index = { revision: parsed.revision ?? 0, items: parsed.items ?? [] };
667
+ const log = await readText(join(this.root, "revisions.json"));
668
+ this.log = log === null ? [] : JSON.parse(log);
669
+ }
670
+ await this.seedBuiltins();
671
+ await this.write(this.index);
672
+ }
673
+ /** Copy the built-in skills in, read-only, when they are not there yet. */
674
+ async seedBuiltins() {
675
+ const here = fileURLToPath(new URL(".", import.meta.url));
676
+ for (const name of BUILTIN_SKILLS) {
677
+ if (this.index.items.some((entry) => entry.name === name))
678
+ continue;
679
+ const target = join(this.root, "env", "skills", name);
680
+ await mkdir(dirname(target), { recursive: true });
681
+ await cp(join(here, "skills", name), target, { recursive: true });
682
+ const text = await readText(join(target, "SKILL.md"));
683
+ const lines = (text ?? "").split("\n").map((line) => line.trim());
684
+ const entry = {
685
+ ref: `sk:${name}@1`,
686
+ kind: "skill",
687
+ name,
688
+ description: lines.slice(1).find((line) => line !== "") ?? "",
689
+ path: `${this.mount}/skills/${name}`,
690
+ revision: 1,
691
+ time: this.clock(),
692
+ builtin: true,
693
+ target: join("env", "skills", name),
694
+ };
695
+ this.index.items.push(entry);
696
+ await this.keepRevision(entry);
697
+ }
698
+ }
699
+ async write(index) {
700
+ await mkdir(this.root, { recursive: true });
701
+ await writeFile(join(this.root, "index.json"), `${JSON.stringify(index, null, 2)}\n`, "utf8");
702
+ await writeFile(join(this.root, "revisions.json"), `${JSON.stringify(this.log, null, 2)}\n`, "utf8");
703
+ }
704
+ }
705
+ /** One index entry as every other layer sees it. */
706
+ function itemOf(entry) {
707
+ return {
708
+ ref: entry.ref,
709
+ kind: entry.kind,
710
+ name: entry.name,
711
+ description: entry.description,
712
+ ...(entry.path === undefined ? {} : { path: entry.path }),
713
+ revision: entry.revision,
714
+ time: entry.time,
715
+ ...(entry.builtin === undefined ? {} : { builtin: entry.builtin }),
716
+ ...(entry.functions === undefined ? {} : { functions: entry.functions }),
717
+ };
718
+ }
719
+ /** `sk:deploy@4` and `skill:deploy` both address the item `sk:deploy`. */
720
+ function itemKey(ref) {
721
+ const match = /^(sk|mcp|svc|set|skill|server|service|settings):([^@]+)/.exec(ref);
722
+ if (match === null)
723
+ return ref;
724
+ const kind = KIND_ALIASES[match[1] ?? ""] ?? "skill";
725
+ return `${KIND_PREFIX[kind]}:${match[2] ?? ""}`;
726
+ }
727
+ /** An environment revision's number, so the log can be walked. */
728
+ function revisionNumber(ref) {
729
+ return Number(/^e(\d+)$/.exec(ref)?.[1] ?? 0);
730
+ }
731
+ /**
732
+ * Where an item's own files live, relative to the root. An MCP server that
733
+ * brought its own code keeps the whole directory, so its command runs beside it.
734
+ */
735
+ function targetOf(draft) {
736
+ if (draft.kind === "settings")
737
+ return join("env", `${draft.name}.json`);
738
+ if (draft.kind === "skill")
739
+ return join("env", "skills", draft.name);
740
+ if (draft.kind === "service")
741
+ return join("env", "services", draft.name);
742
+ return draft.directory ? join("env", "mcp", draft.name) : join("env", "mcp", `${draft.name}.json`);
743
+ }
744
+ /** The file a staged draft holds its configuration in. */
745
+ function configOf(draft) {
746
+ return draft.directory ? draft.path : join(draft.path, `${draft.name}.json`);
747
+ }
748
+ /** Copy a file or a whole directory onto a path, replacing what was there. */
749
+ async function copyTree(from, to) {
750
+ const info = await stat(from).catch(() => null);
751
+ if (info === null)
752
+ return;
753
+ if (info.isDirectory()) {
754
+ await rm(to, { recursive: true, force: true });
755
+ await mkdir(dirname(to), { recursive: true });
756
+ await cp(from, to, { recursive: true });
757
+ return;
758
+ }
759
+ await mkdir(dirname(to), { recursive: true });
760
+ await cp(from, to);
761
+ }
762
+ /** Clean up one staged draft, whether it was installed or refused. */
763
+ async function discard(draft) {
764
+ await rm(draft.path, { recursive: true, force: true }).catch(() => { });
765
+ }
766
+ function errorInfo(error, draft) {
767
+ const info = error;
768
+ if (info.rule === undefined) {
769
+ return {
770
+ code: "invalid_input",
771
+ rule: "env.draft",
772
+ message: `环境:草稿 ${draft.name} 无效:${error instanceof Error ? error.message : String(error)}`,
773
+ nextAction: "修正草稿后重新调用 reload。",
774
+ };
775
+ }
776
+ return {
777
+ code: info.code ?? "invalid_input",
778
+ rule: info.rule,
779
+ message: info.message ?? "",
780
+ ...(info.nextAction === undefined ? {} : { nextAction: info.nextAction }),
781
+ ...(info.details === undefined ? {} : { details: info.details }),
782
+ };
783
+ }
784
+ async function readText(path) {
785
+ try {
786
+ return await readFile(path, "utf8");
787
+ }
788
+ catch {
789
+ return null;
790
+ }
791
+ }
792
+ async function readJson(path) {
793
+ const text = await readText(path);
794
+ if (text === null)
795
+ return null;
796
+ try {
797
+ const parsed = JSON.parse(text);
798
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
799
+ ? parsed
800
+ : null;
801
+ }
802
+ catch {
803
+ return null;
804
+ }
805
+ }
806
+ function stringField(config, key) {
807
+ const value = config[key];
808
+ return typeof value === "string" ? value : "";
809
+ }
810
+ function stringsField(config, key) {
811
+ const value = config[key];
812
+ if (!Array.isArray(value))
813
+ return undefined;
814
+ return value.filter((item) => typeof item === "string");
815
+ }
816
+ function numbersField(config, key) {
817
+ const value = config[key];
818
+ if (!Array.isArray(value))
819
+ return undefined;
820
+ return value.filter((item) => typeof item === "number" && Number.isFinite(item));
821
+ }
822
+ function objectField(config, key) {
823
+ const value = config[key];
824
+ if (typeof value !== "object" || value === null || Array.isArray(value))
825
+ return undefined;
826
+ return value;
827
+ }
828
+ function safeName(name) {
829
+ return name.replace(/[^a-zA-Z0-9._-]/g, "_");
830
+ }