openworkgraph 0.3.1 → 0.3.2

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.
@@ -96,7 +96,7 @@ export class SkillConfigs {
96
96
  if (await powershell(aclCheck, JSON.stringify({ path, sid })) !== 'ok')
97
97
  throw Error('Unsafe ACL');
98
98
  }
99
- async directory(skillId) {
99
+ async directory(skillId, writable = true) {
100
100
  identifier(skillId);
101
101
  if (!['win32', 'darwin', 'linux'].includes(process.platform))
102
102
  throw new ServiceError('MODEL_UNAVAILABLE', '当前平台不支持受限技能配置存储。');
@@ -104,12 +104,36 @@ export class SkillConfigs {
104
104
  const workspace = join(root, hash(this.serviceId));
105
105
  const skill = join(workspace, hash(skillId));
106
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);
107
+ if (writable) {
108
+ ensureDirectory(path);
109
+ if (process.platform === 'win32')
110
+ await this.windowsAcl(path, true, true);
111
+ }
112
+ else {
113
+ try {
114
+ const stat = lstatSync(path);
115
+ if (!stat.isDirectory() || stat.isSymbolicLink() || (process.getuid && stat.uid !== process.getuid()))
116
+ throw Error('Unsafe storage');
117
+ }
118
+ catch (error) {
119
+ if (error.code !== 'ENOENT')
120
+ throw error;
121
+ }
122
+ }
110
123
  }
111
124
  return skill;
112
125
  }
126
+ /** Immutable snapshots and an atomically published manifest need no read lock. */
127
+ async reading(skillId, action) {
128
+ try {
129
+ return await action(await this.directory(skillId, false));
130
+ }
131
+ catch (error) {
132
+ if (error instanceof ServiceError)
133
+ throw error;
134
+ throw new ServiceError('MODEL_UNAVAILABLE', '技能配置读取不可用。');
135
+ }
136
+ }
113
137
  async locked(skillId, action) {
114
138
  try {
115
139
  const path = await this.directory(skillId), lock = join(path, '.lock');
@@ -139,16 +163,52 @@ export class SkillConfigs {
139
163
  throw new ServiceError('MODEL_UNAVAILABLE', '受限技能配置存储不可用。');
140
164
  }
141
165
  }
142
- async claim(lock) {
166
+ /** Coordinate revocation with final validation/dispatch, independently of write locks.
167
+ * Owner metadata contains no configuration values and needs no ACL subprocesses. */
168
+ async withDispatchLock(action) {
169
+ const lock = join(this.directories.config, '.skill-dispatch-' + hash(this.serviceId));
170
+ try {
171
+ ensureDirectory(this.directories.config);
172
+ const deadline = Date.now() + 5000;
173
+ for (;;) {
174
+ if (await this.claim(lock, false))
175
+ break;
176
+ if (await this.recover(lock, 0, false))
177
+ continue;
178
+ if (Date.now() >= deadline)
179
+ throw new ServiceError('CONFLICT', '技能配置正在清除或派发,请稍后重试。', { retryable: true });
180
+ await delay(20);
181
+ }
182
+ }
183
+ catch (error) {
184
+ if (error instanceof ServiceError)
185
+ throw error;
186
+ throw new ServiceError('MODEL_UNAVAILABLE', '技能配置撤销协调不可用。');
187
+ }
188
+ try {
189
+ return await action();
190
+ }
191
+ finally {
192
+ try {
193
+ const released = join(this.directories.config, '.released-' + randomUUID());
194
+ await rename(lock, released);
195
+ await rm(released, { recursive: true, force: true });
196
+ }
197
+ catch {
198
+ throw new ServiceError('MODEL_UNAVAILABLE', '技能配置撤销协调不可用。');
199
+ }
200
+ }
201
+ }
202
+ async claim(lock, restrict = true) {
143
203
  const prepared = join(dirname(lock), '.owner-' + randomUUID());
144
204
  await mkdir(prepared, { mode: 0o700 });
145
205
  try {
146
- if (process.platform === 'win32')
206
+ if (restrict && process.platform === 'win32')
147
207
  await this.windowsAcl(prepared, true, true);
148
208
  const owner = { pid: process.pid, started: new Date(Date.now() - process.uptime() * 1000).toISOString(), hostname: hostname(), token: randomUUID() };
149
209
  // Publish a nonempty directory only after durable owner metadata exists.
150
210
  // rename cannot replace another nonempty lock directory, on either OS.
151
- await this.atomic(prepared, 'owner-' + owner.token + '.json', owner);
211
+ await this.atomic(prepared, 'owner-' + owner.token + '.json', owner, restrict);
152
212
  try {
153
213
  lstatSync(lock);
154
214
  return undefined;
@@ -218,17 +278,17 @@ export class SkillConfigs {
218
278
  return error.code === 'ESRCH';
219
279
  }
220
280
  }
221
- async recover(lock, depth = 0) {
281
+ async recover(lock, depth = 0, restrict = true) {
222
282
  const owner = await this.owner(lock);
223
283
  if (!owner || !this.dead(owner) || depth > 4)
224
284
  return false;
225
285
  const recovery = join(lock, '.recovery');
226
286
  let claim;
227
287
  try {
228
- claim = await this.claim(recovery);
288
+ claim = await this.claim(recovery, restrict);
229
289
  if (!claim) {
230
290
  // A recovery worker can itself crash. Its owner follows the same rule.
231
- await this.recover(recovery, depth + 1);
291
+ await this.recover(recovery, depth + 1, restrict);
232
292
  return false;
233
293
  }
234
294
  }
@@ -284,29 +344,34 @@ export class SkillConfigs {
284
344
  const stat = await file.stat();
285
345
  if (!stat.isFile() || stat.nlink !== 1 || (process.platform !== 'win32' && (stat.mode & 0o777) !== 0o600) || stat.size > storageBytes || (process.getuid && stat.uid !== process.getuid()))
286
346
  throw Error('Unsafe storage');
287
- if (process.platform === 'win32')
288
- await this.windowsAcl(path, false, false);
289
347
  return JSON.parse(await file.readFile('utf8'));
290
348
  }
291
349
  finally {
292
350
  await file.close();
293
351
  }
294
352
  }
295
- async manifest(path) {
353
+ async manifest(path, writable = true) {
296
354
  const raw = await this.load(join(path, 'current.json'));
297
355
  if (raw === undefined) {
298
356
  // Establish revision zero before a first write. A lost committed manifest
299
357
  // must not make old revision numbers eligible for reuse with new secrets.
300
- if ((await readdir(path)).some(name => /^[0-9]+\.json$/.test(name)))
358
+ const names = await readdir(path).catch(error => { if (error.code === 'ENOENT')
359
+ return []; throw error; });
360
+ if (names.some(name => /^[0-9]+\.json$/.test(name))) {
361
+ // A first writer may have committed after our initial read.
362
+ if (!writable && await this.load(join(path, 'current.json')) !== undefined)
363
+ return this.manifest(path, false);
301
364
  throw Error('Missing revision manifest');
365
+ }
302
366
  const manifest = initial();
303
- await this.atomic(path, 'current.json', manifest);
367
+ if (writable)
368
+ await this.atomic(path, 'current.json', manifest);
304
369
  return manifest;
305
370
  }
306
371
  const value = raw;
307
372
  if (!value || value.version !== 1 || !Number.isSafeInteger(value.revision) || value.revision < 0 || !Number.isSafeInteger(value.minimumRevision) || value.minimumRevision < 0 || value.minimumRevision > value.revision)
308
373
  throw Error('Invalid storage');
309
- if (value.minimumRevision > 0)
374
+ if (writable && value.minimumRevision > 0)
310
375
  await this.removeRevoked(path, value.minimumRevision);
311
376
  return value;
312
377
  }
@@ -335,15 +400,17 @@ export class SkillConfigs {
335
400
  }
336
401
  return value;
337
402
  }
338
- async atomic(path, name, value) {
403
+ async atomic(path, name, value, restrict = true) {
339
404
  const text = JSON.stringify(value);
340
405
  if (Buffer.byteLength(text) > storageBytes)
341
406
  invalid();
342
407
  const temporary = join(path, '.' + randomUUID() + '.tmp');
343
408
  const file = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
344
409
  try {
345
- if (process.platform === 'win32')
346
- await this.windowsAcl(temporary, false, true);
410
+ if (process.platform === 'win32') {
411
+ if (restrict)
412
+ await this.windowsAcl(temporary, false, true);
413
+ }
347
414
  else
348
415
  await file.chmod(0o600);
349
416
  await file.writeFile(text, 'utf8');
@@ -387,8 +454,8 @@ export class SkillConfigs {
387
454
  }
388
455
  async read(skillId, schema) {
389
456
  const checked = schemaCopy(schema);
390
- return this.locked(skillId, async (path) => {
391
- const manifest = await this.manifest(path);
457
+ return this.reading(skillId, async (path) => {
458
+ const manifest = await this.manifest(path, false);
392
459
  return this.view(skillId, checked, manifest, await this.snapshot(path, manifest, manifest.revision));
393
460
  });
394
461
  }
@@ -457,23 +524,23 @@ export class SkillConfigs {
457
524
  const snapshot = { revision, values: {} };
458
525
  const next = { ...manifest, revision, minimumRevision: revision };
459
526
  await this.atomic(path, String(revision) + '.json', snapshot);
460
- await this.atomic(path, 'current.json', next);
527
+ await this.withDispatchLock(() => this.atomic(path, 'current.json', next));
461
528
  // Revocation commits before deletion. A crash midway is safe, and the
462
- // next locked read resumes deleting the revoked private files.
529
+ // next write resumes deleting the revoked private files.
463
530
  await this.removeRevoked(path, revision);
464
531
  return this.view(skillId, emptySchema, next, snapshot);
465
532
  });
466
533
  }
467
- async revision(skillId) { return this.locked(skillId, async (path) => (await this.manifest(path)).revision); }
534
+ async revision(skillId) { return this.reading(skillId, async (path) => (await this.manifest(path, false)).revision); }
468
535
  async hasRevision(skillId, revision) {
469
536
  revisionNumber(revision);
470
- return this.locked(skillId, async (path) => {
471
- const manifest = await this.manifest(path);
537
+ return this.reading(skillId, async (path) => {
538
+ const manifest = await this.manifest(path, false);
472
539
  if (revision < manifest.minimumRevision || revision > manifest.revision)
473
540
  return false;
474
541
  try {
475
542
  await this.snapshot(path, manifest, revision);
476
- return true;
543
+ return revision >= (await this.manifest(path, false)).minimumRevision;
477
544
  }
478
545
  catch (error) {
479
546
  if (error instanceof ServiceError && error.code === 'INVALID_REQUEST')
@@ -485,8 +552,10 @@ export class SkillConfigs {
485
552
  async environment(skillId, schema, revision) {
486
553
  const checked = schemaCopy(schema);
487
554
  revisionNumber(revision);
488
- return this.locked(skillId, async (path) => {
489
- const manifest = await this.manifest(path), snapshot = await this.snapshot(path, manifest, revision);
555
+ return this.reading(skillId, async (path) => {
556
+ const manifest = await this.manifest(path, false), snapshot = await this.snapshot(path, manifest, revision);
557
+ if (revision < (await this.manifest(path, false)).minimumRevision)
558
+ unavailable();
490
559
  return this.snapshotEnvironment(checked, snapshot);
491
560
  });
492
561
  }
@@ -501,9 +570,8 @@ export class SkillConfigs {
501
570
  }
502
571
  return environment;
503
572
  }
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. */
573
+ /** Read snapshots without write locks. Only final validation and synchronous
574
+ * dispatch exclude clear's commit, across instances and processes. */
507
575
  async withRevisions(requests, dispatch) {
508
576
  const unique = new Map();
509
577
  for (const request of requests) {
@@ -515,40 +583,30 @@ export class SkillConfigs {
515
583
  }
516
584
  const ids = [...unique.keys()].sort();
517
585
  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
- })();
586
+ const environments = new Map();
587
+ for (const skillId of ids) {
588
+ const request = unique.get(skillId);
589
+ await this.reading(skillId, async (path) => {
590
+ paths.set(skillId, path);
591
+ const manifest = await this.manifest(path, false);
592
+ const snapshot = await this.snapshot(path, manifest, request.revision);
593
+ if (request.schema)
594
+ environments.set(skillId, this.snapshotEnvironment(request.schema, snapshot));
595
+ });
596
+ }
597
+ const dispatchChecked = async () => {
598
+ for (const skillId of ids) {
599
+ const request = unique.get(skillId);
600
+ await this.reading(skillId, async () => {
601
+ const manifest = await this.manifest(paths.get(skillId), false);
602
+ if (request.revision < manifest.minimumRevision || request.revision > manifest.revision)
603
+ unavailable();
604
+ });
605
+ }
606
+ // Keep command promises inside the envelope so the lock ends at dispatch.
607
+ return { value: dispatch(environments) };
543
608
  };
544
- try {
545
- return await acquire(0);
546
- }
547
- catch (error) {
548
- if (dispatchFailed)
549
- throw dispatchError;
550
- throw error;
551
- }
609
+ return ids.length ? this.withDispatchLock(dispatchChecked) : dispatchChecked();
552
610
  }
553
611
  }
554
612
  //# sourceMappingURL=skill-configs.js.map
@@ -11,8 +11,7 @@ export declare class SkillSessions {
11
11
  freeze(prompt: string, raw: unknown, cwd: string): Promise<FrozenSkill[]>;
12
12
  private references;
13
13
  environment(skills: readonly FrozenSkill[], explicitOnly?: boolean): Promise<Record<string, string>>;
14
- /** Launch validates every frozen revision; activation resolves only its target.
15
- * Automatic skills with missing required values remain available until used. */
14
+ /** Resolve only skills used at launch or by this activation. */
16
15
  withEnvironment<T>(skills: readonly FrozenSkill[], skillId: string | null, dispatch: (environment: Record<string, string>, secrets: Record<string, string>) => T): Promise<{
17
16
  value: T;
18
17
  }>;
@@ -82,8 +82,7 @@ export class SkillSessions {
82
82
  async environment(skills, explicitOnly = false) {
83
83
  return (await this.withSelectedEnvironment(skills, skill => !explicitOnly || skill.explicit, environment => environment)).value;
84
84
  }
85
- /** Launch validates every frozen revision; activation resolves only its target.
86
- * Automatic skills with missing required values remain available until used. */
85
+ /** Resolve only skills used at launch or by this activation. */
87
86
  async withEnvironment(skills, skillId, dispatch) {
88
87
  if (skillId !== null) {
89
88
  const skill = skills.find(item => item.skillId === skillId && item.source === 'openworkgraph');
@@ -98,15 +97,17 @@ export class SkillSessions {
98
97
  const frozen = structuredClone(skills);
99
98
  const requests = [];
100
99
  for (const skill of frozen) {
100
+ if (!selected(skill))
101
+ continue;
101
102
  if (skill.source === 'codex') {
102
- if (selected(skill) && (!isAbsolute(skill.path) || basename(skill.path) !== 'SKILL.md' || (await fs.stat(skill.path)).size > 128 * 1024))
103
+ if (!isAbsolute(skill.path) || basename(skill.path) !== 'SKILL.md' || (await fs.stat(skill.path)).size > 128 * 1024)
103
104
  throw new ServiceError('INPUT_BLOCKED', '冻结 Codex 技能路径不可用:' + skill.name);
104
105
  continue;
105
106
  }
106
107
  const entry = await this.packages.packageEntry(skill.skillId, skill.packageVersion);
107
108
  if (entry.path !== skill.path || skill.configRevision === undefined)
108
109
  throw new ServiceError('INPUT_BLOCKED', '冻结技能版本不可用:' + skill.name);
109
- requests.push({ skillId: skill.skillId, revision: skill.configRevision, ...(selected(skill) ? { schema: entry.config } : {}) });
110
+ requests.push({ skillId: skill.skillId, revision: skill.configRevision, schema: entry.config });
110
111
  }
111
112
  return this.configs.withRevisions(requests, environments => {
112
113
  const environment = {};
@@ -11,7 +11,7 @@ export class SkillsManagementApi {
11
11
  async decorate(item) {
12
12
  if (!item.installed)
13
13
  return item;
14
- const entry = await this.runtime.skills.packageEntry(item.skillId, item.packageVersion);
14
+ const entry = await this.runtime.skills.packageMetadata(item.skillId, item.packageVersion);
15
15
  if (!entry.config.environment.length)
16
16
  return { ...item, configuration: 'none' };
17
17
  const config = await this.runtime.skillConfigs.read(item.skillId, entry.config);
@@ -65,7 +65,11 @@ export class SkillsManagementApi {
65
65
  const query = (url.searchParams.get('query') ?? '').trim().toLocaleLowerCase();
66
66
  if (query.length > 1024)
67
67
  throw new ServiceError('INVALID_REQUEST', '技能搜索内容过长。');
68
- const catalog = await this.runtime.skills.catalog(url.searchParams.get('refresh') === '1');
68
+ // Installed browsing only reads the host's installation records and schemas.
69
+ // Remote source availability must never delay this view, including refresh.
70
+ const catalog = installed
71
+ ? await this.runtime.skills.installedCatalog()
72
+ : await this.runtime.skills.catalog(url.searchParams.get('refresh') === '1');
69
73
  const filtered = catalog.items.filter(item => (!installed || item.installed) && (item.name + ' ' + item.description).toLocaleLowerCase().includes(query));
70
74
  // Unpaged calls remain compatible with welcome setup and skill suggestions.
71
75
  const paged = !installed && (url.searchParams.has('offset') || url.searchParams.has('limit'));
@@ -104,7 +108,7 @@ export class SkillsManagementApi {
104
108
  }
105
109
  else {
106
110
  if (request.method === 'GET') {
107
- const entry = await this.runtime.skills.packageEntry(id);
111
+ const entry = await this.runtime.skills.packageMetadata(id);
108
112
  result = { ...await this.runtime.skillConfigs.read(id, entry.config), packageVersion: entry.item.packageVersion };
109
113
  }
110
114
  else {
@@ -5,7 +5,7 @@ export * from './execution-chain.js';
5
5
  export * from './graph-title.js';
6
6
  export * from './graph-binary.js';
7
7
  export declare const PROTOCOL_VERSION: '1.0';
8
- export declare const SERVICE_VERSION: '0.3.1';
8
+ export declare const SERVICE_VERSION: '0.3.2';
9
9
  /** Work Graph uploads use the UI's binary MB convention. */
10
10
  export declare const WORKGRAPH_UPLOAD_MAX_BYTES: number;
11
11
  export declare const WORKGRAPH_TRANSFER_TOTAL_BYTES: number;
@@ -5,7 +5,7 @@ export * from './execution-chain.js';
5
5
  export * from './graph-title.js';
6
6
  export * from './graph-binary.js';
7
7
  export const PROTOCOL_VERSION = '1.0';
8
- export const SERVICE_VERSION = '0.3.1';
8
+ export const SERVICE_VERSION = '0.3.2';
9
9
  /** Work Graph uploads use the UI's binary MB convention. */
10
10
  export const WORKGRAPH_UPLOAD_MAX_BYTES = 300 * 1024 * 1024;
11
11
  export const WORKGRAPH_TRANSFER_TOTAL_BYTES = 1024 * 1024 * 1024;
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
- {
2
- "name": "openworkgraph", "version": "0.3.1", "type": "module",
3
- "publishConfig": { "access": "public" },
4
- "engines": { "node": ">=24.0.0" },
5
- "bin": { "openworkgraph": "dist/cli.js" },
6
- "files": ["dist/**/*.js", "dist/**/*.d.ts", "README.md"],
7
- "scripts": { "prebuild": "node ../scripts/check-versions.mjs --quiet", "build": "tsc -p tsconfig.json && node scripts/vendor-protocol.mjs", "prepack": "node scripts/prepare-pack.mjs", "typecheck": "tsc -p tsconfig.json --noEmit", "start": "node dist/cli.js", "test": "node --test test/*.test.mjs" },
8
- "dependencies": { "sharp": "^0.35.4", "yaml": "^2.9.1" }
9
- }
1
+ {
2
+ "name": "openworkgraph", "version": "0.3.2", "type": "module",
3
+ "publishConfig": { "access": "public" },
4
+ "engines": { "node": ">=24.0.0" },
5
+ "bin": { "openworkgraph": "dist/cli.js" },
6
+ "files": ["dist/**/*.js", "dist/**/*.d.ts", "README.md"],
7
+ "scripts": { "prebuild": "node ../scripts/check-versions.mjs --quiet", "build": "tsc -p tsconfig.json && node scripts/vendor-protocol.mjs", "prepack": "node scripts/prepare-pack.mjs", "typecheck": "tsc -p tsconfig.json --noEmit", "start": "node dist/cli.js", "test": "node --test test/*.test.mjs" },
8
+ "dependencies": { "sharp": "^0.35.4", "yaml": "^2.9.1" }
9
+ }