@mokuyoaxis/dsh-iris 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/.dsh/skills/iris-compose-media/SKILL.md +23 -73
  2. package/.dsh/skills/iris-compose-media/agents/openai.yaml +1 -1
  3. package/.dsh/skills/iris-compose-media/references/task-v2-recovery.md +23 -22
  4. package/.dsh/skills/iris-compose-media/references/workflows.md +25 -30
  5. package/.dsh/skills/iris-use/SKILL.md +41 -0
  6. package/.dsh/skills/iris-use/agents/openai.yaml +4 -0
  7. package/.dsh/skills/iris-use/references/media-contract.md +73 -0
  8. package/.dsh/skills/iris-verify-ui/SKILL.md +9 -7
  9. package/.dsh/skills/iris-verify-ui/agents/openai.yaml +1 -1
  10. package/.dsh/skills/iris-verify-ui/references/verdict-and-degradation.md +3 -0
  11. package/CHANGELOG.md +48 -2
  12. package/README.en.md +47 -40
  13. package/README.md +45 -39
  14. package/bin/dsh-iris.js +27 -6
  15. package/docs/AGENT_QUICKSTART.md +9 -9
  16. package/docs/ARCHITECTURE.md +11 -3
  17. package/docs/ARTIFACT_LIBRARY.md +1 -1
  18. package/docs/CLI_MANAGEMENT.md +5 -3
  19. package/docs/COMPOSITE_VISION.md +1 -1
  20. package/docs/CORE_RUNTIME_CONTRACT.md +12 -2
  21. package/docs/DSH_CORE_MIGRATION.md +7 -3
  22. package/docs/FAULT_INJECTION.md +11 -1
  23. package/docs/HEADLESS_CLI.md +10 -4
  24. package/docs/HOST_ADAPTER_CONTRACT.md +1 -1
  25. package/docs/HOST_DOCTOR.md +3 -3
  26. package/docs/MODEL_PORT_CONTRACT.md +9 -3
  27. package/docs/MUSIC.md +77 -0
  28. package/docs/OCR_MODEL.md +1 -1
  29. package/docs/PROMPT_OPTIMIZER.md +74 -6
  30. package/docs/PROVIDER_ADAPTER_CONTRACT.md +12 -2
  31. package/docs/PROVIDER_HEALTH.md +1 -1
  32. package/docs/ROADMAP.md +15 -18
  33. package/docs/SECURITY.md +4 -3
  34. package/docs/SYNC_DELIVERY_RECOVERY.md +39 -0
  35. package/docs/TASK_SEMANTICS.md +5 -5
  36. package/docs/VISION_CLI.md +1 -1
  37. package/docs/VISION_MODEL.md +1 -1
  38. package/docs/WORKBENCH_ARTIFACTS.md +1 -1
  39. package/docs/releases/0.2.2.md +31 -0
  40. package/docs/releases/0.2.3.md +54 -0
  41. package/lib/actions.js +52 -19
  42. package/lib/adapters.js +89 -24
  43. package/lib/api.js +14 -5
  44. package/lib/bundled-skills.js +1 -1
  45. package/lib/capability.js +25 -16
  46. package/lib/client.js +374 -43
  47. package/lib/command-service.js +12 -3
  48. package/lib/config.js +39 -6
  49. package/lib/core-artifact-store.js +44 -6
  50. package/lib/core-delivery-cache.js +145 -0
  51. package/lib/core-lease-recovery.js +64 -92
  52. package/lib/core-maintenance.js +12 -1
  53. package/lib/core-runtime.js +45 -47
  54. package/lib/core-tasks.js +19 -5
  55. package/lib/core-user-projection.js +2 -2
  56. package/lib/core-writer-lease.js +134 -0
  57. package/lib/dsh-core-adapter.js +14 -4
  58. package/lib/dsh-text-model-adapter.js +15 -2
  59. package/lib/generation-input.js +10 -0
  60. package/lib/host-runtime.js +4 -2
  61. package/lib/index.js +23 -4
  62. package/lib/models.js +23 -38
  63. package/lib/music-generation.js +21 -0
  64. package/lib/music-options.js +18 -0
  65. package/lib/music-transport.js +46 -0
  66. package/lib/prompt-optimizer-config.js +111 -30
  67. package/lib/prompt-optimizer-core.js +183 -12
  68. package/lib/prompt-optimizer.js +20 -14
  69. package/lib/provider-adapter.js +1 -0
  70. package/lib/provider-adapters.js +26 -2
  71. package/lib/provider-catalog.js +18 -7
  72. package/lib/provider-config-service.js +16 -5
  73. package/lib/provider-protocol.js +73 -0
  74. package/lib/provider-task-runner.js +86 -9
  75. package/package.json +4 -2
  76. package/user_guide.md +52 -24
@@ -20,6 +20,7 @@ import { GenerationInputError, normalizeGenerationInput } from './generation-inp
20
20
  import { videoTaskCandidates } from './video-input.js';
21
21
  import { waitForCoreTask } from './task-wait.js';
22
22
  import { planCoreDeletion, planCoreCleanup, quarantineCoreFiles, inspectCoreTransactions, restoreCoreTransaction } from './core-maintenance.js';
23
+ import { deliveryCacheNeedsProvider } from './core-delivery-cache.js';
23
24
 
24
25
  export const COMMAND_CONTRACT_VERSION = 0;
25
26
  export const CORE_COMMANDS = Object.freeze([
@@ -366,16 +367,20 @@ export function createCommandService(runtime, ports = {}) {
366
367
  });
367
368
  }
368
369
  // task.redeliver:只对 outcome=succeeded / deliveryState=failed 的任务重新取回
369
- // 已生成的远端产物(re-poll 拿产物清单,绝不 submit,也不会重新生成)。
370
+ // 已生成产物:同步结果读私有缓存,异步结果 re-poll;绝不 submit。
370
371
  if (name === 'task.redeliver') {
371
372
  exactInput(input, ['task_id']);
372
373
  return runtime.run('execute', async ({ dataRoot }) => {
373
374
  const before = inspectCoreTask(dataRoot, input.task_id);
374
- if (before.outcome !== 'succeeded' || before.deliveryState !== 'failed' || !before.remoteTaskId) throw commandError('IRIS_TASK_NOT_REDELIVERABLE', 'Task 不处于可重新交付状态;不会重新生成');
375
+ if (before.outcome !== 'succeeded' || before.deliveryState !== 'failed' || (!before.remoteTaskId && !before.deliveryCache)) throw commandError('IRIS_TASK_NOT_REDELIVERABLE', 'Task 不处于可重新交付状态;不会重新生成');
376
+ if (before.deliveryCache && !deliveryCacheNeedsProvider(dataRoot, before)) {
377
+ const task = await createProviderTaskRunner(runtime).redeliver(input.task_id);
378
+ return Object.freeze({ contractVersion: COMMAND_CONTRACT_VERSION, command: name, taskId: task.id, task });
379
+ }
375
380
  if (typeof ports.resolveTaskAdapter !== 'function') throw commandError('IRIS_COMMAND_PROVIDER_REQUIRED', 'task.redeliver 需要宿主显式提供 Task Provider resolver');
376
381
  const adapter = await ports.resolveTaskAdapter(before);
377
382
  if (!adapter || adapter.id !== before.providerId || !adapter.capabilities?.includes(before.capability)) throw commandError('IRIS_PROVIDER_TASK_IDENTITY_MISMATCH', 'Task Provider resolver 返回了不匹配的 Adapter');
378
- if (!hasProviderOperation(adapter, 'poll')) throw commandError('IRIS_PROVIDER_TASK_OBSERVE_UNSUPPORTED', 'Task 原 Provider 不支持 poll;不会重新生成');
383
+ if (!before.deliveryCache && !hasProviderOperation(adapter, 'poll')) throw commandError('IRIS_PROVIDER_TASK_OBSERVE_UNSUPPORTED', 'Task 原 Provider 不支持 poll;不会重新生成');
379
384
  const task = await createProviderTaskRunner(runtime).redeliver(input.task_id, adapter);
380
385
  return Object.freeze({ contractVersion: COMMAND_CONTRACT_VERSION, command: name, taskId: task.id, task });
381
386
  });
@@ -415,6 +420,10 @@ export function createCommandService(runtime, ports = {}) {
415
420
  throw commandError('IRIS_TASK_NOT_RETRYABLE', '只有终态且未成功交付的 Task 可以重试为新任务');
416
421
  }
417
422
  let normalized;
423
+ if (before.capability === 'music' && input.provider_input &&
424
+ ['retry_502', 'retry_delay_ms'].some(field => Object.hasOwn(input.provider_input, field))) {
425
+ throw commandError('IRIS_COMMAND_INPUT_INVALID', 'task.retry 只执行一次重新生成;502重试配置请使用Music生成入口');
426
+ }
418
427
  try { normalized = normalizeGenerationInput(before.capability, input.provider_input, { allowVideoPaths: before.capability === 'video' }); }
419
428
  catch (error) {
420
429
  if (error instanceof GenerationInputError) throw commandError('IRIS_COMMAND_INPUT_INVALID', error.message);
package/lib/config.js CHANGED
@@ -11,7 +11,7 @@ import os from 'node:os';
11
11
  import * as cap from './capability.js';
12
12
  import * as models from './models.js';
13
13
  import { atomicWritePrivate, chmodPrivateFile, privateSibling } from './private-storage.js';
14
- import { selectMediaProtocol, selectImageProtocol, normalizeImageProtocol, isConfiguredProvider } from './provider-protocol.js';
14
+ import { selectMediaProtocol, selectImageProtocol, normalizeImageProtocol, normalizeMusicProtocol, isConfiguredProvider } from './provider-protocol.js';
15
15
  import { redactProviderMessage } from './provider-contract.js';
16
16
  import {
17
17
  PROVIDER_HEALTH_FRESH_MS,
@@ -66,7 +66,7 @@ function normalizeStoredProvider(provider) {
66
66
  const changed = provider.mediaProtocol !== selected.mediaProtocol
67
67
  || (provider.protocolInferred === true) !== selected.protocolInferred;
68
68
  Object.assign(provider, selected);
69
- return models.migrateProviderModels(provider) || changed;
69
+ return changed;
70
70
  }
71
71
 
72
72
  export function irisHome() {
@@ -196,7 +196,7 @@ export function pickFor(capability) {
196
196
  m = pool.find((x) => !x.rateLimited && x.ref === ref && x.capabilities.includes(capability));
197
197
  if (m) break;
198
198
  }
199
- if (!m) m = models.pickModel(pool, capability);
199
+ if (!m && capability !== cap.CAPABILITIES.MUSIC) m = models.pickModel(pool, capability);
200
200
  if (!m) return null;
201
201
  const p = providerById(m.providerId);
202
202
  if (!p) return null;
@@ -489,6 +489,7 @@ export function upsert(provider) {
489
489
  if (rest.visionInput !== undefined) rest.visionInput = normalizeVisionInputLimits(rest.visionInput) || null;
490
490
  if (Array.isArray(rest.models)) rest.models = rest.models.map(model => typeof model === 'string' ? model : ({ ...model,
491
491
  ...(model?.imageProtocol !== undefined ? { imageProtocol: normalizeImageProtocol(model.imageProtocol) } : {}),
492
+ ...(model?.musicProtocol !== undefined ? { musicProtocol: normalizeMusicProtocol(model.musicProtocol) } : {}),
492
493
  ...(model?.visionInput !== undefined ? { visionInput: normalizeVisionInputLimits(model.visionInput) || null } : {}) }));
493
494
  if (id) {
494
495
  const i = c.providers.findIndex((p) => p.id === id);
@@ -541,6 +542,7 @@ export function setProviderModels(id, modelEntries) {
541
542
  p.models = modelEntries.map((m) => {
542
543
  const entry = typeof m === 'string' ? { id: m } : { id: m.id, capabilities: m.capabilities,
543
544
  ...(m.imageProtocol !== undefined ? { imageProtocol: normalizeImageProtocol(m.imageProtocol) } : {}),
545
+ ...(m.musicProtocol !== undefined ? { musicProtocol: normalizeMusicProtocol(m.musicProtocol) } : {}),
544
546
  ...(m.visionInput !== undefined ? { visionInput: normalizeVisionInputLimits(m.visionInput) || null } : {}) };
545
547
  const old = prev.get(entry.id);
546
548
  if (old) {
@@ -548,6 +550,7 @@ export function setProviderModels(id, modelEntries) {
548
550
  if (old.source) entry.source = old.source;
549
551
  if (old.source === 'manual' && Array.isArray(old.capabilities)) entry.capabilities = old.capabilities;
550
552
  if (m?.imageProtocol === undefined && old.imageProtocol !== undefined) entry.imageProtocol = old.imageProtocol;
553
+ if (m?.musicProtocol === undefined && old.musicProtocol !== undefined) entry.musicProtocol = old.musicProtocol;
551
554
  if (m?.visionInput === undefined && old.visionInput !== undefined) entry.visionInput = old.visionInput;
552
555
  }
553
556
  if (m && m.verified) entry.verified = m.verified;
@@ -565,10 +568,19 @@ export function setProviderModels(id, modelEntries) {
565
568
  export function mergeDiscoveredModels(id, modelEntries) {
566
569
  const provider = load().providers.find((p) => p.id === id);
567
570
  if (!provider) return null;
568
- const merged = new Map(models.providerModels(provider).map((m) => [m.id, m]));
571
+ const merged = new Map(models.providerModels(provider).map((m) => {
572
+ const raw = provider.models?.find((entry) => String(typeof entry === 'string' ? entry : entry?.id || '').trim() === m.id);
573
+ // 读取投影的 [] 不能伪造成用户明确清空;未标注条目以后仍可接收可信元数据。
574
+ if (!Array.isArray(raw?.capabilities) && !models.hasConfiguredModelCapabilities(provider, m.id)) {
575
+ const entry = { ...m };
576
+ delete entry.capabilities;
577
+ return [m.id, entry];
578
+ }
579
+ return [m.id, m];
580
+ }));
569
581
  for (const model of modelEntries) {
570
582
  const previous = merged.get(model.id);
571
- if (previous?.source === 'manual') continue;
583
+ if (previous && models.hasConfiguredModelCapabilities(provider, model.id)) continue;
572
584
  merged.set(model.id, { ...previous, ...model, source: 'discovered' });
573
585
  }
574
586
  return setProviderModels(id, [...merged.values()]);
@@ -581,6 +593,9 @@ function findModelEntry(p, modelId) {
581
593
 
582
594
  function resetChangedImageProtocolHealth(previous, next) {
583
595
  for (const model of models.providerModels(previous)) {
596
+ if (model.musicProtocol !== models.providerModels(next).find(item => item.id === model.id)?.musicProtocol) {
597
+ clearModelCapabilityHealth(next, model.id, cap.CAPABILITIES.MUSIC);
598
+ }
584
599
  if (selectImageProtocol(previous, model.id).mediaProtocol !== selectImageProtocol(next, model.id).mediaProtocol) {
585
600
  clearModelCapabilityHealth(next, model.id, cap.CAPABILITIES.IMAGE);
586
601
  }
@@ -606,6 +621,24 @@ export function setModelImageProtocol(id, modelId, value) {
606
621
  return true;
607
622
  }
608
623
 
624
+ /** 音乐协议独立绑定到已配置模型,不覆盖账号生图协议。 */
625
+ export function setModelMusicProtocol(id, modelId, value) {
626
+ const protocol = normalizeMusicProtocol(value);
627
+ const provider = load().providers.find(item => item.id === id);
628
+ if (!provider) return false;
629
+ const entries = Array.isArray(provider.models) ? provider.models
630
+ : models.providerModels(provider).map(({ id, capabilities }) => ({ id, capabilities }));
631
+ const index = entries.findIndex(model => (typeof model === 'string' ? model : model?.id)?.trim() === modelId);
632
+ if (index < 0) return false;
633
+ const entry = typeof entries[index] === 'string' ? { id: entries[index] } : entries[index];
634
+ entries[index] = entry;
635
+ provider.models = entries;
636
+ if (entry.musicProtocol !== protocol) clearModelCapabilityHealth(provider, modelId, cap.CAPABILITIES.MUSIC);
637
+ entry.musicProtocol = protocol;
638
+ persist();
639
+ return true;
640
+ }
641
+
609
642
  /** 看图预算变更不会伪造实测成功、解除额度停用或改写原图。 */
610
643
  export function setModelVisionInput(id, modelId, value) {
611
644
  const limits = normalizeVisionInputLimits(value);
@@ -631,7 +664,7 @@ export function addProviderModel(id, modelId, capabilities) {
631
664
  if (!Array.isArray(p.models)) p.models = [];
632
665
  let entry = findModelEntry(p, name);
633
666
  if (!entry) {
634
- entry = { id: name, capabilities: Array.isArray(capabilities) ? capabilities : [], source: 'manual' };
667
+ entry = { id: name, capabilities: Array.isArray(capabilities) ? capabilities : undefined, source: 'manual' };
635
668
  p.models.push(entry);
636
669
  } else if (Array.isArray(capabilities)) {
637
670
  entry.capabilities = capabilities;
@@ -305,20 +305,46 @@ function finishObject(dataRoot, input) {
305
305
  const relations = normalizedRelations(input.relations);
306
306
  validateRelationTargets(dataRoot, relations);
307
307
  const metadata = jsonClone(input.metadata, 'metadata');
308
- const id = 'artifact_' + crypto.randomBytes(12).toString('hex');
308
+ // 同步交付缓存预留的 ID 可重入提交,避免多产物交付或 Task 落盘中断后重复作品。
309
+ const resumable = input.artifactId !== undefined;
310
+ const id = resumable ? input.artifactId : 'artifact_' + crypto.randomBytes(12).toString('hex');
311
+ if (!ID.test(id) || (resumable && (!HASH.test(input.expectedDigest)
312
+ || !Number.isSafeInteger(input.expectedSize) || input.expectedSize < 1))) {
313
+ fail('IRIS_ARTIFACT_INPUT_INVALID', '可恢复 Artifact 身份或内容证据无效');
314
+ }
309
315
  const store = ensureStore(dataRoot);
310
316
  const objectName = id + extension;
311
317
  const object = path.join(store.objects, objectName);
318
+ const exists = (file) => {
319
+ try { fs.lstatSync(file); return true; }
320
+ catch (error) { if (error.code === 'ENOENT') return false; throw error; }
321
+ };
322
+ if (resumable && exists(recordPath(dataRoot, id))) {
323
+ const previous = readArtifact(dataRoot, id).manifest;
324
+ if (previous.size !== input.expectedSize || previous.digest.value !== input.expectedDigest
325
+ || previous.kind !== kind || previous.mediaType !== mediaType
326
+ || JSON.stringify(previous.metadata) !== JSON.stringify(metadata)
327
+ || JSON.stringify(previous.relations) !== JSON.stringify(relations)) {
328
+ fail('IRIS_ARTIFACT_INPUT_INVALID', '可恢复 Artifact 与已提交内容不一致');
329
+ }
330
+ try { refreshIndex(dataRoot); } catch (_) { /* 不撤销已有提交 */ }
331
+ return publicArtifact(previous);
332
+ }
312
333
  let stat;
313
334
  try {
314
- input.writeObject(object);
335
+ if (resumable && exists(object)) {
336
+ if (regularFile(object).size !== input.expectedSize || hashFile(object) !== input.expectedDigest) {
337
+ fail('IRIS_ARTIFACT_INPUT_INVALID', '未提交 Artifact 与恢复内容不一致');
338
+ }
339
+ } else input.writeObject(object);
315
340
  stat = regularFile(object);
316
- } catch (_) {
341
+ } catch (error) {
342
+ if (resumable) throw error;
317
343
  try { fs.rmSync(object, { force: true }); } catch (_) { /* 当前随机 ID */ }
318
344
  fail('IRIS_ARTIFACT_WRITE_FAILED', 'Artifact 对象无法安全写入');
319
345
  }
320
346
  if (!stat.size) {
321
- try { fs.rmSync(object, { force: true }); } catch (_) { /* 只清理当前随机 ID */ }
347
+ if (!resumable) try { fs.rmSync(object, { force: true }); } catch (_) { /* 只清理当前随机 ID */ }
322
348
  fail('IRIS_ARTIFACT_INPUT_INVALID', 'Artifact 内容不能为空');
323
349
  }
324
350
  const manifest = {
@@ -333,12 +359,23 @@ function finishObject(dataRoot, input) {
333
359
  relations,
334
360
  metadata
335
361
  };
362
+ if (resumable && (manifest.size !== input.expectedSize || manifest.digest.value !== input.expectedDigest)) {
363
+ fail('IRIS_ARTIFACT_INPUT_INVALID', 'Artifact 复制结果与恢复内容证据不一致');
364
+ }
365
+ if (resumable && exists(manifestPath(dataRoot, id))) {
366
+ const previous = validateManifest(jsonFile(manifestPath(dataRoot, id)), id);
367
+ if (JSON.stringify(previous) !== JSON.stringify(manifest)) {
368
+ fail('IRIS_ARTIFACT_INPUT_INVALID', '未提交 Artifact Manifest 与恢复内容不一致');
369
+ }
370
+ }
336
371
  try {
337
372
  writeManifest(dataRoot, manifest);
338
373
  commitRecord(dataRoot, id);
339
374
  } catch (_) {
340
- try { fs.rmSync(object, { force: true }); } catch (_) { /* 当前 ID */ }
341
- try { fs.rmSync(manifestPath(dataRoot, id), { force: true }); } catch (_) { /* 当前 ID */ }
375
+ if (!resumable) {
376
+ try { fs.rmSync(object, { force: true }); } catch (_) { /* 当前 ID */ }
377
+ try { fs.rmSync(manifestPath(dataRoot, id), { force: true }); } catch (_) { /* 当前 ID */ }
378
+ }
342
379
  fail('IRIS_ARTIFACT_WRITE_FAILED', 'Artifact 无法安全提交');
343
380
  }
344
381
  try { refreshIndex(dataRoot); } catch (_) { /* index 是可重建缓存,不撤销已提交 Artifact */ }
@@ -366,6 +403,7 @@ export function adoptCoreArtifactFile(dataRoot, input = {}) {
366
403
  return finishObject(dataRoot, {
367
404
  ...input,
368
405
  mediaType,
406
+ ...(input.artifactId ? { expectedSize: stat.size, expectedDigest: hashFile(source) } : {}),
369
407
  kind: input.kind || 'adopted',
370
408
  writeObject(object) {
371
409
  const tmp = privateSibling(object, 'adopt');
@@ -0,0 +1,145 @@
1
+ 'use strict';
2
+
3
+ /** 同步完成结果的私有交付证据;来源 URL/字节不进入 Task、Manifest 或日志。 */
4
+ import crypto from 'node:crypto';
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { atomicWritePrivate, ensurePrivateDir } from './private-storage.js';
8
+
9
+ const TASK_ID = /^task_[a-f0-9]{24}$/;
10
+ const ARTIFACT_ID = /^artifact_[a-f0-9]{24}$/;
11
+ const PAYLOAD_FILE = /^payload-[a-f0-9]{24}\.bin$/;
12
+ const HASH = /^[a-f0-9]{64}$/;
13
+
14
+ export class DeliveryCacheError extends Error {
15
+ constructor(message) {
16
+ super(message);
17
+ this.name = 'DeliveryCacheError';
18
+ this.code = 'IRIS_DELIVERY_CACHE_INVALID';
19
+ }
20
+ }
21
+
22
+ function invalid() { throw new DeliveryCacheError('同步交付缓存缺失、损坏或与 Task 身份不一致;不会重新生成'); }
23
+
24
+ function directory(dataRoot, taskId, create = false) {
25
+ if (!TASK_ID.test(taskId)) invalid();
26
+ let current = dataRoot;
27
+ for (const part of ['delivery-cache', 'v0', taskId]) {
28
+ current = path.join(current, part);
29
+ if (create) {
30
+ try { fs.lstatSync(current); }
31
+ catch (error) { if (error.code !== 'ENOENT') throw error; ensurePrivateDir(current); }
32
+ }
33
+ const stat = fs.lstatSync(current);
34
+ if (!stat.isDirectory() || stat.isSymbolicLink()) invalid();
35
+ }
36
+ return current;
37
+ }
38
+
39
+ function regular(file) {
40
+ const stat = fs.lstatSync(file);
41
+ if (!stat.isFile() || stat.isSymbolicLink()) invalid();
42
+ return stat;
43
+ }
44
+
45
+ function fingerprint(file) {
46
+ const stat = regular(file);
47
+ if (!stat.size) invalid();
48
+ const hash = crypto.createHash('sha256');
49
+ const chunk = Buffer.alloc(1024 * 1024);
50
+ const fd = fs.openSync(file, 'r');
51
+ try {
52
+ let count;
53
+ while ((count = fs.readSync(fd, chunk, 0, chunk.length, null))) hash.update(chunk.subarray(0, count));
54
+ } finally { fs.closeSync(fd); }
55
+ return { size: stat.size, sha256: hash.digest('hex') };
56
+ }
57
+
58
+ function save(dataRoot, cache) {
59
+ atomicWritePrivate(path.join(directory(dataRoot, cache.taskId), 'result.json'), JSON.stringify(cache, null, 2) + '\n');
60
+ }
61
+
62
+ export function createDeliveryCache(dataRoot, task, artifacts) {
63
+ if (!Array.isArray(artifacts) || !artifacts.length) invalid();
64
+ const root = directory(dataRoot, task.id, true);
65
+ try { fs.lstatSync(path.join(root, 'result.json')); invalid(); }
66
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
67
+ const cache = {
68
+ schemaVersion: 0, resultKind: 'completed', taskId: task.id, attemptId: task.activeAttemptId,
69
+ providerId: task.providerId, modelRef: task.modelRef, capability: task.capability,
70
+ ...(task.providerBinding ? { providerBinding: task.providerBinding } : {}),
71
+ createdAt: new Date().toISOString(), items: []
72
+ };
73
+ for (const remote of artifacts) {
74
+ const item = { artifactId: 'artifact_' + crypto.randomBytes(12).toString('hex') };
75
+ if (remote.kind === 'remote-url') {
76
+ item.source = { kind: 'remote-url', url: remote.url, ...(remote.mediaType ? { mediaType: remote.mediaType } : {}) };
77
+ } else if (remote.kind === 'inline-base64') {
78
+ const bytes = Buffer.from(remote.data, 'base64');
79
+ if (!bytes.length || bytes.toString('base64') !== remote.data) invalid();
80
+ const file = 'payload-' + crypto.randomBytes(12).toString('hex') + '.bin';
81
+ atomicWritePrivate(path.join(root, file), bytes);
82
+ item.payload = { file, size: bytes.length, sha256: crypto.createHash('sha256').update(bytes).digest('hex'),
83
+ ...(remote.mediaType ? { mediaType: remote.mediaType } : {}) };
84
+ } else invalid();
85
+ cache.items.push(item);
86
+ }
87
+ // result.json 是完整同步响应的提交证据;未发布的文件不作为可恢复结果。
88
+ save(dataRoot, cache);
89
+ return cache;
90
+ }
91
+
92
+ export function readDeliveryCache(dataRoot, task, { allowAbsent = false } = {}) {
93
+ let cache;
94
+ try {
95
+ const file = path.join(directory(dataRoot, task.id), 'result.json');
96
+ regular(file);
97
+ cache = JSON.parse(fs.readFileSync(file, 'utf8'));
98
+ } catch (error) {
99
+ if (allowAbsent && error.code === 'ENOENT') return null;
100
+ invalid();
101
+ }
102
+ if (!cache || cache.schemaVersion !== 0 || cache.resultKind !== 'completed'
103
+ || cache.taskId !== task.id || cache.attemptId !== task.activeAttemptId
104
+ || cache.providerId !== task.providerId || cache.modelRef !== task.modelRef
105
+ || cache.capability !== task.capability || cache.providerBinding !== task.providerBinding
106
+ || !Array.isArray(cache.items) || !cache.items.length
107
+ || typeof cache.createdAt !== 'string' || !Number.isFinite(Date.parse(cache.createdAt))) invalid();
108
+ const ids = new Set();
109
+ for (const item of cache.items) {
110
+ if (!item || !ARTIFACT_ID.test(item.artifactId) || ids.has(item.artifactId)) invalid();
111
+ ids.add(item.artifactId);
112
+ if (item.source && (item.source.kind !== 'remote-url' || typeof item.source.url !== 'string' || !item.source.url.trim()
113
+ || (item.source.mediaType !== undefined && typeof item.source.mediaType !== 'string'))) invalid();
114
+ if (item.payload && (!PAYLOAD_FILE.test(item.payload.file) || !HASH.test(item.payload.sha256)
115
+ || !Number.isSafeInteger(item.payload.size) || item.payload.size < 1
116
+ || (item.payload.mediaType !== undefined && typeof item.payload.mediaType !== 'string'))) invalid();
117
+ if (!item.payload && !item.source) invalid();
118
+ }
119
+ return cache;
120
+ }
121
+
122
+ export function deliveryCacheNeedsProvider(dataRoot, task) {
123
+ return readDeliveryCache(dataRoot, task).items.some(item => !item.payload);
124
+ }
125
+
126
+ export function newDeliveryPayloadPath(dataRoot, cache) {
127
+ return path.join(directory(dataRoot, cache.taskId), 'payload-' + crypto.randomBytes(12).toString('hex') + '.bin');
128
+ }
129
+
130
+ export function recordDeliveryPayload(dataRoot, cache, index, file, mediaType) {
131
+ const root = directory(dataRoot, cache.taskId);
132
+ if (path.dirname(file) !== root || !PAYLOAD_FILE.test(path.basename(file))) invalid();
133
+ cache.items[index].payload = { file: path.basename(file), ...fingerprint(file), mediaType };
134
+ delete cache.items[index].source;
135
+ save(dataRoot, cache);
136
+ }
137
+
138
+ export function readDeliveryPayload(dataRoot, cache, index) {
139
+ const payload = cache.items[index].payload;
140
+ const file = path.join(directory(dataRoot, cache.taskId), payload.file);
141
+ let actual;
142
+ try { actual = fingerprint(file); } catch (_) { invalid(); }
143
+ if (actual.size !== payload.size || actual.sha256 !== payload.sha256) invalid();
144
+ return file;
145
+ }
@@ -3,11 +3,11 @@
3
3
  import crypto from 'node:crypto';
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
- import { CORE_CONTRACT_VERSION, CoreContractError, normalizeCoreOptions } from './core-contract.js';
7
- import { writePrivateFile } from './private-storage.js';
6
+ import { CoreContractError, normalizeCoreOptions } from './core-contract.js';
7
+ import { newWriterOwner, publishLeaseRecord, readWriterLease, recoveryClaimFile, removeWriterIntent,
8
+ retireLeaseDirectory, withdrawLeaseRecord,
9
+ writerOwnerStatus } from './core-writer-lease.js';
8
10
 
9
- const WRITER_LEASE_DIR = '.iris-runtime-writer-v0';
10
- const WRITER_OWNER_FILE = 'owner.json';
11
11
  const RECOVERY_AUDIT_DIR = '.iris-runtime-recovery-audit-v0';
12
12
 
13
13
  function fail(code, message) {
@@ -27,34 +27,6 @@ function physicalDataRoot(dataRoot) {
27
27
  }
28
28
  }
29
29
 
30
- function readLease(root) {
31
- const directory = path.join(root, WRITER_LEASE_DIR);
32
- const stat = fs.lstatSync(directory);
33
- if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('invalid lease directory');
34
- const entries = fs.readdirSync(directory);
35
- if (entries.length !== 1 || entries[0] !== WRITER_OWNER_FILE) throw new Error('invalid lease contents');
36
- const ownerFile = path.join(directory, WRITER_OWNER_FILE);
37
- const ownerStat = fs.lstatSync(ownerFile);
38
- if (!ownerStat.isFile() || ownerStat.isSymbolicLink() || ownerStat.size > 16384) throw new Error('invalid owner');
39
- const serialized = fs.readFileSync(ownerFile, 'utf8');
40
- const owner = JSON.parse(serialized);
41
- const created = Date.parse(owner?.createdAt);
42
- if (owner?.contractVersion !== CORE_CONTRACT_VERSION || !Number.isSafeInteger(owner.pid) || owner.pid <= 0
43
- || !/^[a-f0-9-]{36}$/.test(String(owner.instanceId || '')) || !Number.isFinite(created)) {
44
- throw new Error('incomplete owner');
45
- }
46
- return { directory, ownerFile, owner, serialized, created };
47
- }
48
-
49
- function ownerStatus(pid) {
50
- try {
51
- process.kill(pid, 0);
52
- return 'alive';
53
- } catch (error) {
54
- return error?.code === 'ESRCH' ? 'missing' : 'unknown';
55
- }
56
- }
57
-
58
30
  function auditDirectory(root) {
59
31
  const directory = path.join(root, RECOVERY_AUDIT_DIR);
60
32
  try {
@@ -68,15 +40,12 @@ function auditDirectory(root) {
68
40
  return directory;
69
41
  }
70
42
 
71
- function releaseRecoveryLease(lease) {
72
- const stat = fs.lstatSync(lease.directory);
73
- if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('invalid recovery lease');
74
- const entries = fs.readdirSync(lease.directory);
75
- if (entries.length !== 1 || entries[0] !== WRITER_OWNER_FILE) throw new Error('unexpected recovery lease contents');
76
- const actual = JSON.parse(fs.readFileSync(lease.ownerFile, 'utf8'));
77
- if (actual.instanceId !== lease.owner.instanceId) throw new Error('recovery owner changed');
78
- fs.unlinkSync(lease.ownerFile);
79
- fs.rmdirSync(lease.directory);
43
+ function requireMissingOwners(lease) {
44
+ for (const owner of lease.owners) {
45
+ const status = writerOwnerStatus(owner.pid);
46
+ if (status === 'alive') fail('IRIS_CORE_LEASE_OWNER_ALIVE', '租约 owner PID 仍存活;拒绝恢复');
47
+ if (status !== 'missing') fail('IRIS_CORE_LEASE_OWNER_UNVERIFIED', '无法确认租约 owner PID 已退出;拒绝恢复');
48
+ }
80
49
  }
81
50
 
82
51
  /**
@@ -85,41 +54,45 @@ function releaseRecoveryLease(lease) {
85
54
  */
86
55
  export function recoverCoreWriterLease(dataRoot, { confirmStalePid } = {}) {
87
56
  const root = physicalDataRoot(dataRoot);
88
- const leaseDirectory = path.join(root, WRITER_LEASE_DIR);
89
- try { fs.lstatSync(leaseDirectory); }
90
- catch (error) {
91
- if (error?.code === 'ENOENT') fail('IRIS_CORE_LEASE_NOT_FOUND', '未发现可恢复的 Iris 写者租约');
92
- fail('IRIS_CORE_LEASE_INVALID', 'Iris 写者租约证据无法读取;已保留现场');
93
- }
94
57
  let lease;
95
- try { lease = readLease(root); }
58
+ try { lease = readWriterLease(root); }
96
59
  catch (_) { fail('IRIS_CORE_LEASE_INVALID', 'Iris 写者租约证据不完整;已保留现场'); }
60
+ if (!lease) fail('IRIS_CORE_LEASE_NOT_FOUND', '未发现可恢复的 Iris 写者租约');
97
61
  if (!Number.isSafeInteger(confirmStalePid) || confirmStalePid <= 0 || confirmStalePid !== lease.owner.pid) {
98
62
  fail('IRIS_CORE_LEASE_CONFIRMATION_MISMATCH', '确认的 owner PID 与租约证据不一致;未执行恢复');
99
63
  }
100
- const processStatus = ownerStatus(lease.owner.pid);
101
- if (processStatus === 'alive') fail('IRIS_CORE_LEASE_OWNER_ALIVE', '租约 owner PID 仍存活;拒绝恢复');
102
- if (processStatus !== 'missing') fail('IRIS_CORE_LEASE_OWNER_UNVERIFIED', '无法确认租约 owner PID 已退出;拒绝恢复');
64
+ requireMissingOwners(lease);
103
65
 
104
66
  const auditId = 'lease_recovery_' + crypto.randomBytes(12).toString('hex');
105
- const instanceId = crypto.randomUUID();
106
67
  const recoveredAt = new Date().toISOString();
107
- const recoveryOwner = Object.freeze({
108
- contractVersion: CORE_CONTRACT_VERSION,
109
- instanceId,
110
- pid: process.pid,
111
- createdAt: recoveredAt
112
- });
113
- const recoverySerialized = JSON.stringify(recoveryOwner, null, 2) + '\n';
114
- const staleOwnerFile = path.join(lease.directory, `.owner.stale-${instanceId}.json`);
115
- let tookLease = false;
68
+ const recoveryOwner = newWriterOwner();
69
+ const claimFile = recoveryClaimFile(root, lease.owner);
70
+ const claim = { schemaVersion: 1, previousInstanceId: lease.owner.instanceId, owner: recoveryOwner };
71
+ let published = false;
72
+ let retired = false;
116
73
  let auditFile = null;
117
74
  try {
118
- // 租约目录始终存在;普通 writer 在接管窗口内仍会 fail-fast。
119
- fs.renameSync(lease.ownerFile, staleOwnerFile);
120
- if (fs.readFileSync(staleOwnerFile, 'utf8') !== lease.serialized) throw new Error('lease changed');
121
- writePrivateFile(lease.ownerFile, recoverySerialized, { flag: 'wx' });
122
- tookLease = true;
75
+ if (readWriterLease(root)?.fingerprint !== lease.fingerprint) throw new Error('lease changed');
76
+ if (!lease.intent) {
77
+ // 兼容旧 owner 目录;固定意图屏障使目录退役后的中断也可诊断和续办。
78
+ fs.linkSync(path.join(lease.directory, 'owner.json'), lease.intentFile);
79
+ lease = readWriterLease(root);
80
+ requireMissingOwners(lease);
81
+ }
82
+ if (!lease.stat) {
83
+ // 已原子发布意图,但在 mkdir 前被杀;建立占位后仍只允许显式恢复。
84
+ fs.mkdirSync(lease.directory, { mode: 0o700 });
85
+ lease = readWriterLease(root);
86
+ }
87
+ // 每一步以父 owner UUID 命名且独占发布;并发恢复只有一个能取得下一步。
88
+ // 前驱不删除,中断后可确认最近恢复 PID 并继续,证据不会消失。
89
+ publishLeaseRecord(root, claimFile, claim);
90
+ published = true;
91
+ const claimed = readWriterLease(root);
92
+ if (claimed?.owner.instanceId !== recoveryOwner.instanceId
93
+ || claimed.stat?.dev !== lease.stat.dev || claimed.stat?.ino !== lease.stat.ino
94
+ || claimed.intent?.serialized !== lease.intent.serialized) throw new Error('lease changed');
95
+ requireMissingOwners(lease);
123
96
 
124
97
  const directory = auditDirectory(root);
125
98
  auditFile = path.join(directory, auditId + '.json');
@@ -129,38 +102,37 @@ export function recoverCoreWriterLease(dataRoot, { confirmStalePid } = {}) {
129
102
  action: 'writer-lease-recovery',
130
103
  recoveredAt,
131
104
  confirmation: { ownerPid: confirmStalePid },
132
- staleOwner: {
133
- contractVersion: lease.owner.contractVersion,
134
- instanceId: lease.owner.instanceId,
135
- pid: lease.owner.pid,
136
- createdAt: new Date(lease.created).toISOString()
137
- },
138
- recoveryOwner
105
+ staleOwner: lease.originalOwner,
106
+ recoveryOwner,
107
+ leasePhase: lease.phase,
108
+ evidenceDirectory: auditId + '.lease',
109
+ evidenceFingerprint: claimed.fingerprint,
110
+ recoveryClaims: claimed.claims.map(claim => claim.value),
111
+ authorization: 'retire-lease-directory'
139
112
  };
140
- writePrivateFile(auditFile, JSON.stringify(audit, null, 2) + '\n', { flag: 'wx' });
141
- fs.unlinkSync(staleOwnerFile);
142
- releaseRecoveryLease({
143
- directory: lease.directory,
144
- ownerFile: lease.ownerFile,
145
- owner: recoveryOwner
146
- });
113
+ publishLeaseRecord(root, auditFile, audit);
114
+ if (readWriterLease(root)?.fingerprint !== claimed.fingerprint) throw new Error('lease changed');
115
+ // 审计先发布,完整目录再原子退役。只在最终步骤撤下意图屏障。
116
+ retireLeaseDirectory(lease.directory, path.join(directory, audit.evidenceDirectory));
117
+ retired = true;
118
+ removeWriterIntent(lease);
119
+ // 意图屏障解除后只整理 UUID 专属旧证据,不再触碰当前租约路径。
120
+ for (const claim of claimed.claims) {
121
+ try { fs.renameSync(claim.file, path.join(directory, audit.evidenceDirectory, path.basename(claim.file))); }
122
+ catch (_) { /* 未整理的记录仍在根中,不阻止新 UUID 的 writer */ }
123
+ }
147
124
  return Object.freeze({
148
125
  status: 'recovered',
149
126
  auditId,
150
- staleOwner: Object.freeze({ pid: lease.owner.pid, createdAt: new Date(lease.created).toISOString() })
127
+ staleOwner: Object.freeze({ pid: lease.originalOwner.pid,
128
+ createdAt: new Date(Date.parse(lease.originalOwner.createdAt)).toISOString() })
151
129
  });
152
130
  } catch (error) {
153
- // 恢复失败时尽力还原原 owner;绝不把不确定现场宣布为可写。
154
- try {
155
- if (tookLease && fs.existsSync(lease.ownerFile)) {
156
- const actual = JSON.parse(fs.readFileSync(lease.ownerFile, 'utf8'));
157
- if (actual.instanceId === instanceId) fs.unlinkSync(lease.ownerFile);
158
- }
159
- if (fs.existsSync(lease.directory) && !fs.existsSync(lease.ownerFile)) {
160
- if (fs.existsSync(staleOwnerFile)) fs.renameSync(staleOwnerFile, lease.ownerFile);
161
- else writePrivateFile(lease.ownerFile, lease.serialized, { flag: 'wx' });
162
- }
163
- } catch (_) { /* 保留现场,由 Doctor 报告 invalid */ }
131
+ // 可捕获的错误只撤回本次最后一条 claim;原 owner 与旧恢复证据保持。
132
+ if (published && !retired) {
133
+ try { withdrawLeaseRecord(claimFile, claim); }
134
+ catch (_) { /* 保留可识别的恢复链供进程退出后显式重试 */ }
135
+ }
164
136
  if (error instanceof CoreContractError) throw error;
165
137
  fail('IRIS_CORE_LEASE_RECOVERY_FAILED',
166
138
  auditFile && fs.existsSync(auditFile)
@@ -4,11 +4,12 @@ import crypto from 'node:crypto';
4
4
  import { inspectCoreArtifact, refreshCoreArtifactInventory } from './core-artifacts.js';
5
5
  import { inspectCoreTask, listCoreTasks } from './core-tasks.js';
6
6
  import { atomicWritePrivate } from './private-storage.js';
7
+ import { readDeliveryCache } from './core-delivery-cache.js';
7
8
 
8
9
  const TASK = /^task_[a-f0-9]{24}$/;
9
10
  const ARTIFACT = /^artifact_[a-f0-9]{24}$/;
10
11
  const TRANSACTION = /^delete_[a-f0-9]{24}$/;
11
- const MANAGED = /^(?:task-store\/v0\/tasks\/task_[a-f0-9]{24}\.json|artifact-store\/v0\/(?:records|manifests)\/artifact_[a-f0-9]{24}\.json|artifact-store\/v0\/objects\/artifact_[a-f0-9]{24}\.(?:png|jpg|webp|gif|mp4|webm|wav|mp3|ogg|flac|txt|json)|provider-staging\/v0\/download-[1-9][0-9]*-[a-f0-9]{16}\.part)$/;
12
+ const MANAGED = /^(?:task-store\/v0\/tasks\/task_[a-f0-9]{24}\.json|artifact-store\/v0\/(?:records|manifests)\/artifact_[a-f0-9]{24}\.json|artifact-store\/v0\/objects\/artifact_[a-f0-9]{24}\.(?:png|jpg|webp|gif|mp4|webm|wav|mp3|ogg|flac|txt|json)|provider-staging\/v0\/download-[1-9][0-9]*-[a-f0-9]{16}\.part|delivery-cache\/v0\/task_[a-f0-9]{24}\/(?:result\.json|payload-[a-f0-9]{24}\.bin))$/;
12
13
  const fail = (code, message) => { throw Object.assign(new Error(message), { code }); };
13
14
  function fileDigest(file) {
14
15
  const hash = crypto.createHash('sha256'), chunk = Buffer.allocUnsafe(1024 * 1024), fd = fs.openSync(file, 'r');
@@ -68,6 +69,8 @@ export function planCoreDeletion(root, input) {
68
69
  const task = inspectCoreTask(root, id);
69
70
  if (task.phase !== 'terminal' || task.acceptance === 'unknown' || task.outcome === 'unknown') blockers.push({ id, reason: 'task_not_settled' });
70
71
  files.push(managedFile(root, 'task-store/v0/tasks/' + id + '.json'));
72
+ const cache = 'delivery-cache/v0/' + id;
73
+ for (const name of names(root, cache)) files.push(managedFile(root, cache + '/' + name));
71
74
  }
72
75
  for (const id of artifactIds) {
73
76
  inspectCoreArtifact(root, id);
@@ -83,6 +86,11 @@ export function planCoreDeletion(root, input) {
83
86
  for (const task of existing.tasks) {
84
87
  if (selectedTasks.has(task.id)) continue;
85
88
  for (const id of task.artifactIds) if (selectedArtifacts.has(id)) blockers.push({ id, referencedBy: task.id, reason: 'task_artifact_reference' });
89
+ if (task.deliveryCache && task.deliveryState !== 'ready') {
90
+ for (const item of readDeliveryCache(root, task).items) {
91
+ if (selectedArtifacts.has(item.artifactId)) blockers.push({ id: item.artifactId, referencedBy: task.id, reason: 'delivery_cache_reference' });
92
+ }
93
+ }
86
94
  if (selectedArtifacts.has(task.sourceArtifactId)) blockers.push({ id: task.sourceArtifactId, referencedBy: task.id, reason: 'task_input_reference' });
87
95
  if (selectedTasks.has(task.retriedFrom)) blockers.push({ id: task.retriedFrom, referencedBy: task.id, reason: 'retry_reference' });
88
96
  }
@@ -109,6 +117,9 @@ export function planCoreCleanup(root, { older_than_ms = 3600000 } = {}) {
109
117
  const referenced = new Set(existing.artifacts.map(artifact => artifact.id));
110
118
  for (const artifact of existing.artifacts) for (const edge of artifact.relations) referenced.add(edge.artifactId);
111
119
  for (const task of existing.tasks) for (const id of task.artifactIds) referenced.add(id);
120
+ for (const task of existing.tasks) if (task.deliveryCache && task.deliveryState !== 'ready') {
121
+ for (const item of readDeliveryCache(root, task).items) referenced.add(item.artifactId);
122
+ }
112
123
  for (const task of existing.tasks) if (task.sourceArtifactId) referenced.add(task.sourceArtifactId);
113
124
  for (const name of names(root, 'artifact-store/v0/manifests')) {
114
125
  if (/^artifact_[a-f0-9]{24}\.json$/.test(name)) {