@mokuyoaxis/dsh-iris 0.2.7 → 0.3.0-beta.1

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 (234) hide show
  1. package/CHANGELOG.md +143 -1
  2. package/README.en.md +109 -311
  3. package/README.md +106 -309
  4. package/bin/dsh-iris.js +452 -92
  5. package/docs/AGENT_CORE_QUICKSTART.md +64 -0
  6. package/docs/AGENT_QUICKSTART.md +2 -0
  7. package/docs/ARCHITECTURE.md +13 -3
  8. package/docs/ARTIFACT_MANIFEST.md +4 -0
  9. package/docs/AUDIO_FLOWS.md +87 -0
  10. package/docs/BETA_SCOPE.md +42 -0
  11. package/docs/CONTACT_SHEET.md +62 -0
  12. package/docs/CORE_CONNECTIONS.md +101 -0
  13. package/docs/CORE_QUICKSTART.md +110 -0
  14. package/docs/CORE_RUNTIME_CONTRACT.md +7 -2
  15. package/docs/CORE_SERVICE.md +101 -0
  16. package/docs/CORE_SKILLS.md +35 -3
  17. package/docs/DSH_COMPATIBILITY.md +21 -1
  18. package/docs/DSH_CORE_MIGRATION.md +6 -0
  19. package/docs/DSH_RC2_ADAPTATION.md +1 -1
  20. package/docs/DSH_RUNTIME.md +6 -2
  21. package/docs/HEADLESS_CLI.md +30 -5
  22. package/docs/HOST_ADAPTER_CONTRACT.md +5 -3
  23. package/docs/IMAGE_FLOW.md +4 -0
  24. package/docs/IMAGE_GENERATION.md +127 -0
  25. package/docs/LOCAL_API.md +4 -2
  26. package/docs/LOCAL_API_CONTRACT.md +42 -15
  27. package/docs/LOCAL_CREATION.md +7 -1
  28. package/docs/LOCAL_OPERATIONS.md +46 -0
  29. package/docs/LOCAL_SELECTION.md +57 -0
  30. package/docs/LOCAL_WEB.md +207 -0
  31. package/docs/MCP.md +212 -0
  32. package/docs/MEDIA_SCHEDULING.md +72 -0
  33. package/docs/MUSIC.md +2 -0
  34. package/docs/NETWORK_RESOURCES.md +106 -0
  35. package/docs/NETWORK_ROUTING.md +103 -0
  36. package/docs/NETWORK_WORKBENCH.md +65 -0
  37. package/docs/PANEL_EXTENSIONS.md +64 -0
  38. package/docs/PLUGINS.md +193 -0
  39. package/docs/PLUGIN_DEVELOPMENT.md +39 -0
  40. package/docs/PLUGIN_STARTUP.md +113 -0
  41. package/docs/PROVIDER_ADAPTER_CONTRACT.md +8 -2
  42. package/docs/PROVIDER_CREDENTIALS.md +67 -0
  43. package/docs/PROVIDER_EXTENSIONS.md +82 -0
  44. package/docs/PROVIDER_HEALTH.md +5 -1
  45. package/docs/PROVIDER_NETWORK.md +45 -0
  46. package/docs/PROVIDER_PLUGIN_CONTRACT.md +24 -0
  47. package/docs/PROVIDER_SETTINGS.md +91 -0
  48. package/docs/PROVIDER_SUBMISSION_CONTRACT.md +8 -0
  49. package/docs/ROADMAP.md +18 -8
  50. package/docs/SECURITY.md +1 -1
  51. package/docs/SYNC_DELIVERY_RECOVERY.md +5 -1
  52. package/docs/VIDEO_FLOWS.md +89 -0
  53. package/docs/VISION_CLI.md +1 -1
  54. package/docs/VISION_MODEL.md +2 -0
  55. package/docs/WEB_PROTOTYPE.md +21 -6
  56. package/docs/WEB_RECOVERY.md +46 -0
  57. package/docs/WORKBENCH_ARTIFACTS.md +6 -0
  58. package/docs/WORKBENCH_LOGIN.md +63 -0
  59. package/docs/assets/screenshots/core-connections-preview.webp +0 -0
  60. package/docs/assets/screenshots/core-model-pools-preview.webp +0 -0
  61. package/docs/assets/screenshots/core-overview-preview.webp +0 -0
  62. package/docs/assets/screenshots/core-spectrum-preview.webp +0 -0
  63. package/docs/assets/screenshots/core-studio-dark-preview.webp +0 -0
  64. package/docs/assets/screenshots/core-studio-mobile-preview.webp +0 -0
  65. package/docs/assets/screenshots/core-studio-preview.webp +0 -0
  66. package/docs/releases/0.3.0-alpha.1.md +51 -0
  67. package/docs/releases/0.3.0-beta.1.md +34 -0
  68. package/docs/screenshots.md +41 -1
  69. package/lib/actions.js +56 -4
  70. package/lib/adapters.js +75 -49
  71. package/lib/api.js +23 -13
  72. package/lib/application-runtime.js +73 -17
  73. package/lib/application-writer.js +193 -0
  74. package/lib/audio-flow-input.js +37 -0
  75. package/lib/client.d.ts +3 -3
  76. package/lib/client.js +402 -72
  77. package/lib/comfyui-adapter.js +95 -0
  78. package/lib/comfyui-client.js +60 -0
  79. package/lib/comfyui-inspection.js +73 -0
  80. package/lib/comfyui-workflow.js +94 -0
  81. package/lib/command-service.js +18 -5
  82. package/lib/config.js +49 -10
  83. package/lib/configured-image-runtime.js +69 -0
  84. package/lib/connection-skill.js +98 -0
  85. package/lib/connection-template.js +23 -0
  86. package/lib/core-artifact-store.js +84 -28
  87. package/lib/core-bridge-selection.js +31 -0
  88. package/lib/core-bridge-tools.js +85 -0
  89. package/lib/core-capability-catalog.js +192 -0
  90. package/lib/core-connection-diagnostics.js +49 -0
  91. package/lib/core-connection-store.js +277 -0
  92. package/lib/core-connections.js +225 -0
  93. package/lib/core-delivery-cache.js +8 -3
  94. package/lib/core-maintenance.js +9 -3
  95. package/lib/core-runtime.js +2 -1
  96. package/lib/core-service-client.js +174 -0
  97. package/lib/core-tasks.js +113 -21
  98. package/lib/core-writer-lease.js +16 -0
  99. package/lib/creation-request-store.js +41 -64
  100. package/lib/doctor.js +2 -2
  101. package/lib/dsh-core-adapter.js +61 -9
  102. package/lib/dsh-host-adapter.js +4 -1
  103. package/lib/dsh-provider-config.js +1 -1
  104. package/lib/dsh-service-bridge.js +67 -0
  105. package/lib/execution-ownership.js +43 -0
  106. package/lib/flow-store.js +156 -14
  107. package/lib/generation-input.js +10 -1
  108. package/lib/headless-vision.js +7 -7
  109. package/lib/image-edit-input.js +1 -1
  110. package/lib/image-flow.js +97 -31
  111. package/lib/index.js +47 -22
  112. package/lib/local-api-projection.js +15 -6
  113. package/lib/local-api-token-directory.js +31 -0
  114. package/lib/local-api.js +861 -71
  115. package/lib/local-creation.js +96 -24
  116. package/lib/local-operations.js +36 -0
  117. package/lib/local-request-root.js +86 -0
  118. package/lib/local-web-assets.js +39 -0
  119. package/lib/mcp-bridge.js +58 -0
  120. package/lib/mcp-check.js +26 -0
  121. package/lib/mcp-config.js +42 -0
  122. package/lib/mcp-delivery.js +46 -0
  123. package/lib/mcp-http.js +174 -0
  124. package/lib/mcp-server.js +69 -0
  125. package/lib/media-scheduler.js +194 -0
  126. package/lib/model-directory-service.js +37 -10
  127. package/lib/model-directory.js +3 -3
  128. package/lib/models.js +2 -2
  129. package/lib/music-transport.js +6 -3
  130. package/lib/network-config-service.js +123 -0
  131. package/lib/network-config.js +127 -0
  132. package/lib/network-doctor.js +94 -0
  133. package/lib/network-management.js +51 -0
  134. package/lib/network-policy.js +19 -0
  135. package/lib/network-route-evidence.js +38 -0
  136. package/lib/network-routing.js +283 -0
  137. package/lib/operation-runner.js +281 -0
  138. package/lib/operation-store.js +216 -0
  139. package/lib/panel-extension.js +36 -0
  140. package/lib/plugin-contract.js +194 -0
  141. package/lib/plugin-execution.js +51 -0
  142. package/lib/plugin-launch.js +86 -0
  143. package/lib/plugin-operation-store.js +101 -0
  144. package/lib/plugin-operations.js +28 -0
  145. package/lib/plugin-registry.js +109 -0
  146. package/lib/plugin-selection.js +15 -0
  147. package/lib/plugin-source-image.js +5 -0
  148. package/lib/plugin-web-assets.js +54 -0
  149. package/lib/plugins.d.ts +307 -0
  150. package/lib/plugins.js +59 -0
  151. package/lib/provider-adapter.js +7 -3
  152. package/lib/provider-adapters.js +92 -22
  153. package/lib/provider-catalog.js +64 -36
  154. package/lib/provider-config-format.js +51 -0
  155. package/lib/provider-config-service.js +143 -31
  156. package/lib/provider-context.d.ts +90 -0
  157. package/lib/provider-context.js +42 -0
  158. package/lib/provider-credentials.js +97 -0
  159. package/lib/provider-diagnostics.js +56 -0
  160. package/lib/provider-extensions.js +29 -0
  161. package/lib/provider-health.js +18 -6
  162. package/lib/provider-monitor.js +81 -0
  163. package/lib/provider-network.js +52 -0
  164. package/lib/provider-protocol.js +73 -22
  165. package/lib/provider-registry.js +128 -0
  166. package/lib/provider-response.js +38 -0
  167. package/lib/provider-settings.js +136 -0
  168. package/lib/provider-task-runner.js +132 -43
  169. package/lib/provider-transport.js +16 -0
  170. package/lib/render.js +11 -1
  171. package/lib/scheduling-policy.js +66 -0
  172. package/lib/skill-installation.js +23 -11
  173. package/lib/tasks.js +3 -2
  174. package/lib/video-flow-input.js +16 -0
  175. package/lib/video-input.js +12 -5
  176. package/lib/vision-model-routing.js +5 -0
  177. package/lib/web/app.js +703 -0
  178. package/lib/web/appearance.js +111 -0
  179. package/lib/web/comfyui.js +146 -0
  180. package/lib/web/connections.js +516 -0
  181. package/lib/web/creation.js +359 -0
  182. package/lib/web/flow-view.js +34 -0
  183. package/lib/web/gallery.js +133 -0
  184. package/lib/web/index.html +344 -0
  185. package/lib/web/media-client.js +218 -0
  186. package/lib/web/network.js +232 -0
  187. package/lib/web/plugin-panel.js +223 -0
  188. package/lib/web/plugin-status.js +140 -0
  189. package/lib/web/plugin-surface.css +17 -0
  190. package/lib/web/providers.js +513 -0
  191. package/lib/web/recovery.js +105 -0
  192. package/lib/web/startup.js +18 -0
  193. package/lib/web/styles.css +571 -0
  194. package/lib/web/transport.js +101 -0
  195. package/lib/web/workbench-login.js +165 -0
  196. package/lib/workbench-artifacts.js +3 -2
  197. package/lib/workbench-auth-cli.js +47 -0
  198. package/lib/workbench-auth.js +200 -0
  199. package/package.json +24 -6
  200. package/plugins/contact-sheet/index.d.mts +4 -0
  201. package/plugins/contact-sheet/index.mjs +33 -0
  202. package/plugins/contact-sheet/web/board.css +14 -0
  203. package/plugins/contact-sheet/web/board.mjs +93 -0
  204. package/skills/iris-core/SKILL.md +8 -4
  205. package/skills/iris-core/references/cli.md +2 -2
  206. package/skills/iris-core/references/comfyui.md +69 -0
  207. package/skills/iris-core/references/tasks.md +2 -0
  208. package/templates/plugin-starter/LICENSE +21 -0
  209. package/templates/plugin-starter/README.md +74 -0
  210. package/templates/plugin-starter/assets/peach.png +0 -0
  211. package/templates/plugin-starter/assets/violet.png +0 -0
  212. package/templates/plugin-starter/index.mjs +27 -0
  213. package/templates/plugin-starter/package.json +18 -0
  214. package/templates/plugin-starter/scripts/core-cli.mjs +20 -0
  215. package/templates/plugin-starter/scripts/environment.mjs +65 -0
  216. package/templates/plugin-starter/scripts/mcp-probe.mjs +22 -0
  217. package/templates/plugin-starter/scripts/setup.mjs +42 -0
  218. package/templates/plugin-starter/scripts/smoke.mjs +76 -0
  219. package/templates/plugin-starter/web/board.css +27 -0
  220. package/templates/plugin-starter/web/board.mjs +160 -0
  221. package/templates/plugin-starter/web/png.mjs +23 -0
  222. package/templates/provider-starter/LICENSE +21 -0
  223. package/templates/provider-starter/README.md +50 -0
  224. package/templates/provider-starter/index.d.mts +5 -0
  225. package/templates/provider-starter/index.mjs +165 -0
  226. package/templates/provider-starter/package.json +13 -0
  227. package/templates/provider-starter/scripts/core-cli.mjs +22 -0
  228. package/templates/provider-starter/scripts/environment.mjs +122 -0
  229. package/templates/provider-starter/scripts/fixture.mjs +54 -0
  230. package/templates/provider-starter/scripts/mcp-probe.mjs +21 -0
  231. package/templates/provider-starter/scripts/protocol-smoke.mjs +48 -0
  232. package/templates/provider-starter/scripts/setup.mjs +44 -0
  233. package/templates/provider-starter/scripts/smoke.mjs +145 -0
  234. package/user_guide.md +5 -1
package/lib/image-flow.js CHANGED
@@ -1,14 +1,20 @@
1
1
  'use strict';
2
2
 
3
- import { createCoreArtifact, exportCoreArtifact, readCoreArtifactBytes } from './core-artifacts.js';
3
+ import { createCoreArtifact, exportCoreArtifact, inspectCoreArtifactMetadata, readCoreArtifactBytes } from './core-artifacts.js';
4
4
  import { assertFlowTaskMatches, claimFlowTask, findFlowTask, recoverCoreTask } from './core-tasks.js';
5
5
  import { deliveryCacheNeedsProvider } from './core-delivery-cache.js';
6
6
  import { normalizeGenerationInput } from './generation-input.js';
7
+ import { videoModelSupportsMode } from './video-input.js';
7
8
  import { imageEditCandidates, readImageEditArtifact } from './image-edit-input.js';
8
9
  import { parseModelRef } from './models.js';
9
10
  import { pixelDiff } from './pixels.js';
10
11
  import { createProviderTaskRunner } from './provider-task-runner.js';
11
- import { commitFlowCreation, flowFail, flowSha256, initializeFlowRoot, inspectFlowRoot, listFlowRuns, prepareFlowCreation, readFlowRun, updateFlowRun } from './flow-store.js';
12
+ import { prepareProviderInput } from './provider-adapter.js';
13
+ import { sameNetworkRequirement } from './network-policy.js';
14
+ import { VIDEO_RECIPE, VIDEO_TYPES, MAX_VIDEO_BYTES, normalizeVideoFlowInput } from './video-flow-input.js';
15
+ import { AUDIO_RECIPES, AUDIO_TYPES, isAudioRecipe, normalizeAudioFlowInput, readImportAudio } from './audio-flow-input.js';
16
+ import { commitFlowCreation, flowFail, flowSha256, flowStep, flowStepId, initializeFlowRoot, inspectFlowRoot,
17
+ IMAGE_GENERATE_RECIPE, requireImageGenerationRoot, requireAudioRoot, requireVideoRoot, listFlowRuns, prepareFlowCreation, readFlowRun, updateFlowRun } from './flow-store.js';
12
18
 
13
19
  function exact(input, allowed) {
14
20
  if (!input || typeof input !== 'object' || Array.isArray(input) || Object.keys(input).some(key => !allowed.includes(key))) {
@@ -24,14 +30,15 @@ function readReference(root, expected) {
24
30
  }
25
31
 
26
32
  function facts(root, run) {
27
- const source = readReference(root, run.source);
33
+ const source = run.source ? readReference(root, run.source) : null;
28
34
  const task = findFlowTask(root, run);
29
35
  if (run.output) {
30
36
  if (task.deliveryState !== 'ready' || task.artifactIds.length !== 1 || task.artifactIds[0] !== run.output.artifactId) {
31
37
  flowFail('IRIS_FLOW_TASK_CONFLICT', '流程输出与原 Task 的交付事实不一致');
32
38
  }
33
39
  const { artifact } = readReference(root, run.output);
34
- if (artifact.metadata.taskId !== task.id || !artifact.relations.some(edge => edge.type === 'derived-from' && edge.artifactId === source.artifact.id)) {
40
+ if (artifact.metadata.taskId !== task.id || source && !artifact.relations.some(edge => edge.type === 'derived-from' && edge.artifactId === source.artifact.id)
41
+ || !source && artifact.relations.length) {
35
42
  flowFail('IRIS_FLOW_ARTIFACT_CONFLICT', '流程输出缺少原 Task 或来源关系');
36
43
  }
37
44
  }
@@ -45,35 +52,62 @@ function facts(root, run) {
45
52
  }
46
53
  let status = 'not_started';
47
54
  if (task?.acceptance === 'unknown' || ['unknown', 'failed', 'canceled'].includes(task?.outcome)) status = 'needs_attention';
48
- else if (task?.deliveryState === 'ready') status = run.comparison ? run.selection ? 'selected' : 'awaiting_selection' : 'needs_comparison';
55
+ else if (task?.deliveryState === 'ready') status = isAudioRecipe(run.recipe) || run.recipe === VIDEO_RECIPE ? run.output ? 'ready' : 'needs_result'
56
+ : run.output && (run.recipe === IMAGE_GENERATE_RECIPE || run.comparison)
57
+ ? run.selection ? 'selected' : 'awaiting_selection' : run.recipe === IMAGE_GENERATE_RECIPE ? 'needs_result' : 'needs_comparison';
49
58
  else if (task?.deliveryState === 'failed') status = 'delivery_failed';
50
59
  else if (task?.attempts.length) status = task.acceptance === 'accepted' ? 'running' : 'needs_attention';
51
60
  return { contractVersion: 0, run, task, status, ...(comparison ? { metrics: comparison.metadata } : {}) };
52
61
  }
53
62
 
54
- /** Internal fixed recipe. All state/leases belong to the supplied operation-scoped runtime. */
55
- export function createImageFlowService(runtime, { resolveImageCandidates, resolveTaskAdapter } = {}) {
63
+ /** Fixed media recipes, sharing operation-scoped leases and the original Task. */
64
+ export function createImageFlowService(runtime, { resolveImageCandidates, resolveAudioCandidates, resolveVideoCandidates, resolveTaskAdapter } = {}) {
56
65
  function creationInput(input) {
66
+ if (input?.recipe === VIDEO_RECIPE) { const { recipe, ...value } = input; return { recipe, ...normalizeVideoFlowInput(value) }; }
67
+ if (isAudioRecipe(input?.recipe)) {
68
+ const { recipe, ...value } = input;
69
+ return { recipe, ...normalizeAudioFlowInput(recipe, value) };
70
+ }
57
71
  const normalized = normalizeGenerationInput('image', input);
58
- if (!normalized.sourceArtifactId || !parseModelRef(normalized.modelRef) || normalized.providerInput.n !== 1) {
59
- flowFail('IRIS_FLOW_INPUT_INVALID', '首版流程需要来源 Artifact、明确 model_ref 和 n=1');
72
+ if (!parseModelRef(normalized.modelRef) || normalized.providerInput.n !== 1) {
73
+ flowFail('IRIS_FLOW_INPUT_INVALID', '图片流程需要明确 model_ref 和 n=1;改图还需来源 Artifact');
60
74
  }
61
75
  return { prompt: normalized.providerInput.prompt, model_ref: normalized.modelRef,
62
- source_artifact_id: normalized.sourceArtifactId, n: 1,
63
- ...(normalized.providerInput.size !== undefined ? { size: normalized.providerInput.size } : {}) };
76
+ ...(normalized.sourceArtifactId ? { source_artifact_id: normalized.sourceArtifactId } : {}), n: 1,
77
+ ...(normalized.providerInput.size !== undefined ? { size: normalized.providerInput.size } : {}),
78
+ ...(normalized.providerInput.workflow_parameters !== undefined ? { workflow_parameters: normalized.providerInput.workflow_parameters } : {}) };
64
79
  }
65
80
  async function prepareCreation(root, signal, input) {
66
81
  const owned = creationInput(input);
67
82
  inspectFlowRoot(root);
68
- const source = await readImageEditArtifact(root, owned.source_artifact_id, signal);
83
+ const audio = isAudioRecipe(owned.recipe);
84
+ const video = owned.recipe === VIDEO_RECIPE;
85
+ if (video) requireVideoRoot(root);
86
+ else if (audio) requireAudioRoot(root);
87
+ else if (!owned.source_artifact_id) requireImageGenerationRoot(root);
88
+ const source = owned.source_artifact_id ? audio ? readAudioSource(root, owned.source_artifact_id) : await readImageEditArtifact(root, owned.source_artifact_id, signal) : null;
69
89
  const candidates = await candidatesFor(owned, signal);
70
- return prepareFlowCreation({ source: { artifactId: owned.source_artifact_id, sha256: flowSha256(source.bytes) },
71
- input: owned, providerBinding: candidates[0].providerBinding });
90
+ const { recipe, ...audioInput } = owned;
91
+ return prepareFlowCreation({ ...(source ? { source: { artifactId: owned.source_artifact_id, sha256: flowSha256(source.bytes) } } : {}),
92
+ input: audio || video ? audioInput : owned, providerBinding: candidates[0].providerBinding,
93
+ ...(candidates[0].adapter.networkRequirement ? { networkRequirement: candidates[0].adapter.networkRequirement } : {}), ...(audio || video ? { recipe } : {}) });
94
+ }
95
+ function readAudioSource(root, id) {
96
+ const source = readCoreArtifactBytes(root, id);
97
+ readImportAudio(source.bytes, source.artifact.mediaType);
98
+ return source;
72
99
  }
73
100
  async function candidatesFor(runInput, signal, binding) {
74
- if (!resolveImageCandidates) flowFail('IRIS_FLOW_PROVIDER_REQUIRED', '创建或首次提交流程需要显式 Provider 配置');
101
+ const audio = isAudioRecipe(runInput.recipe), video = runInput.recipe === VIDEO_RECIPE, resolver = video ? resolveVideoCandidates : audio ? resolveAudioCandidates : resolveImageCandidates;
102
+ // Keep historical records readable; restrict only creation/first dispatch.
103
+ if (video && !videoModelSupportsMode(parseModelRef(runInput.model_ref).modelId, 't2v')) {
104
+ flowFail('IRIS_VIDEO_FLOW_INPUT_INVALID', '固定文生视频流程需要匹配的模型,不接受图生视频、S2V 或参考视频模型;已有 Task 仍可按原身份观察');
105
+ }
106
+ if (!resolver) flowFail('IRIS_FLOW_PROVIDER_REQUIRED', '创建或首次提交流程需要显式 Provider 配置');
75
107
  signal.throwIfAborted();
76
- const candidates = await resolveImageCandidates({ modelRef: runInput.model_ref, signal });
108
+ const resolve = () => resolver({ modelRef: runInput.model_ref, signal, input: runInput,
109
+ ...(audio ? { capability: AUDIO_RECIPES[runInput.recipe].capability } : {}) });
110
+ const candidates = await (runtime.networkWait ? runtime.networkWait(resolve, 'candidates') : resolve());
77
111
  signal.throwIfAborted();
78
112
  const parsed = parseModelRef(runInput.model_ref);
79
113
  if (!Array.isArray(candidates) || candidates.length !== 1 || candidates[0].model !== runInput.model_ref
@@ -81,12 +115,14 @@ export function createImageFlowService(runtime, { resolveImageCandidates, resolv
81
115
  flowFail('IRIS_FLOW_INPUT_CONFLICT', '流程必须绑定单一明确模型和有效的连接摘要');
82
116
  }
83
117
  if (binding !== undefined && candidates[0].providerBinding !== binding) flowFail('IRIS_FLOW_PROVIDER_CHANGED', '连接或协议已改变;原流程不重新绑定或生成');
84
- return imageEditCandidates([{ ...candidates[0], providerModel: parsed.modelId, selectionReason: 'explicit' }]);
118
+ const resolved = [{ ...candidates[0], providerModel: parsed.modelId, selectionReason: 'explicit' }];
119
+ return runInput.source_artifact_id && !audio ? imageEditCandidates(resolved) : resolved;
85
120
  }
86
121
 
87
122
  async function adapterFor(task, signal) {
88
123
  if (!resolveTaskAdapter) flowFail('IRIS_FLOW_PROVIDER_REQUIRED', '观察或远端补交付需要原 Task 的 Provider 配置');
89
- const adapter = await resolveTaskAdapter(task, { signal });
124
+ const resolve = () => resolveTaskAdapter(task, { signal });
125
+ const adapter = await (runtime.networkWait ? runtime.networkWait(resolve, 'candidates') : resolve());
90
126
  signal.throwIfAborted();
91
127
  return adapter;
92
128
  }
@@ -94,16 +130,29 @@ export function createImageFlowService(runtime, { resolveImageCandidates, resolv
94
130
  async function continueRun(root, signal, id) {
95
131
  let run = readFlowRun(root, id);
96
132
  let task = facts(root, run).task;
97
- if (task?.attempts.length && !run.edit.claimed) flowFail('IRIS_FLOW_TASK_CONFLICT', '步骤检查点与已开始的 Task 冲突');
133
+ if (task?.attempts.length && !flowStep(run).claimed) flowFail('IRIS_FLOW_TASK_CONFLICT', '步骤检查点与已开始的 Task 冲突');
98
134
  const runner = createProviderTaskRunner(runtime);
135
+ if (task) runner.assertIdle(task.id);
99
136
  if (!task || !task.attempts.length) {
100
- const candidates = await candidatesFor(run.input, signal, run.providerBinding);
137
+ const audio = isAudioRecipe(run.recipe);
138
+ const video = run.recipe === VIDEO_RECIPE;
139
+ let candidates = await candidatesFor({ ...run.input, ...(audio || video ? { recipe: run.recipe } : {}) }, signal, run.providerBinding);
140
+ if (!sameNetworkRequirement(run.networkRequirement, candidates[0].adapter.networkRequirement)) flowFail('IRIS_NETWORK_REQUIREMENT_MISMATCH', '流程网络要求已改变,不会隐式转换或解除');
141
+ if (run.recipe === 'transcribe/v0') {
142
+ const source = readAudioSource(root, run.source.artifactId);
143
+ candidates = candidates.map(candidate => ({ ...candidate, prepareInput: async ({ signal, taskId, attemptId, networkBudget, recordNetworkRoute, modelRef, providerId }) => {
144
+ const prepared = await prepareProviderInput(candidate.adapter, { model: candidate.providerModel, bytes: source.bytes,
145
+ filename: source.artifact.id + '.' + AUDIO_TYPES[source.artifact.mediaType], signal }, { taskId, attemptId, networkBudget, recordNetworkRoute, modelRef, providerId });
146
+ return { audioUrl: prepared.url };
147
+ } }));
148
+ }
101
149
  task = claimFlowTask(root, run.id);
102
- if (!run.edit.claimed) run = updateFlowRun(root, run, { edit: { ...run.edit, claimed: true } });
150
+ if (!flowStep(run).claimed) run = updateFlowRun(root, run, { [flowStepId(run)]: { ...flowStep(run), claimed: true } });
103
151
  signal.throwIfAborted();
104
- const normalized = normalizeGenerationInput('image', run.input);
105
- task = (await runner.submit({ capability: 'image', candidates, providerInput: normalized.providerInput,
106
- sourceArtifactId: run.source.artifactId, flowRunId: run.id })).task;
152
+ const normalized = audio || video ? Object.fromEntries(Object.entries(run.input).filter(([key]) => key !== 'model_ref'))
153
+ : normalizeGenerationInput('image', run.input).providerInput;
154
+ task = (await runner.submit({ capability: video ? 'video' : audio ? AUDIO_RECIPES[run.recipe].capability : 'image', candidates, providerInput: normalized,
155
+ sourceArtifactId: run.source?.artifactId, flowRunId: run.id })).task;
107
156
  } else {
108
157
  task = recoverCoreTask(root, task.id);
109
158
  assertFlowTaskMatches(task, run);
@@ -116,11 +165,17 @@ export function createImageFlowService(runtime, { resolveImageCandidates, resolv
116
165
  }
117
166
  signal.throwIfAborted();
118
167
  if (task.deliveryState !== 'ready') return facts(root, run);
119
- if (task.artifactIds.length !== 1) flowFail('IRIS_FLOW_RESULT_UNSUPPORTED', '首版流程只支持一个改图结果,原 Task 与作品已保留');
168
+ if (task.artifactIds.length !== 1) flowFail('IRIS_FLOW_RESULT_UNSUPPORTED', '当前流程只支持一个结果,原 Task 与作品已保留');
169
+ if (run.recipe === VIDEO_RECIPE) {
170
+ const metadata = inspectCoreArtifactMetadata(root, task.artifactIds[0]);
171
+ if (!Object.hasOwn(VIDEO_TYPES, metadata.mediaType) || metadata.size > MAX_VIDEO_BYTES) {
172
+ flowFail('IRIS_FLOW_RESULT_UNSUPPORTED', '视频首片只交付不超过 32 MiB 的 MP4/WebM;原 Task 与作品已保留');
173
+ }
174
+ }
120
175
  const output = readCoreArtifactBytes(root, task.artifactIds[0]);
121
176
  if (!run.output) run = updateFlowRun(root, run, { output: reference(output.artifact) });
122
177
  facts(root, run);
123
- if (!run.comparison) {
178
+ if (run.source && !run.comparison && !isAudioRecipe(run.recipe)) {
124
179
  const source = readReference(root, run.source);
125
180
  const diff = await pixelDiff({ inputA: source.bytes, inputB: output.bytes, grid: 8, topRegions: 3 });
126
181
  signal.throwIfAborted();
@@ -152,7 +207,7 @@ export function createImageFlowService(runtime, { resolveImageCandidates, resolv
152
207
  return runtime.run('inspect', ({ dataRoot }) => {
153
208
  const runs = listFlowRuns(dataRoot);
154
209
  return { contractVersion: 0, total: runs.length, runs: runs.map(run => ({ id: run.id, recipe: run.recipe,
155
- createdAt: run.createdAt, source: run.source, modelRef: run.input.model_ref, taskId: run.edit.taskId })) };
210
+ createdAt: run.createdAt, ...(run.source ? { source: run.source } : {}), modelRef: run.input.model_ref, taskId: flowStep(run).taskId })) };
156
211
  });
157
212
  }
158
213
  if (name === 'flow.inspect') {
@@ -164,13 +219,24 @@ export function createImageFlowService(runtime, { resolveImageCandidates, resolv
164
219
  return runtime.run('execute', ({ dataRoot, signal }) => continueRun(dataRoot, signal, input.run_id));
165
220
  }
166
221
  if (name === 'flow.select' || name === 'flow.export') {
167
- exact(input, name === 'flow.select' ? ['run_id', 'artifact_id'] : ['run_id', 'output_path']);
222
+ exact(input, name === 'flow.select' ? ['run_id', 'artifact_id', 'expected_revision'] : ['run_id', 'output_path']);
223
+ if (input.expected_revision !== undefined && (!Number.isSafeInteger(input.expected_revision) || input.expected_revision < 1)) {
224
+ flowFail('IRIS_FLOW_INPUT_INVALID', '预期流程版本必须是正整数');
225
+ }
168
226
  return runtime.run('execute', ({ dataRoot }) => {
169
227
  let run = readFlowRun(dataRoot, input.run_id);
228
+ if (input.expected_revision !== undefined && run.revision !== input.expected_revision) {
229
+ flowFail('IRIS_FLOW_REVISION_CONFLICT', '流程已改变,请读取当前版本后明确选择');
230
+ }
170
231
  facts(dataRoot, run);
171
- if (!run.comparison) flowFail('IRIS_FLOW_NOT_READY', '请先完成改图与比较,再选择结果');
232
+ if (isAudioRecipe(run.recipe) || run.recipe === VIDEO_RECIPE) {
233
+ if (name === 'flow.select') flowFail('IRIS_FLOW_SELECTION_INVALID', '视频、音频和转写为单一结果,无需选择图片版本');
234
+ if (!run.output) flowFail('IRIS_FLOW_NOT_READY', '请先接回原任务的结果');
235
+ return { ...facts(dataRoot, run), export: exportCoreArtifact(dataRoot, run.output.artifactId, input.output_path) };
236
+ }
237
+ if (!run.output || run.source && !run.comparison) flowFail('IRIS_FLOW_NOT_READY', '请先保存生成结果;改图还需完成比较');
172
238
  if (name === 'flow.select') {
173
- const selected = [run.source, run.output].find(item => item.artifactId === input.artifact_id);
239
+ const selected = [run.source, run.output].filter(Boolean).find(item => item.artifactId === input.artifact_id);
174
240
  if (!selected) flowFail('IRIS_FLOW_SELECTION_INVALID', '只能选择本流程的原图或新版');
175
241
  if (run.selection?.artifactId !== selected.artifactId) run = updateFlowRun(dataRoot, run, { selection: selected });
176
242
  return facts(dataRoot, run);
@@ -182,7 +248,7 @@ export function createImageFlowService(runtime, { resolveImageCandidates, resolv
182
248
  throw new TypeError('未知的内部 Flow 命令');
183
249
  }
184
250
  return Object.freeze({ execute, prepareCreation, inspectCreation: (root, intent, options) => {
185
- readReference(root, intent.source);
251
+ if (intent.source) readReference(root, intent.source);
186
252
  return facts(root, commitFlowCreation(root, intent, options));
187
253
  } });
188
254
  }
package/lib/index.js CHANGED
@@ -66,38 +66,47 @@ function outputDir() {
66
66
 
67
67
  /* ---------------- 媒体路由:/iris/media/:taskId/:token/:name ---------------- */
68
68
 
69
+ let latestNativeRuntime;
69
70
  function mountIrisRoutes(routeCtx, core, routes) {
70
71
  // Cordis 服务只在 DSH 宿主边界探测;Command 与通用工具辅助函数不接收原始 ctx
71
72
  const host = dshHost(routeCtx);
72
73
  const reg = hasHostPort(host, 'routes') ? host.ports.routes : undefined;
73
74
  if (!reg || typeof reg.register !== 'function' || routes.mounted) return;
75
+ const disposers = []; let released = false;
76
+ const release = () => {
77
+ if (released) return;
78
+ released = true;
79
+ const errors = [];
80
+ for (const dispose of disposers.reverse()) { try { if (typeof dispose === 'function') dispose(); } catch (error) { errors.push(error); } }
81
+ if (routes.mounted && latestNativeRuntime === core) closeAllSse();
82
+ routes.mounted = false;
83
+ if (errors.length) throw new AggregateError(errors, 'Iris 路由释放失败');
84
+ };
74
85
  try {
75
86
  const disposeMedia = reg.register({
76
87
  kind: 'prefix',
77
88
  path: '/iris/media',
78
89
  handler: guarded(serveMedia)
79
90
  });
91
+ disposers.push(disposeMedia);
80
92
  const disposeApi = reg.register({
81
93
  kind: 'prefix',
82
94
  path: '/iris/api',
83
95
  handler: guarded((req, res) => serveApi(req, res, dshHost(routeCtx), core))
84
96
  });
97
+ disposers.push(disposeApi);
85
98
  const disposeRender = reg.register({
86
99
  kind: 'prefix',
87
100
  path: '/iris/render',
88
101
  handler: guarded(serveRender)
89
102
  });
103
+ disposers.push(disposeRender);
104
+ routeCtx.effect(() => release, 'iris: media+api+actions+render routes');
90
105
  routes.mounted = true;
91
106
  recordHostRoutes(EXPECTED_IRIS_ROUTES);
92
- routeCtx.effect(() => () => {
93
- if (typeof disposeMedia === 'function') disposeMedia();
94
- if (typeof disposeApi === 'function') disposeApi();
95
- if (typeof disposeRender === 'function') disposeRender();
96
- closeAllSse(); // SSE 长连接随插件停用一起关闭,不留悬挂连接
97
- routes.mounted = false;
98
- }, 'iris: media+api+actions+render routes');
99
107
  console.log('[iris] 媒体+工作台+操作路由已挂载:/iris/media · /iris/api · /iris/api/actions · /iris/render');
100
108
  } catch (err) {
109
+ try { release(); } catch (cleanup) { console.error('[iris] 路由回滚失败:', cleanup.message); }
101
110
  console.error('[iris] 路由挂载失败:', err && err.message);
102
111
  }
103
112
  }
@@ -221,13 +230,24 @@ function safeRegister(host, definition) {
221
230
  export async function apply(ctx) {
222
231
  beginHostRuntime({ pluginId: IRIS_PLUGIN_ID, version: PACKAGE_VERSION });
223
232
  const core = createDshRuntime({ dataRoot: dshCoreDataRoot(), getHost: () => dshHost(ctx) });
233
+ latestNativeRuntime = core;
224
234
  ctx.provide('irisRuntime', core);
225
235
  const routes = { mounted: false };
226
236
  const { cropForDsh, diffForDsh, framesForDsh, summaryFramesForDsh, visionImageForDsh, htmlForDsh, inspectProviderTaskForDsh, projectCoreTaskForDsh, readCoreArtifactMediaForDsh, resumeProviderTaskWatchesForDsh } = core;
227
237
  ctx.effect(() => () => {
228
- tasks.stopWatchAll();
238
+ if (latestNativeRuntime === core) tasks.stopWatchAll();
229
239
  return core.dispose();
230
240
  }, 'iris: DSH runtime');
241
+ // Explicit additional connection for shared Flow tools. Legacy native media/skills/UI retain their own roots.
242
+ if (process.env.IRIS_CORE_CONNECTION_FILE) {
243
+ const { createDshServiceBridge } = await import('./dsh-service-bridge.js');
244
+ const bridge = createDshServiceBridge({ connectionFile: process.env.IRIS_CORE_CONNECTION_FILE,
245
+ requestedScopes: (process.env.IRIS_CORE_SCOPES || 'read').split(','), getHost: () => dshHost(ctx), harnessVersion: dshHost(ctx).version });
246
+ ctx.provide('irisCoreService', bridge);
247
+ ctx.effect(() => () => bridge.dispose(), 'iris: Core service connection');
248
+ try { await bridge.start(); }
249
+ catch (error) { console.error('[iris] Core 服务连接失败:', error?.code || 'IRIS_CORE_SERVICE_UNAVAILABLE'); }
250
+ }
231
251
  guard('Iris 私有存储权限收紧', () => hardenPrivateTree(store.irisHome()));
232
252
 
233
253
  guard('上传临时文件清理', () => purgeStaleUploads());
@@ -242,13 +262,18 @@ export async function apply(ctx) {
242
262
  else console.log('[iris] 工作台导入:' + imported.reason);
243
263
  });
244
264
 
245
- // 接管上次进程未跑完的异步任务(恢复循环本身在 tasks.js 里逐任务防御)
246
- guard('恢复后台任务', () => {
247
- const resumed = tasks.resumePending((t) => {
248
- const p = store.providerById(t.providerId);
249
- return p ? pollDeps(p, t.cap) : null;
265
+ // Legacy 接管也要解析当次 DSH 连接;关闭会中止解析,禁止迟到的恢复再启动盯守。
266
+ void core.runHostOperation(signal => {
267
+ const resume = () => tasks.resumePending((task) => {
268
+ const provider = store.providerById(task.providerId);
269
+ return provider ? taskPollDeps(provider, task) : null;
250
270
  });
271
+ if (!tasks.all().some(task => task.status === 'running' && task.remoteTaskId)) return resume();
272
+ return withDshProviderConfig(dshHost(ctx), resume, { signal, fresh: true });
273
+ }).then(resumed => {
251
274
  if (resumed.length) console.log(`[iris] 已恢复接管 ${resumed.length} 个后台任务`);
275
+ }, error => {
276
+ if (core.snapshot().state === 'open') console.error('[iris] 恢复后台任务失败(已隔离,不影响宿主):', error?.code || 'unknown');
252
277
  });
253
278
 
254
279
  // Owned background setup lets unload cancel a slow connection resolver during takeover.
@@ -399,7 +424,7 @@ export async function apply(ctx) {
399
424
  const refs = [];
400
425
  for (const f of files) {
401
426
  const mediaType = MEDIA_TYPES[path.extname(f).toLowerCase()] || 'image/png';
402
- const ref = await attachmentService(host).saveImage({ data: new Uint8Array(fs.readFileSync(f)), mediaType, name: path.basename(f) });
427
+ const ref = await attachmentService(dshHost(ctx)).saveImage({ data: new Uint8Array(fs.readFileSync(f)), mediaType, name: path.basename(f) });
403
428
  refs.push(ref);
404
429
  blocks.push({ type: 'image', attachment: ref });
405
430
  }
@@ -647,7 +672,7 @@ export async function apply(ctx) {
647
672
  const refs = [];
648
673
  for (let i = 0; i < frames.length; i++) {
649
674
  const f = frames[i];
650
- const ref = await attachmentService(host).saveImage({
675
+ const ref = await attachmentService(dshHost(ctx)).saveImage({
651
676
  data: new Uint8Array(f.bytes),
652
677
  mediaType,
653
678
  name: `iris-frame-${Date.now()}-${i + 1}.${ext}`
@@ -744,7 +769,7 @@ export async function apply(ctx) {
744
769
  });
745
770
  // 成功摘要的同一张拼图作为显式工具产物发布。
746
771
  if (exec.signal?.aborted) throw exec.signal.reason;
747
- const ref = await attachmentService(host).saveImage({
772
+ const ref = await attachmentService(dshHost(ctx)).saveImage({
748
773
  data: new Uint8Array(sheet.buffer),
749
774
  mediaType: 'image/png',
750
775
  name: `iris-sheet-${Date.now()}.png`
@@ -801,7 +826,7 @@ export async function apply(ctx) {
801
826
  async prepareImage(signal) {
802
827
  if (source === 'artifact_id') return visionImageForDsh({ artifactId: args.artifact_id, signal });
803
828
  const image = await readVisionImageFile(p, mt, signal);
804
- await waitForModelWork(() => attachmentService(host).saveImage({ data: image.bytes, mediaType: mt, name: path.basename(p) }), signal);
829
+ await waitForModelWork(() => attachmentService(dshHost(ctx)).saveImage({ data: image.bytes, mediaType: mt, name: path.basename(p) }), signal);
805
830
  return image;
806
831
  }
807
832
  });
@@ -833,10 +858,10 @@ export async function apply(ctx) {
833
858
  return runVisionTool(dshHost(ctx), exec, { origin: 'relook', model: args.model, question,
834
859
  async prepareImage(signal) {
835
860
  // 会话扫描、解引用与读图都属于这次操作的总预算。
836
- const hit = (await waitForModelWork(() => sessionAttachmentRef(host, exec, id), signal)) || findOwnAttachment(id);
861
+ const hit = (await waitForModelWork(() => sessionAttachmentRef(dshHost(ctx), exec, id), signal)) || findOwnAttachment(id);
837
862
  if (!hit) throw new Error('iris: 图片引用不可用');
838
863
  if (hit.absPath) return readVisionImageFile(hit.absPath, hit.ref.mediaType || MEDIA_TYPES[path.extname(hit.absPath).toLowerCase()] || 'image/png', signal);
839
- const stored = await waitForModelWork(() => attachmentService(host).readImage(hit.ref, signal), signal);
864
+ const stored = await waitForModelWork(() => attachmentService(dshHost(ctx)).readImage(hit.ref, signal), signal);
840
865
  return { bytes: new Uint8Array(stored.data), mediaType: stored.mediaType || hit.ref.mediaType };
841
866
  }
842
867
  });
@@ -900,7 +925,7 @@ export async function apply(ctx) {
900
925
  signal: exec.signal
901
926
  });
902
927
  if (exec.signal && exec.signal.aborted) throw new Error('已取消');
903
- const ref = await attachmentService(host).saveImage({
928
+ const ref = await attachmentService(dshHost(ctx)).saveImage({
904
929
  data: new Uint8Array(result.bytes),
905
930
  mediaType: result.mediaType,
906
931
  name: `iris-crop-${Date.now()}.png`
@@ -946,7 +971,7 @@ export async function apply(ctx) {
946
971
  grid: args.grid, topRegions: args.top_regions, signal: exec.signal
947
972
  });
948
973
  const { ratio, diffPixels, totalPixels, width, height, worstRegions } = result.metrics;
949
- const ref = await attachmentService(host).saveImage({
974
+ const ref = await attachmentService(dshHost(ctx)).saveImage({
950
975
  data: new Uint8Array(result.bytes),
951
976
  mediaType: result.mediaType,
952
977
  name: `iris-diff-${Date.now()}.png`
@@ -1037,7 +1062,7 @@ export async function apply(ctx) {
1037
1062
  });
1038
1063
  const png = Buffer.from(rendered.bytes);
1039
1064
  const { width: outW, height: outH } = await imageDimensions(png);
1040
- const ref = await attachmentService(host).saveImage({
1065
+ const ref = await attachmentService(dshHost(ctx)).saveImage({
1041
1066
  data: new Uint8Array(png),
1042
1067
  mediaType: 'image/png',
1043
1068
  name: `iris-html-${Date.now()}.png`
@@ -1,9 +1,11 @@
1
1
  // HTTP DTOs whitelist existing Core facts; never expose records or configuration wholesale.
2
+ import { isAudioRecipe } from './audio-flow-input.js';
3
+ import { VIDEO_RECIPE } from './video-flow-input.js';
2
4
  const STATUSES = new Set(['not_started', 'running', 'needs_attention', 'delivery_failed',
3
- 'needs_comparison', 'awaiting_selection', 'selected']);
5
+ 'needs_comparison', 'needs_result', 'awaiting_selection', 'selected', 'ready']);
4
6
 
5
7
  export function projectLocalApiFlow(facts) {
6
- if (facts.contractVersion !== 0 || facts.run.recipe !== 'image-edit/v0' || !STATUSES.has(facts.status)) {
8
+ if (facts.contractVersion !== 0 || !['image-edit/v0', 'image-generate/v0', VIDEO_RECIPE].includes(facts.run.recipe) && !isAudioRecipe(facts.run.recipe) || !STATUSES.has(facts.status)) {
7
9
  throw new TypeError('不支持的流程契约或状态');
8
10
  }
9
11
  const { run, task } = facts;
@@ -11,11 +13,16 @@ export function projectLocalApiFlow(facts) {
11
13
  return {
12
14
  contractVersion: 0, id: run.id, recipe: run.recipe, revision: run.revision,
13
15
  createdAt: run.createdAt, updatedAt: run.updatedAt, status: facts.status,
14
- input: { prompt: run.input.prompt, modelRef: run.input.model_ref,
15
- ...(run.input.size !== undefined ? { size: run.input.size } : {}) },
16
+ input: { ...(isAudioRecipe(run.recipe) ? run.recipe === 'speech/v0' ? { text: run.input.text,
17
+ ...(run.input.voice !== undefined ? { voice: run.input.voice } : {}) } : {} : { prompt: run.input.prompt }), modelRef: run.input.model_ref,
18
+ ...(run.input.size !== undefined ? { size: run.input.size } : {}),
19
+ ...(run.input.duration !== undefined ? { duration: run.input.duration } : {}),
20
+ ...(run.input.workflow_parameters !== undefined ? { workflowParameters: run.input.workflow_parameters } : {}) },
16
21
  source: ref(run.source), output: ref(run.output), comparison: ref(run.comparison), selection: ref(run.selection),
17
22
  task: task ? { id: task.id, acceptance: task.acceptance, outcome: task.outcome,
18
- deliveryState: task.deliveryState, attemptCount: task.attempts.length } : null,
23
+ deliveryState: task.deliveryState, attemptCount: task.attempts.length,
24
+ ...(task.networkRoutes ? { networkRoutes: task.networkRoutes.map(route => ({ route: route.route, operation: route.operation, lastAt: route.lastAt,
25
+ ...(route.poolId ? { poolId: route.poolId } : {}), ...(route.nodeId ? { nodeId: route.nodeId } : {}) })) } : {}) } : null,
19
26
  metrics: facts.metrics ? Object.fromEntries(['ratio', 'diffPixels', 'totalPixels', 'width', 'height']
20
27
  .map(key => [key, facts.metrics[key]])) : null
21
28
  };
@@ -23,5 +30,7 @@ export function projectLocalApiFlow(facts) {
23
30
 
24
31
  export function projectLocalApiRunSummary(run) {
25
32
  return { id: run.id, recipe: run.recipe, createdAt: run.createdAt,
26
- modelRef: run.modelRef, source: { artifactId: run.source.artifactId } };
33
+ updatedAt: run.updatedAt, revision: run.revision, modelRef: run.input.model_ref,
34
+ source: run.source ? { artifactId: run.source.artifactId } : null, output: run.output ? { artifactId: run.output.artifactId } : null,
35
+ selection: run.selection ? { artifactId: run.selection.artifactId } : null };
27
36
  }
@@ -0,0 +1,31 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import { writeLocalApiTokenFile } from './local-api.js';
5
+
6
+ const invalid = message => { throw Object.assign(new Error(message), { code: 'IRIS_API_TOKEN_DIRECTORY_INVALID' }); };
7
+
8
+ /** The explicitly chosen directory may be new; its parent must exist. No chmod or cleanup. */
9
+ export function checkLocalApiTokenDirectory(directory, dataRoot) {
10
+ if (typeof directory !== 'string' || !path.isAbsolute(directory)) invalid('token-dir 必须是根外私有目录的绝对路径');
11
+ const supplied = path.resolve(directory);
12
+ let info;
13
+ try { info = fs.lstatSync(supplied); }
14
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
15
+ if (info && (!info.isDirectory() || info.isSymbolicLink() || process.platform !== 'win32' && info.mode & 0o077)) {
16
+ invalid('token-dir 必须是私有真实目录(POSIX 0700);不会修改已有目录权限');
17
+ }
18
+ const resolved = info ? fs.realpathSync(supplied) : path.join(fs.realpathSync(path.dirname(supplied)), path.basename(supplied));
19
+ const physical = info ? fs.realpathSync.native(supplied) : path.join(fs.realpathSync.native(path.dirname(supplied)), path.basename(supplied));
20
+ const relative = path.relative(fs.realpathSync.native(dataRoot), physical);
21
+ if (!relative || relative !== '..' && !relative.startsWith('..' + path.sep) && !path.isAbsolute(relative)) invalid('token-dir 必须位于 Core 数据根之外');
22
+ return resolved;
23
+ }
24
+
25
+ export function writeLocalApiTokenDirectory(directory, token, dataRoot) {
26
+ const target = checkLocalApiTokenDirectory(directory, dataRoot);
27
+ if (!fs.existsSync(target)) fs.mkdirSync(target, { mode: 0o700 });
28
+ const file = path.join(target, 'session-' + crypto.randomUUID() + '.token');
29
+ writeLocalApiTokenFile(file, token, dataRoot);
30
+ return file;
31
+ }