@nelsonlongxiang/dsh-open-a2a-net 0.6.30 → 0.6.32

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.
Files changed (89) hide show
  1. package/README.md +4 -1
  2. package/README_zh.md +3 -0
  3. package/assets/nexusDist/assets/index-D5H8R8de.js +9 -0
  4. package/assets/nexusDist/assets/index-D9GYZyGZ.css +1 -0
  5. package/assets/nexusDist/index.html +2 -2
  6. package/cli/a2a.mjs +445 -57
  7. package/lib/a2a-client.js +9 -4
  8. package/lib/auth-observer.js +254 -0
  9. package/lib/blob-store.js +210 -0
  10. package/lib/card.js +4 -2
  11. package/lib/cli-deposit.js +122 -7
  12. package/lib/client.js +64 -64
  13. package/lib/client.js.map +1 -1
  14. package/lib/faces/admin.js +133 -43
  15. package/lib/faces/canvas-notify.js +12 -3
  16. package/lib/faces/files.js +155 -0
  17. package/lib/faces/nodes.js +171 -0
  18. package/lib/faces/session-log.js +162 -0
  19. package/lib/faces/wire.js +206 -39
  20. package/lib/index.js +809 -1720
  21. package/lib/joined-store.js +23 -0
  22. package/lib/ledger-conservation.js +97 -0
  23. package/lib/legacy/teams-bridge-inbound.js +156 -0
  24. package/lib/node-bind.js +534 -0
  25. package/lib/node-github.js +72 -0
  26. package/lib/node-self.js +453 -0
  27. package/lib/node-store.js +484 -0
  28. package/lib/node-tasks.js +342 -0
  29. package/lib/origin-claim.js +246 -0
  30. package/lib/peer-directory.js +134 -0
  31. package/lib/receipt.js +26 -1
  32. package/lib/skill/SKILL.md +3 -1
  33. package/lib/skill/references/cli.md +82 -3
  34. package/lib/skill/references/collaboration.md +146 -73
  35. package/lib/skill/references/discovery.md +29 -0
  36. package/lib/skill/references/intake.md +107 -0
  37. package/lib/skill/references/router.md +96 -0
  38. package/lib/skill/references/setup.md +2 -1
  39. package/lib/task-auth.js +483 -0
  40. package/lib/task-ledger.js +324 -12
  41. package/lib/team-store.js +24 -1
  42. package/lib/tools/act.js +383 -0
  43. package/lib/tools/files.js +201 -0
  44. package/lib/tools/info.js +684 -0
  45. package/lib/types/a2a-client.d.ts +10 -4
  46. package/lib/types/auth-observer.d.ts +110 -0
  47. package/lib/types/blob-store.d.ts +83 -0
  48. package/lib/types/cli-deposit.d.ts +38 -0
  49. package/lib/types/faces/admin.d.ts +14 -2
  50. package/lib/types/faces/canvas-notify.d.ts +4 -1
  51. package/lib/types/faces/files.d.ts +53 -0
  52. package/lib/types/faces/nodes.d.ts +69 -0
  53. package/lib/types/faces/session-log.d.ts +44 -0
  54. package/lib/types/faces/wire.d.ts +50 -5
  55. package/lib/types/index.d.ts +27 -0
  56. package/lib/types/joined-store.d.ts +9 -0
  57. package/lib/types/ledger-conservation.d.ts +77 -0
  58. package/lib/types/legacy/teams-bridge-inbound.d.ts +94 -0
  59. package/lib/types/node-bind.d.ts +220 -0
  60. package/lib/types/node-github.d.ts +35 -0
  61. package/lib/types/node-self.d.ts +178 -0
  62. package/lib/types/node-store.d.ts +180 -0
  63. package/lib/types/node-tasks.d.ts +121 -0
  64. package/lib/types/origin-claim.d.ts +119 -0
  65. package/lib/types/peer-directory.d.ts +39 -0
  66. package/lib/types/receipt.d.ts +12 -0
  67. package/lib/types/task-auth.d.ts +271 -0
  68. package/lib/types/task-ledger.d.ts +143 -6
  69. package/lib/types/team-store.d.ts +9 -0
  70. package/lib/types/tools/act.d.ts +52 -0
  71. package/lib/types/tools/files.d.ts +38 -0
  72. package/lib/types/tools/info.d.ts +102 -0
  73. package/lib/types/types.d.ts +6 -0
  74. package/nexus-stage/src/canvas-ops.ts +17 -2
  75. package/nexus-stage/src/canvas-wire.ts +27 -19
  76. package/nexus-stage/src/federation.ts +1 -0
  77. package/nexus-stage/src/main.ts +41 -18
  78. package/nexus-stage/src/planning-member-handles.ts +34 -0
  79. package/nexus-stage/src/planning-mirror.ts +47 -3
  80. package/nexus-stage/src/planning-reachability.ts +28 -0
  81. package/nexus-stage/src/planning-types.ts +1 -1
  82. package/nexus-stage/src/planning-view.ts +30 -27
  83. package/nexus-stage/src/planning.css +4 -0
  84. package/nexus-stage/src/wb-directory.ts +13 -0
  85. package/nexus-stage/src/workbench.css +3 -0
  86. package/nexus-stage/src/workbench.ts +5 -1
  87. package/package.json +1 -1
  88. package/assets/nexusDist/assets/index-C_x3iAth.js +0 -9
  89. package/assets/nexusDist/assets/index-Dz8N80tI.css +0 -1
@@ -0,0 +1,484 @@
1
+ /**
2
+ * Node identity store (docs/design/node-identity.md, slice N1): long-lived,
3
+ * human-named nodes that a session can carry, with layered prompt versions.
4
+ *
5
+ * - `nodes.json` under the a2a home holds the node records and the retired
6
+ * names (a name is never reused, and never renamed — §8-1).
7
+ * - Prompt layers live in two namespaces, append-only:
8
+ * `nodes/<name>/core-v<n>.json` (the immutable layer, humans only) and
9
+ * `nodes/<name>/mutable-v<n>.json` (the four-section mutable layer).
10
+ * Every version is a FULL snapshot; the next number is max+1 over the
11
+ * files on disk, so a write after a rollback never collides; a write must
12
+ * name the sha it expects to replace (CAS — parallel sessions of one node).
13
+ * - Identity writes (create / update / archive / core / rollback) are
14
+ * gated by the caller (the nodes face requires apiKey even on loopback,
15
+ * §8-2); this module only validates shapes and keeps the chain closed.
16
+ * - Never-reuse survives a lost roster: create makes the node's version
17
+ * directory at once, and a name with a directory counts as used. A roster
18
+ * that does not parse, or parses into the wrong shape, is copied to
19
+ * `nodes.json.corrupt-<ts>` and LEFT IN PLACE; the store is READ-ONLY for
20
+ * as long as it stays unreadable (every restart), so it is never silently
21
+ * overwritten. Entries dropped as malformed are backed up first
22
+ * (`nodes.json.dropped-<ts>`). Writes go to disk first (tmp + rename) and
23
+ * only then into memory.
24
+ * @module @nelsonlongxiang/dsh-open-a2a-net/node-store
25
+ */
26
+ import { createHash } from 'node:crypto';
27
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
28
+ import { join } from 'node:path';
29
+ /** §8-1: node names. */
30
+ export const NODE_NAME = /^[a-z][a-z0-9-]{1,31}$/;
31
+ /** Upper bound on live nodes per host (a roster, not a database). */
32
+ export const NODE_CAP = 64;
33
+ /** The four mutable sections, in prompt order (§5). */
34
+ export const MUTABLE_SECTIONS = ['当前状态', '执行要点', '已知陷阱', '当前重点'];
35
+ /** §8-9 quotas, in characters (code points, not UTF-16 units). */
36
+ export const QUOTA = { core: 2000, mutable: 3000, mutableSection: 1000 };
37
+ const chars = (text) => [...text].length;
38
+ const ROLES = new Set(['dev', 'review', 'supervise', 'ops', 'intake', 'test', 'design', 'chief']);
39
+ const LABEL_MAX = 64;
40
+ const TEAM_MAX = 16;
41
+ const fail = (code, error) => ({ ok: false, code, error });
42
+ /** The sha a version is pinned by: canonical JSON of {version, sections} (keys sorted). */
43
+ export function promptSha(version, sections) {
44
+ const sorted = Object.fromEntries(Object.keys(sections).sort().map(key => [key, sections[key]]));
45
+ return createHash('sha256').update(JSON.stringify({ version, sections: sorted }), 'utf8').digest('hex');
46
+ }
47
+ function textList(raw, max, item) {
48
+ if (raw === undefined)
49
+ return [];
50
+ if (!Array.isArray(raw) || raw.length > max)
51
+ return undefined;
52
+ const out = [];
53
+ for (const value of raw) {
54
+ if (typeof value !== 'string' || !item(value))
55
+ return undefined;
56
+ if (!out.includes(value))
57
+ out.push(value);
58
+ }
59
+ return out;
60
+ }
61
+ function carrierOf(raw) {
62
+ if (raw === undefined)
63
+ return { cwd: '', agentPreset: '', model: '' };
64
+ if (raw === null || typeof raw !== 'object')
65
+ return undefined;
66
+ const r = raw;
67
+ const field = (key) => {
68
+ const v = r[key] ?? '';
69
+ return typeof v === 'string' && v.length <= 512 ? v : undefined;
70
+ };
71
+ const cwd = field('cwd'), agentPreset = field('agentPreset'), model = field('model');
72
+ return cwd === undefined || agentPreset === undefined || model === undefined ? undefined : { cwd, agentPreset, model };
73
+ }
74
+ /** A team ref a node may list: a canvas team (`<zone>/canvas/<name>`); no control characters (these reach prompt text in N2/N3). */
75
+ function teamRef(value) {
76
+ return /^[a-z0-9-]+\/canvas\/[^/\p{Cc}]{1,40}$/u.test(value) && value.trim() === value;
77
+ }
78
+ const isText = (v) => typeof v === 'string';
79
+ /** One roster entry as read from disk, or undefined when any field is malformed. */
80
+ function recordOf(raw) {
81
+ if (raw === null || typeof raw !== 'object')
82
+ return undefined;
83
+ const r = raw;
84
+ const p = r.prompt;
85
+ const c = r.carrier;
86
+ const version = (v) => typeof v === 'number' && Number.isSafeInteger(v) && v >= 0;
87
+ if (!isText(r.name) || !NODE_NAME.test(r.name) || !isText(r.label) || !isText(r.created_by) || !isText(r.created_at))
88
+ return undefined;
89
+ if (!Array.isArray(r.role) || !r.role.every(isText) || !Array.isArray(r.teams) || !r.teams.every(isText))
90
+ return undefined;
91
+ if (c === null || c === undefined || typeof c !== 'object' || !isText(c.cwd) || !isText(c.agentPreset) || !isText(c.model))
92
+ return undefined;
93
+ if (p === null || p === undefined || typeof p !== 'object' || !version(p.core_version) || !version(p.mutable_version) || !isText(p.core_sha256) || !isText(p.mutable_sha256))
94
+ return undefined;
95
+ return {
96
+ name: r.name, label: r.label, role: [...r.role], teams: [...r.teams],
97
+ carrier: { cwd: c.cwd, agentPreset: c.agentPreset, model: c.model },
98
+ prompt: { core_version: p.core_version, core_sha256: p.core_sha256, mutable_version: p.mutable_version, mutable_sha256: p.mutable_sha256 },
99
+ created_by: r.created_by, created_at: r.created_at,
100
+ };
101
+ }
102
+ /** Whether a version file is a well-formed frozen version of `layer` (shape, keys, quotas, self-consistent sha). */
103
+ function wellFormed(v, layer) {
104
+ if (v === undefined || v === null || typeof v !== 'object' || v.layer !== layer || typeof v.version !== 'number')
105
+ return false;
106
+ const sections = v.sections;
107
+ if (sections === null || sections === undefined || typeof sections !== 'object')
108
+ return false;
109
+ const keys = Object.keys(sections);
110
+ const expected = layer === 'core' ? ['core'] : [...MUTABLE_SECTIONS];
111
+ if (keys.length !== expected.length || !expected.every(k => typeof sections[k] === 'string'))
112
+ return false;
113
+ const texts = expected.map(k => sections[k]);
114
+ const overQuota = layer === 'core'
115
+ ? chars(texts[0] ?? '') > QUOTA.core
116
+ : texts.some(t => chars(t) > QUOTA.mutableSection) || texts.reduce((n, t) => n + chars(t), 0) > QUOTA.mutable;
117
+ if (overQuota)
118
+ return false;
119
+ return v.sha256 === promptSha(v.version, sections);
120
+ }
121
+ /**
122
+ * Node store. One writer (the nodes face); read fresh by the state face.
123
+ */
124
+ export class NodeStore {
125
+ now;
126
+ nodes = [];
127
+ retired = [];
128
+ root;
129
+ /** Set when the roster on disk could not be read: identity writes are refused until a human repairs it. */
130
+ readOnlyReason = '';
131
+ /** Roster entries dropped on load (malformed), for the face to report. */
132
+ dropped = [];
133
+ /**
134
+ * @param root - the `<home>/a2a` directory; '' keeps everything in memory (tests).
135
+ * @param now - clock seam (ISO timestamps).
136
+ */
137
+ constructor(root, now = () => new Date().toISOString()) {
138
+ this.now = now;
139
+ this.root = root;
140
+ this.restore();
141
+ }
142
+ get file() { return join(this.root, 'nodes.json'); }
143
+ versionDir(name) { return join(this.root, 'nodes', name); }
144
+ restore() {
145
+ if (this.root === '' || !existsSync(this.file))
146
+ return;
147
+ const stamp = this.now().replace(/[:.]/g, '-');
148
+ const keepAside = (suffix) => {
149
+ const aside = `${this.file}.${suffix}-${stamp}`;
150
+ try {
151
+ copyFileSync(this.file, aside);
152
+ }
153
+ catch { /* the original stays in place either way */ }
154
+ return aside;
155
+ };
156
+ let snap;
157
+ try {
158
+ snap = JSON.parse(readFileSync(this.file, 'utf8'));
159
+ }
160
+ catch {
161
+ snap = undefined;
162
+ }
163
+ const shaped = snap !== null && typeof snap === 'object' && !Array.isArray(snap)
164
+ && (snap.nodes === undefined || Array.isArray(snap.nodes))
165
+ && (snap.retired === undefined || Array.isArray(snap.retired));
166
+ if (!shaped) {
167
+ // Never overwrite what could not be read. The original stays where it is, so the store comes
168
+ // back read-only on every restart until a human repairs it (design §3: it vetoes nothing else).
169
+ this.readOnlyReason = `nodes.json is unreadable or not a roster (copy kept at ${keepAside('corrupt')}); repair it, then restart`;
170
+ return;
171
+ }
172
+ const parsed = snap;
173
+ for (const raw of parsed.nodes ?? []) {
174
+ const record = recordOf(raw);
175
+ if (record === undefined || this.nodes.some(n => n.name === record.name))
176
+ this.dropped.push(String(raw?.name ?? '?'));
177
+ else
178
+ this.nodes.push(record);
179
+ }
180
+ this.retired = (parsed.retired ?? []).filter(isText);
181
+ // The next commit rewrites the roster without the dropped entries: keep the original first, and
182
+ // retire their names — a malformed entry may have no version directory to reserve it.
183
+ if (this.dropped.length > 0) {
184
+ keepAside('dropped');
185
+ for (const name of this.dropped)
186
+ if (NODE_NAME.test(name) && !this.retired.includes(name))
187
+ this.retired.push(name);
188
+ }
189
+ }
190
+ /** Disk first (tmp + rename), memory after — a failed write leaves both as they were. */
191
+ commit(next) {
192
+ const nodes = next.nodes ?? this.nodes;
193
+ const retired = next.retired ?? this.retired;
194
+ if (this.root !== '') {
195
+ try {
196
+ mkdirSync(this.root, { recursive: true });
197
+ const tmp = `${this.file}.${String(process.pid)}.tmp`;
198
+ writeFileSync(tmp, JSON.stringify({ nodes, retired }), { mode: 0o600 });
199
+ renameSync(tmp, this.file);
200
+ }
201
+ catch (error) {
202
+ return fail('unavailable', `could not persist nodes.json: ${String(error)}`);
203
+ }
204
+ }
205
+ this.nodes = nodes;
206
+ this.retired = retired;
207
+ return { ok: true, value: true };
208
+ }
209
+ /** Identity writes are refused while the roster is read-only. */
210
+ writable() {
211
+ return this.readOnlyReason === '' ? { ok: true, value: true } : fail('unavailable', `node roster is read-only: ${this.readOnlyReason}`);
212
+ }
213
+ /** Why the store refuses writes ('' when it does not). */
214
+ get readOnly() { return this.readOnlyReason; }
215
+ /** Names reserved in memory mode (root ''), standing in for the version directory. */
216
+ memReserved = new Set();
217
+ /** A name is used when it is live, retired, or has a version directory (created with the node, so it survives a lost roster). */
218
+ nameUsed(name) {
219
+ if (this.get(name) !== undefined || this.retired.includes(name))
220
+ return true;
221
+ if (this.root === '')
222
+ return this.memReserved.has(name);
223
+ return existsSync(this.versionDir(name));
224
+ }
225
+ // In-memory version files for root '' (tests); keyed `<name>/<layer>-v<n>`.
226
+ memVersions = new Map();
227
+ versionNumbers(name, layer) {
228
+ const pattern = new RegExp(`^${layer}-v(\\d+)\\.json$`);
229
+ if (this.root === '') {
230
+ return [...this.memVersions.keys()].map(k => k.startsWith(`${name}/`) ? pattern.exec(k.slice(name.length + 1)) : null).filter((m) => m !== null).map(m => Number(m[1]));
231
+ }
232
+ const dir = this.versionDir(name);
233
+ if (!existsSync(dir))
234
+ return [];
235
+ return readdirSync(dir).map(f => pattern.exec(f)).filter((m) => m !== null).map(m => Number(m[1]));
236
+ }
237
+ writeVersion(name, v) {
238
+ // Throws EEXIST (append-only collision) or an I/O error; freeze() tells them apart.
239
+ const file = `${v.layer}-v${String(v.version)}.json`;
240
+ if (this.root === '') {
241
+ if (this.memVersions.has(`${name}/${file}`))
242
+ throw new Error(`version exists: ${file}`);
243
+ this.memVersions.set(`${name}/${file}`, v);
244
+ return;
245
+ }
246
+ mkdirSync(this.versionDir(name), { recursive: true });
247
+ // 'wx': append-only — an existing version file is never overwritten.
248
+ writeFileSync(join(this.versionDir(name), file), JSON.stringify(v), { mode: 0o600, flag: 'wx' });
249
+ }
250
+ /** One frozen version, or undefined. */
251
+ version(name, layer, version) {
252
+ const file = `${layer}-v${String(version)}.json`;
253
+ if (this.root === '')
254
+ return this.memVersions.get(`${name}/${file}`);
255
+ try {
256
+ return JSON.parse(readFileSync(join(this.versionDir(name), file), 'utf8'));
257
+ }
258
+ catch {
259
+ return undefined;
260
+ }
261
+ }
262
+ /**
263
+ * The version a node's record pins for `layer`, only when it is well-formed AND its sha is the one
264
+ * the record pins — a hand-edited or swapped version file reads as absent, never as prompt text.
265
+ * undefined for an unknown node, a layer never written (version 0), or a file that fails either check.
266
+ */
267
+ current(name, layer) {
268
+ const node = this.get(name);
269
+ if (node === undefined)
270
+ return undefined;
271
+ const pinned = layer === 'core' ? node.prompt.core_version : node.prompt.mutable_version;
272
+ const sha = layer === 'core' ? node.prompt.core_sha256 : node.prompt.mutable_sha256;
273
+ if (pinned <= 0)
274
+ return undefined;
275
+ const v = this.version(name, layer, pinned);
276
+ return wellFormed(v, layer) && v.version === pinned && v.sha256 === sha ? v : undefined;
277
+ }
278
+ /**
279
+ * A specific version a live session froze at mount (#174: a plugin reload re-mounts at it). The pinned
280
+ * current version goes through {@link current} (the roster's sha); an older one must be well-formed,
281
+ * carry its own number, AND match the sha the mount recorded — the outside anchor a hand-edited file
282
+ * that recomputed its own sha cannot also fake. undefined when any check fails (the caller falls back).
283
+ */
284
+ frozen(name, layer, version, sha) {
285
+ const node = this.get(name);
286
+ if (node === undefined || version <= 0)
287
+ return undefined;
288
+ const v = version === (layer === 'core' ? node.prompt.core_version : node.prompt.mutable_version)
289
+ ? this.current(name, layer)
290
+ : this.version(name, layer, version);
291
+ return wellFormed(v, layer) && v.version === version && v.sha256 === sha ? v : undefined;
292
+ }
293
+ /** Version numbers present for one layer, ascending. */
294
+ versions(name, layer) {
295
+ return this.versionNumbers(name, layer).sort((a, b) => a - b);
296
+ }
297
+ list() { return this.nodes.map(n => ({ ...n })); }
298
+ get(name) { return this.nodes.find(n => n.name === name); }
299
+ /** Create a node (identity write). The name is final and never reused. */
300
+ create(input, by) {
301
+ const name = typeof input.name === 'string' ? input.name : '';
302
+ const w = this.writable();
303
+ if (!w.ok)
304
+ return w;
305
+ if (!NODE_NAME.test(name))
306
+ return fail('bad-request', 'name must match ^[a-z][a-z0-9-]{1,31}$');
307
+ if (this.get(name) !== undefined)
308
+ return fail('conflict', `node ${name} exists`);
309
+ if (this.nameUsed(name))
310
+ return fail('conflict', `node name ${name} was used before and is never reused`);
311
+ if (this.nodes.length >= NODE_CAP)
312
+ return fail('cap', `node cap ${String(NODE_CAP)} reached`);
313
+ const fields = this.fields(input);
314
+ if (!fields.ok)
315
+ return fields;
316
+ const record = {
317
+ name, ...fields.value,
318
+ label: fields.value.label === '' ? name : fields.value.label,
319
+ prompt: { core_version: 0, core_sha256: '', mutable_version: 0, mutable_sha256: '' },
320
+ created_by: by, created_at: this.now(),
321
+ };
322
+ // Reserve the name on disk before the roster names it: the directory outlives any roster loss.
323
+ if (this.root === '')
324
+ this.memReserved.add(name);
325
+ else {
326
+ try {
327
+ mkdirSync(this.versionDir(name), { recursive: true });
328
+ }
329
+ catch (error) {
330
+ return fail('unavailable', `could not reserve ${name}: ${String(error)}`);
331
+ }
332
+ }
333
+ const saved = this.commit({ nodes: [...this.nodes, record] });
334
+ return saved.ok ? { ok: true, value: record } : saved;
335
+ }
336
+ /** Update label / role / teams / carrier (identity write). There is no rename. */
337
+ update(name, input) {
338
+ const w = this.writable();
339
+ if (!w.ok)
340
+ return w;
341
+ const current = this.get(name);
342
+ if (current === undefined)
343
+ return fail('not-found', `no node ${name}`);
344
+ if (input.name !== undefined && input.name !== name)
345
+ return fail('bad-request', 'nodes are never renamed (§8-1): create a new node and archive this one');
346
+ const fields = this.fields({ label: input.label ?? current.label, role: input.role ?? current.role, teams: input.teams ?? current.teams, carrier: input.carrier ?? current.carrier });
347
+ if (!fields.ok)
348
+ return fields;
349
+ const next = { ...current, ...fields.value, label: fields.value.label === '' ? name : fields.value.label };
350
+ const saved = this.commit({ nodes: this.nodes.map(n => (n.name === name ? next : n)) });
351
+ return saved.ok ? { ok: true, value: next } : saved;
352
+ }
353
+ /** Archive a node (identity write): it leaves the roster, its name is retired, its versions stay on disk. */
354
+ archive(name) {
355
+ const w = this.writable();
356
+ if (!w.ok)
357
+ return w;
358
+ if (this.get(name) === undefined)
359
+ return fail('not-found', `no node ${name}`);
360
+ const saved = this.commit({ nodes: this.nodes.filter(n => n.name !== name), retired: [...this.retired, name] });
361
+ return saved.ok ? { ok: true, value: name } : saved;
362
+ }
363
+ fields(input) {
364
+ const label = input.label === undefined ? '' : input.label;
365
+ if (typeof label !== 'string' || label.length > LABEL_MAX)
366
+ return fail('bad-request', `label must be a string of at most ${String(LABEL_MAX)} characters`);
367
+ const role = textList(input.role, ROLES.size, r => ROLES.has(r));
368
+ if (role === undefined)
369
+ return fail('bad-request', `role must be a list drawn from ${[...ROLES].join('|')}`);
370
+ const teams = textList(input.teams, TEAM_MAX, teamRef);
371
+ if (teams === undefined)
372
+ return fail('bad-request', 'teams must be canvas team refs <zone>/canvas/<name>');
373
+ const carrier = carrierOf(input.carrier);
374
+ if (carrier === undefined)
375
+ return fail('bad-request', 'carrier must be {cwd, agentPreset, model} strings');
376
+ return { ok: true, value: { label: label.trim(), role, teams, carrier } };
377
+ }
378
+ /**
379
+ * Freeze a new version of one layer. `sections` is the FULL layer for
380
+ * core (`{core: text}`) and a PATCH for mutable (the named sections
381
+ * replace their text; the rest carry over). `expectedSha` must equal the
382
+ * layer's current sha ('' while the layer has no version yet).
383
+ */
384
+ writeLayer(name, layer, sections, meta) {
385
+ const w = this.writable();
386
+ if (!w.ok)
387
+ return w;
388
+ const node = this.get(name);
389
+ if (node === undefined)
390
+ return fail('not-found', `no node ${name}`);
391
+ if (sections === null || typeof sections !== 'object' || Array.isArray(sections))
392
+ return fail('bad-request', 'sections must be an object');
393
+ const patch = sections;
394
+ if (Object.keys(patch).length === 0)
395
+ return fail('bad-request', 'sections is empty: nothing to freeze');
396
+ const reason = typeof meta.reason === 'string' ? meta.reason.trim() : '';
397
+ if (reason === '' || reason.length > 500)
398
+ return fail('bad-request', 'reason is required (at most 500 characters)');
399
+ const currentVersion = layer === 'core' ? node.prompt.core_version : node.prompt.mutable_version;
400
+ const currentSha = layer === 'core' ? node.prompt.core_sha256 : node.prompt.mutable_sha256;
401
+ if (meta.expectedSha !== currentSha)
402
+ return fail('conflict', `expected_sha256 does not match the current ${layer} sha (${currentSha === '' ? 'none yet' : currentSha.slice(0, 12)}) — re-read and retry`);
403
+ let full;
404
+ if (layer === 'core') {
405
+ const keys = Object.keys(patch);
406
+ if (keys.length !== 1 || keys[0] !== 'core' || typeof patch.core !== 'string')
407
+ return fail('bad-request', 'core layer is {core: text}');
408
+ if (chars(patch.core) > QUOTA.core)
409
+ return fail('quota', `core layer exceeds ${String(QUOTA.core)} characters`);
410
+ full = { core: patch.core };
411
+ }
412
+ else {
413
+ // A patch carries the other sections over from the CURRENT version — which must be readable
414
+ // and match the roster's sha, or the patch would silently blank them.
415
+ let base = Object.fromEntries(MUTABLE_SECTIONS.map(s => [s, '']));
416
+ if (currentVersion > 0) {
417
+ const current = this.version(name, 'mutable', currentVersion);
418
+ if (!wellFormed(current, 'mutable') || current.sha256 !== currentSha)
419
+ return fail('conflict', `current mutable v${String(currentVersion)} is missing or does not match the roster sha — repair it or roll back first`);
420
+ base = { ...current.sections };
421
+ }
422
+ for (const [key, value] of Object.entries(patch)) {
423
+ if (!MUTABLE_SECTIONS.includes(key))
424
+ return fail('bad-request', `unknown mutable section ${key} (allowed: ${MUTABLE_SECTIONS.join(' / ')})`);
425
+ if (typeof value !== 'string')
426
+ return fail('bad-request', `section ${key} must be text`);
427
+ if (chars(value) > QUOTA.mutableSection)
428
+ return fail('quota', `section ${key} exceeds ${String(QUOTA.mutableSection)} characters`);
429
+ base[key] = value;
430
+ }
431
+ full = Object.fromEntries(MUTABLE_SECTIONS.map(s => [s, String(base[s] ?? '')]));
432
+ const total = Object.values(full).reduce((n, t) => n + chars(t), 0);
433
+ if (total > QUOTA.mutable)
434
+ return fail('quota', `mutable layer exceeds ${String(QUOTA.mutable)} characters in total`);
435
+ }
436
+ return this.freeze(node, layer, full, { parent: currentVersion, changed: Object.keys(patch), reason, by: meta.by, via: meta.via });
437
+ }
438
+ /** Roll a layer back (identity write): freezes a NEW version whose content is the target's. */
439
+ rollback(name, layer, to, meta) {
440
+ const w = this.writable();
441
+ if (!w.ok)
442
+ return w;
443
+ const node = this.get(name);
444
+ if (node === undefined)
445
+ return fail('not-found', `no node ${name}`);
446
+ if (typeof to !== 'number' || !Number.isSafeInteger(to))
447
+ return fail('bad-request', 'to must be a version number');
448
+ const target = this.version(name, layer, to);
449
+ if (target === undefined)
450
+ return fail('not-found', `no ${layer} version ${String(to)}`);
451
+ // A hand-placed or foreign file is never promoted into a frozen version.
452
+ if (!wellFormed(target, layer) || target.version !== to)
453
+ return fail('conflict', `${layer} v${String(to)} is not a well-formed frozen version (layer, keys, quota or sha)`);
454
+ const currentSha = layer === 'core' ? node.prompt.core_sha256 : node.prompt.mutable_sha256;
455
+ if (meta.expectedSha !== currentSha)
456
+ return fail('conflict', `expected_sha256 does not match the current ${layer} sha — re-read and retry`);
457
+ const reason = typeof meta.reason === 'string' ? meta.reason.trim() : '';
458
+ if (reason === '' || reason.length > 500)
459
+ return fail('bad-request', 'reason is required (at most 500 characters)');
460
+ const parent = layer === 'core' ? node.prompt.core_version : node.prompt.mutable_version;
461
+ return this.freeze(node, layer, { ...target.sections }, { parent, changed: Object.keys(target.sections), reason, by: meta.by, via: 'host-face', rollbackOf: to });
462
+ }
463
+ freeze(node, layer, sections, meta) {
464
+ const version = Math.max(0, ...this.versionNumbers(node.name, layer)) + 1;
465
+ const v = {
466
+ layer, version, sha256: promptSha(version, sections), parent_version: meta.parent, sections,
467
+ changed: meta.changed, reason: meta.reason, by: meta.by, via: meta.via, at: this.now(),
468
+ ...(meta.rollbackOf !== undefined ? { rollback_of: meta.rollbackOf } : {}),
469
+ };
470
+ try {
471
+ this.writeVersion(node.name, v);
472
+ }
473
+ catch (error) {
474
+ const exists = error.code === 'EEXIST' || /version exists/.test(String(error));
475
+ return fail(exists ? 'conflict' : 'unavailable', `could not freeze ${layer} v${String(version)}: ${String(error)}`);
476
+ }
477
+ const prompt = layer === 'core'
478
+ ? { ...node.prompt, core_version: version, core_sha256: v.sha256 }
479
+ : { ...node.prompt, mutable_version: version, mutable_sha256: v.sha256 };
480
+ // The version file is frozen even if the roster write fails: the next write numbers past it (max+1).
481
+ const saved = this.commit({ nodes: this.nodes.map(n => (n.name === node.name ? { ...n, prompt } : n)) });
482
+ return saved.ok ? { ok: true, value: v } : saved;
483
+ }
484
+ }