openworkgraph 0.2.12 → 0.3.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,554 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { spawn } from 'node:child_process';
3
+ import { constants, chmodSync, lstatSync, mkdirSync } from 'node:fs';
4
+ import { open, rename, unlink, mkdir, rm, readdir, rmdir } from 'node:fs/promises';
5
+ import { join, dirname } from 'node:path';
6
+ import { hostname } from 'node:os';
7
+ import { setTimeout as delay } from 'node:timers/promises';
8
+ import { ServiceError } from './errors.js';
9
+ import { parseSkillConfig, SKILL_CONFIG_LIMITS, validateSkillValue } from './skill-schema.js';
10
+ const emptySchema = { version: 1, environment: [] };
11
+ const initial = () => ({ version: 1, revision: 0, minimumRevision: 0 });
12
+ const storageBytes = 2 * 1024 * 1024;
13
+ const hash = (value) => createHash('sha256').update(value).digest('hex');
14
+ const invalid = () => { throw new ServiceError('INVALID_REQUEST', '技能配置输入无效。'); };
15
+ const unavailable = () => { throw new ServiceError('INVALID_REQUEST', '技能配置修订不可用,请重新提交任务。'); };
16
+ function revisionNumber(value) { if (!Number.isSafeInteger(value) || value < 0)
17
+ invalid(); }
18
+ function identifier(value) { if (typeof value !== 'string' || !value.trim() || value.length > 1024 || value.includes('\0'))
19
+ invalid(); }
20
+ function schemaCopy(schema) {
21
+ try {
22
+ return parseSkillConfig(JSON.stringify(schema));
23
+ }
24
+ catch {
25
+ return invalid();
26
+ }
27
+ }
28
+ function privateDirectory(path) {
29
+ mkdirSync(path, { mode: 0o700 });
30
+ }
31
+ function ensureDirectory(path) {
32
+ try {
33
+ privateDirectory(path);
34
+ }
35
+ catch (error) {
36
+ if (error.code !== 'EEXIST')
37
+ throw error;
38
+ }
39
+ const stat = lstatSync(path);
40
+ if (!stat.isDirectory() || stat.isSymbolicLink() || (process.getuid && stat.uid !== process.getuid()))
41
+ throw Error('Unsafe storage');
42
+ if (process.platform !== 'win32') {
43
+ chmodSync(path, 0o700);
44
+ if ((lstatSync(path).mode & 0o777) !== 0o700)
45
+ throw Error('Unsafe storage');
46
+ }
47
+ }
48
+ /** No shell, interpolated PowerShell code, or platform diagnostics in errors. */
49
+ function command(program, args, input = '') {
50
+ return new Promise((resolve, reject) => {
51
+ const child = spawn(program, args, { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] });
52
+ let output = '', oversized = false;
53
+ const timer = setTimeout(() => child.kill(), 10000);
54
+ timer.unref();
55
+ child.stdout.on('data', chunk => { if (output.length + chunk.length > 64 * 1024) {
56
+ oversized = true;
57
+ child.kill();
58
+ }
59
+ else
60
+ output += chunk; });
61
+ child.stderr.resume();
62
+ child.stdin.on('error', () => { });
63
+ child.on('error', () => { clearTimeout(timer); reject(Error('ACL command unavailable')); });
64
+ child.on('close', code => { clearTimeout(timer); code === 0 && !oversized ? resolve(output.trim()) : reject(Error('ACL command failed')); });
65
+ child.stdin.end(input);
66
+ });
67
+ }
68
+ const aclCheck = '$ErrorActionPreference="Stop"; $p=ConvertFrom-Json ([Console]::In.ReadToEnd()); $a=Get-Acl -LiteralPath $p.path; $sid=$p.sid; if (!$a.AreAccessRulesProtected -or $a.GetOwner([System.Security.Principal.SecurityIdentifier]).Value -ne $sid) {exit 1}; $rules=@($a.GetAccessRules($true,$true,[System.Security.Principal.SecurityIdentifier])); if ($rules.Count -eq 0) {exit 1}; foreach($r in $rules) {if ($r.IsInherited -or $r.IdentityReference.Value -ne $sid -or $r.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow -or ($r.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -ne [System.Security.AccessControl.FileSystemRights]::FullControl) {exit 1}}; [Console]::Out.Write("ok")';
69
+ function powershell(script, input) {
70
+ return command('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], input);
71
+ }
72
+ /** Workspace-private, immutable revisions. Uninstall deliberately does not call clear. */
73
+ export class SkillConfigs {
74
+ directories;
75
+ serviceId;
76
+ sid;
77
+ constructor(directories, serviceId) {
78
+ this.directories = directories;
79
+ this.serviceId = serviceId;
80
+ identifier(serviceId);
81
+ }
82
+ async windowsSid() {
83
+ this.sid ??= powershell('[Console]::Out.Write([System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value)').then(sid => {
84
+ if (!/^S-1-(?:[0-9]+-)+[0-9]+$/.test(sid))
85
+ throw Error('Invalid SID');
86
+ return sid;
87
+ }).catch(error => { this.sid = undefined; throw error; });
88
+ return this.sid;
89
+ }
90
+ async windowsAcl(path, directory, restrict) {
91
+ const sid = await this.windowsSid();
92
+ if (restrict)
93
+ await command('icacls.exe', [path, '/inheritance:r', '/grant:r', '*' + sid + ':' + (directory ? '(OI)(CI)F' : 'F'), '/Q', '/L']);
94
+ // /grant:r replaces only this SID's ACE. Refuse leftover explicit grants,
95
+ // inherited ACEs, different ownership, or unsupported filesystems.
96
+ if (await powershell(aclCheck, JSON.stringify({ path, sid })) !== 'ok')
97
+ throw Error('Unsafe ACL');
98
+ }
99
+ async directory(skillId) {
100
+ identifier(skillId);
101
+ if (!['win32', 'darwin', 'linux'].includes(process.platform))
102
+ throw new ServiceError('MODEL_UNAVAILABLE', '当前平台不支持受限技能配置存储。');
103
+ const root = join(this.directories.config, 'skill-configs');
104
+ const workspace = join(root, hash(this.serviceId));
105
+ const skill = join(workspace, hash(skillId));
106
+ for (const path of [this.directories.config, root, workspace, skill]) {
107
+ ensureDirectory(path);
108
+ if (process.platform === 'win32')
109
+ await this.windowsAcl(path, true, true);
110
+ }
111
+ return skill;
112
+ }
113
+ async locked(skillId, action) {
114
+ try {
115
+ const path = await this.directory(skillId), lock = join(path, '.lock');
116
+ const deadline = Date.now() + 5000;
117
+ for (;;) {
118
+ if (await this.claim(lock))
119
+ break;
120
+ if (await this.recover(lock))
121
+ continue;
122
+ if (Date.now() >= deadline)
123
+ throw new ServiceError('CONFLICT', '技能配置锁仍被使用,或无法确认其所属进程已退出。', { retryable: true });
124
+ await delay(20);
125
+ }
126
+ try {
127
+ return await action(path);
128
+ }
129
+ finally {
130
+ const released = join(path, '.released-' + randomUUID());
131
+ await rename(lock, released);
132
+ await rm(released, { recursive: true, force: true });
133
+ }
134
+ }
135
+ catch (error) {
136
+ if (error instanceof ServiceError)
137
+ throw error;
138
+ // Filesystem diagnostics and JSON parse errors must never expose values.
139
+ throw new ServiceError('MODEL_UNAVAILABLE', '受限技能配置存储不可用。');
140
+ }
141
+ }
142
+ async claim(lock) {
143
+ const prepared = join(dirname(lock), '.owner-' + randomUUID());
144
+ await mkdir(prepared, { mode: 0o700 });
145
+ try {
146
+ if (process.platform === 'win32')
147
+ await this.windowsAcl(prepared, true, true);
148
+ const owner = { pid: process.pid, started: new Date(Date.now() - process.uptime() * 1000).toISOString(), hostname: hostname(), token: randomUUID() };
149
+ // Publish a nonempty directory only after durable owner metadata exists.
150
+ // rename cannot replace another nonempty lock directory, on either OS.
151
+ await this.atomic(prepared, 'owner-' + owner.token + '.json', owner);
152
+ try {
153
+ lstatSync(lock);
154
+ return undefined;
155
+ }
156
+ catch (error) {
157
+ if (error.code !== 'ENOENT')
158
+ throw error;
159
+ }
160
+ try {
161
+ await rename(prepared, lock);
162
+ return owner;
163
+ }
164
+ catch (error) {
165
+ if (['EEXIST', 'ENOTEMPTY', 'EPERM', 'EACCES'].includes(error.code ?? '')) {
166
+ try {
167
+ if (lstatSync(lock).isDirectory())
168
+ return undefined;
169
+ }
170
+ catch (stateError) {
171
+ if (stateError.code === 'ENOENT')
172
+ return undefined;
173
+ throw stateError;
174
+ }
175
+ }
176
+ throw error;
177
+ }
178
+ }
179
+ finally {
180
+ await rm(prepared, { recursive: true, force: true });
181
+ }
182
+ }
183
+ async owner(lock) {
184
+ try {
185
+ const stat = lstatSync(lock);
186
+ if (!stat.isDirectory() || stat.isSymbolicLink())
187
+ throw Error('Unsafe lock');
188
+ }
189
+ catch (error) {
190
+ if (error.code === 'ENOENT')
191
+ return undefined;
192
+ throw error;
193
+ }
194
+ let names;
195
+ try {
196
+ names = (await readdir(lock)).filter(name => /^owner(?:-[a-zA-Z0-9-]+)?\.json$/.test(name));
197
+ }
198
+ catch (error) {
199
+ if (error.code === 'ENOENT')
200
+ return undefined;
201
+ throw error;
202
+ }
203
+ if (names.length !== 1)
204
+ return undefined;
205
+ const value = await this.load(join(lock, names[0]));
206
+ if (!value || !Number.isSafeInteger(value.pid) || value.pid <= 0 || typeof value.started !== 'string' || !Number.isFinite(Date.parse(value.started)) || typeof value.hostname !== 'string' || typeof value.token !== 'string')
207
+ return undefined;
208
+ return value;
209
+ }
210
+ dead(owner) {
211
+ if (owner.hostname !== hostname())
212
+ return false;
213
+ try {
214
+ process.kill(owner.pid, 0);
215
+ return false;
216
+ }
217
+ catch (error) {
218
+ return error.code === 'ESRCH';
219
+ }
220
+ }
221
+ async recover(lock, depth = 0) {
222
+ const owner = await this.owner(lock);
223
+ if (!owner || !this.dead(owner) || depth > 4)
224
+ return false;
225
+ const recovery = join(lock, '.recovery');
226
+ let claim;
227
+ try {
228
+ claim = await this.claim(recovery);
229
+ if (!claim) {
230
+ // A recovery worker can itself crash. Its owner follows the same rule.
231
+ await this.recover(recovery, depth + 1);
232
+ return false;
233
+ }
234
+ }
235
+ catch (error) {
236
+ if (error.code === 'ENOENT')
237
+ return false;
238
+ throw error;
239
+ }
240
+ let moved = false;
241
+ try {
242
+ const current = await this.owner(lock);
243
+ if (!current || current.token !== owner.token || !this.dead(current))
244
+ return false;
245
+ const retired = join(dirname(lock), '.retired-' + randomUUID());
246
+ await rename(lock, retired);
247
+ moved = true;
248
+ await rm(retired, { recursive: true, force: true });
249
+ return true;
250
+ }
251
+ finally {
252
+ if (!moved) {
253
+ // A live owner may release and replace the parent while we check it.
254
+ // Remove only our unique owner file, never another worker's claim.
255
+ await unlink(join(recovery, 'owner-' + claim.token + '.json')).catch(error => { if (error.code !== 'ENOENT')
256
+ throw error; });
257
+ await rmdir(recovery).catch(error => { if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(error.code ?? ''))
258
+ throw error; });
259
+ }
260
+ }
261
+ }
262
+ async load(path) {
263
+ let file;
264
+ if (process.platform === 'win32') {
265
+ try {
266
+ if (lstatSync(path).isSymbolicLink())
267
+ throw Error('Unsafe storage');
268
+ }
269
+ catch (error) {
270
+ if (error.code === 'ENOENT')
271
+ return undefined;
272
+ throw error;
273
+ }
274
+ }
275
+ try {
276
+ file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
277
+ }
278
+ catch (error) {
279
+ if (error.code === 'ENOENT')
280
+ return undefined;
281
+ throw error;
282
+ }
283
+ try {
284
+ const stat = await file.stat();
285
+ if (!stat.isFile() || stat.nlink !== 1 || (process.platform !== 'win32' && (stat.mode & 0o777) !== 0o600) || stat.size > storageBytes || (process.getuid && stat.uid !== process.getuid()))
286
+ throw Error('Unsafe storage');
287
+ if (process.platform === 'win32')
288
+ await this.windowsAcl(path, false, false);
289
+ return JSON.parse(await file.readFile('utf8'));
290
+ }
291
+ finally {
292
+ await file.close();
293
+ }
294
+ }
295
+ async manifest(path) {
296
+ const raw = await this.load(join(path, 'current.json'));
297
+ if (raw === undefined) {
298
+ // Establish revision zero before a first write. A lost committed manifest
299
+ // must not make old revision numbers eligible for reuse with new secrets.
300
+ if ((await readdir(path)).some(name => /^[0-9]+\.json$/.test(name)))
301
+ throw Error('Missing revision manifest');
302
+ const manifest = initial();
303
+ await this.atomic(path, 'current.json', manifest);
304
+ return manifest;
305
+ }
306
+ const value = raw;
307
+ if (!value || value.version !== 1 || !Number.isSafeInteger(value.revision) || value.revision < 0 || !Number.isSafeInteger(value.minimumRevision) || value.minimumRevision < 0 || value.minimumRevision > value.revision)
308
+ throw Error('Invalid storage');
309
+ if (value.minimumRevision > 0)
310
+ await this.removeRevoked(path, value.minimumRevision);
311
+ return value;
312
+ }
313
+ async removeRevoked(path, minimumRevision) {
314
+ for (const name of await readdir(path)) {
315
+ const revision = /^([0-9]+)\.json$/.exec(name);
316
+ if ((revision && Number(revision[1]) < minimumRevision) || /^\.[a-f0-9-]+\.tmp$/.test(name))
317
+ await unlink(join(path, name));
318
+ }
319
+ }
320
+ async snapshot(path, manifest, revision) {
321
+ revisionNumber(revision);
322
+ if (revision < manifest.minimumRevision || revision > manifest.revision)
323
+ unavailable();
324
+ if (revision === 0)
325
+ return { revision: 0, values: {} };
326
+ const value = await this.load(join(path, String(revision) + '.json'));
327
+ if (!value)
328
+ return unavailable();
329
+ if (value.revision !== revision || !value.values || typeof value.values !== 'object' || Array.isArray(value.values))
330
+ throw Error('Invalid storage');
331
+ for (const stored of Object.values(value.values)) {
332
+ if (!stored || typeof stored.value !== 'string' || typeof stored.secret !== 'boolean')
333
+ throw Error('Invalid storage');
334
+ validateSkillValue(stored.value);
335
+ }
336
+ return value;
337
+ }
338
+ async atomic(path, name, value) {
339
+ const text = JSON.stringify(value);
340
+ if (Buffer.byteLength(text) > storageBytes)
341
+ invalid();
342
+ const temporary = join(path, '.' + randomUUID() + '.tmp');
343
+ const file = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
344
+ try {
345
+ if (process.platform === 'win32')
346
+ await this.windowsAcl(temporary, false, true);
347
+ else
348
+ await file.chmod(0o600);
349
+ await file.writeFile(text, 'utf8');
350
+ await file.sync();
351
+ }
352
+ catch (error) {
353
+ await file.close();
354
+ await unlink(temporary).catch(() => { });
355
+ throw error;
356
+ }
357
+ await file.close();
358
+ try {
359
+ await rename(temporary, join(path, name));
360
+ }
361
+ finally {
362
+ await unlink(temporary).catch(() => { });
363
+ }
364
+ if (process.platform === 'win32')
365
+ return; // Windows does not support fsync of directory handles.
366
+ const directory = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
367
+ try {
368
+ await directory.sync();
369
+ }
370
+ finally {
371
+ await directory.close();
372
+ }
373
+ }
374
+ effective(field, snapshot) {
375
+ const stored = Object.hasOwn(snapshot.values, field.name) ? snapshot.values[field.name] : undefined;
376
+ // Both sensitivity transitions require a fresh value; defaults cannot rescue one.
377
+ if (stored && stored.secret !== field.secret)
378
+ return undefined;
379
+ return stored?.value ?? field.default;
380
+ }
381
+ view(skillId, schema, manifest, snapshot) {
382
+ return { skillId, revision: manifest.revision, schema, values: schema.environment.map(field => {
383
+ const value = this.effective(field, snapshot);
384
+ const configured = value !== undefined && (!field.required || !!value.trim());
385
+ return { name: field.name, configured, ...(value !== undefined && !field.secret && snapshot.values[field.name]?.secret !== true ? { value } : {}) };
386
+ }) };
387
+ }
388
+ async read(skillId, schema) {
389
+ const checked = schemaCopy(schema);
390
+ return this.locked(skillId, async (path) => {
391
+ const manifest = await this.manifest(path);
392
+ return this.view(skillId, checked, manifest, await this.snapshot(path, manifest, manifest.revision));
393
+ });
394
+ }
395
+ async write(skillId, schema, body) {
396
+ const checked = schemaCopy(schema);
397
+ if (!body || typeof body !== 'object' || Array.isArray(body) || Object.keys(body).some(key => !['expectedRevision', 'values'].includes(key)) || !body.values || typeof body.values !== 'object' || Array.isArray(body.values))
398
+ invalid();
399
+ revisionNumber(body.expectedRevision);
400
+ // Copy before waiting for the lock so callers cannot mutate validation inputs.
401
+ const updates = Object.entries(body.values);
402
+ const fields = new Map(checked.environment.map(field => [field.name, field]));
403
+ let bytes = 0;
404
+ for (const [name, value] of updates) {
405
+ if (!fields.has(name) || (value !== null && typeof value !== 'string'))
406
+ invalid();
407
+ if (value !== null) {
408
+ validateSkillValue(value);
409
+ bytes += Buffer.byteLength(value);
410
+ }
411
+ }
412
+ if (bytes > SKILL_CONFIG_LIMITS.valuesBytes)
413
+ invalid();
414
+ const expectedRevision = body.expectedRevision;
415
+ return this.locked(skillId, async (path) => {
416
+ const manifest = await this.manifest(path);
417
+ if (manifest.revision !== expectedRevision)
418
+ throw new ServiceError('CONFLICT', '技能配置修订已改变。', { details: { revision: manifest.revision } });
419
+ const previous = await this.snapshot(path, manifest, manifest.revision);
420
+ const values = Object.create(null);
421
+ // Drop deleted fields, retain unchanged fields including mismatches until replaced.
422
+ for (const field of checked.environment)
423
+ if (Object.hasOwn(previous.values, field.name))
424
+ values[field.name] = previous.values[field.name];
425
+ for (const [name, value] of updates) {
426
+ if (value === null)
427
+ delete values[name];
428
+ else
429
+ values[name] = { value, secret: fields.get(name).secret };
430
+ }
431
+ const nextRevision = manifest.revision + 1;
432
+ revisionNumber(nextRevision);
433
+ const snapshot = { revision: nextRevision, values };
434
+ for (const field of checked.environment) {
435
+ const value = this.effective(field, snapshot);
436
+ if (field.required && (value === undefined || !value.trim()))
437
+ throw new ServiceError('INVALID_REQUEST', '技能必填配置缺失:' + field.name);
438
+ }
439
+ if (Object.values(values).reduce((sum, value) => sum + Buffer.byteLength(value.value), 0) > SKILL_CONFIG_LIMITS.valuesBytes)
440
+ invalid();
441
+ const next = { ...manifest, revision: nextRevision };
442
+ // Publishing the manifest last is the transaction commit. Interrupted snapshots
443
+ // are unreachable and may be replaced by the next writer at the same revision.
444
+ await this.atomic(path, String(nextRevision) + '.json', snapshot);
445
+ await this.atomic(path, 'current.json', next);
446
+ return this.view(skillId, checked, next, snapshot);
447
+ });
448
+ }
449
+ async clear(skillId, expectedRevision) {
450
+ revisionNumber(expectedRevision);
451
+ return this.locked(skillId, async (path) => {
452
+ const manifest = await this.manifest(path);
453
+ if (manifest.revision !== expectedRevision)
454
+ throw new ServiceError('CONFLICT', '技能配置修订已改变。', { details: { revision: manifest.revision } });
455
+ const revision = manifest.revision + 1;
456
+ revisionNumber(revision);
457
+ const snapshot = { revision, values: {} };
458
+ const next = { ...manifest, revision, minimumRevision: revision };
459
+ await this.atomic(path, String(revision) + '.json', snapshot);
460
+ await this.atomic(path, 'current.json', next);
461
+ // Revocation commits before deletion. A crash midway is safe, and the
462
+ // next locked read resumes deleting the revoked private files.
463
+ await this.removeRevoked(path, revision);
464
+ return this.view(skillId, emptySchema, next, snapshot);
465
+ });
466
+ }
467
+ async revision(skillId) { return this.locked(skillId, async (path) => (await this.manifest(path)).revision); }
468
+ async hasRevision(skillId, revision) {
469
+ revisionNumber(revision);
470
+ return this.locked(skillId, async (path) => {
471
+ const manifest = await this.manifest(path);
472
+ if (revision < manifest.minimumRevision || revision > manifest.revision)
473
+ return false;
474
+ try {
475
+ await this.snapshot(path, manifest, revision);
476
+ return true;
477
+ }
478
+ catch (error) {
479
+ if (error instanceof ServiceError && error.code === 'INVALID_REQUEST')
480
+ return false;
481
+ throw error;
482
+ }
483
+ });
484
+ }
485
+ async environment(skillId, schema, revision) {
486
+ const checked = schemaCopy(schema);
487
+ revisionNumber(revision);
488
+ return this.locked(skillId, async (path) => {
489
+ const manifest = await this.manifest(path), snapshot = await this.snapshot(path, manifest, revision);
490
+ return this.snapshotEnvironment(checked, snapshot);
491
+ });
492
+ }
493
+ snapshotEnvironment(schema, snapshot) {
494
+ const environment = {};
495
+ for (const field of schema.environment) {
496
+ const value = this.effective(field, snapshot);
497
+ if (field.required && (value === undefined || !value.trim()))
498
+ throw new ServiceError('INVALID_REQUEST', '技能必填配置缺失:' + field.name);
499
+ if (value !== undefined)
500
+ Object.defineProperty(environment, field.name, { value, enumerable: true, configurable: true, writable: true });
501
+ }
502
+ return environment;
503
+ }
504
+ /** Deterministic multi-skill locking prevents clear between the final revision
505
+ * check, secret read, and synchronous spawn/request. No nested public store
506
+ * methods may be awaited inside dispatch. Never await a returned command. */
507
+ async withRevisions(requests, dispatch) {
508
+ const unique = new Map();
509
+ for (const request of requests) {
510
+ identifier(request.skillId);
511
+ revisionNumber(request.revision);
512
+ if (unique.has(request.skillId))
513
+ invalid();
514
+ unique.set(request.skillId, { skillId: request.skillId, revision: request.revision, ...(request.schema ? { schema: schemaCopy(request.schema) } : {}) });
515
+ }
516
+ const ids = [...unique.keys()].sort();
517
+ const paths = new Map();
518
+ let dispatchFailed = false, dispatchError;
519
+ const acquire = (index) => {
520
+ const id = ids[index];
521
+ if (id !== undefined)
522
+ return this.locked(id, async (path) => { paths.set(id, path); return acquire(index + 1); });
523
+ return (async () => {
524
+ const environments = new Map();
525
+ for (const skillId of ids) {
526
+ const request = unique.get(skillId), path = paths.get(skillId);
527
+ const manifest = await this.manifest(path);
528
+ const snapshot = await this.snapshot(path, manifest, request.revision);
529
+ if (request.schema)
530
+ environments.set(skillId, this.snapshotEnvironment(request.schema, snapshot));
531
+ }
532
+ // The envelope prevents Promise assimilation from holding locks for the
533
+ // lifetime of command/exec rather than only its synchronous dispatch.
534
+ try {
535
+ return { value: dispatch(environments) };
536
+ }
537
+ catch (error) {
538
+ dispatchFailed = true;
539
+ dispatchError = error;
540
+ throw error;
541
+ }
542
+ })();
543
+ };
544
+ try {
545
+ return await acquire(0);
546
+ }
547
+ catch (error) {
548
+ if (dispatchFailed)
549
+ throw dispatchError;
550
+ throw error;
551
+ }
552
+ }
553
+ }
554
+ //# sourceMappingURL=skill-configs.js.map
@@ -0,0 +1,10 @@
1
+ import type { SkillConfigSchema } from "./vendor/protocol/index.js";
2
+ export declare const SKILL_CONFIG_LIMITS: {
3
+ readonly schemaBytes: number;
4
+ readonly fields: 128;
5
+ readonly valueBytes: number;
6
+ readonly valuesBytes: number;
7
+ };
8
+ export declare function validateSkillEnvironmentName(name: string): void;
9
+ export declare function validateSkillValue(value: string): void;
10
+ export declare function parseSkillConfig(text: string): SkillConfigSchema;