@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/core-tasks.js CHANGED
@@ -8,7 +8,10 @@ import { parseModelRef } from './models.js';
8
8
  import { providerErrorRecord } from './provider-contract.js';
9
9
  import { deriveLegacyStatus, semanticViolations } from './task-semantics.js';
10
10
  import { readDeliveryCache } from './core-delivery-cache.js';
11
- import { flowTaskBinding, readFlowRun, validFlowBinding } from './flow-store.js';
11
+ import { flowStep, flowStepId, flowTaskBinding, flowCapability, readFlowRun, validFlowBinding } from './flow-store.js';
12
+ import { checkedPluginDependency, assertPluginDependencyMatches, PluginContractError } from './plugin-contract.js';
13
+ import { checkedNetworkRequirement, sameNetworkRequirement } from './network-policy.js';
14
+ import { checkedNetworkRouteFact, checkedNetworkRouteRecord, networkRouteFactKey, MAX_NETWORK_ROUTE_FACTS } from './network-route-evidence.js';
12
15
 
13
16
  const STORE_DIR = 'task-store';
14
17
  const STORE_VERSION = 'v0';
@@ -103,7 +106,7 @@ function appendAttemptStage(attempt, key, reference) {
103
106
 
104
107
  function validateRecord(record, expectedId) {
105
108
  if (!record || typeof record !== 'object' || Array.isArray(record)
106
- || record.schemaVersion !== 0 || record.semanticsVersion !== 2
109
+ || record.schemaVersion !== 0 || ![2, 3].includes(record.semanticsVersion)
107
110
  || record.id !== expectedId || !CAPABILITIES.has(record.capability)
108
111
  || !Array.isArray(record.attempts) || !Array.isArray(record.artifactIds)
109
112
  || !Number.isSafeInteger(record.revision) || record.revision < 1
@@ -111,8 +114,9 @@ function validateRecord(record, expectedId) {
111
114
  || (record.deliveryCache !== undefined && record.deliveryCache !== true)
112
115
  || (record.providerBinding !== undefined && !PROVIDER_BINDING.test(record.providerBinding))
113
116
  || (record.retriedFrom !== undefined && !TASK_ID.test(record.retriedFrom))
114
- || (record.flowBinding !== undefined && (record.capability !== 'image' || !validFlowBinding(record.flowBinding)))
115
- || (record.sourceArtifactId !== undefined && (record.capability !== 'image' || !ARTIFACT_ID.test(record.sourceArtifactId)))) {
117
+ || (record.flowBinding !== undefined && (!validFlowBinding(record.flowBinding)
118
+ || ({ edit: 'image', generate: 'image', speak: 'tts', transcribe: 'transcribe', video: 'video' })[record.flowBinding.stepId] !== record.capability))
119
+ || (record.sourceArtifactId !== undefined && (!['image', 'transcribe'].includes(record.capability) || !ARTIFACT_ID.test(record.sourceArtifactId)))) {
116
120
  fail('IRIS_TASK_INVALID', 'Task 记录结构无效');
117
121
  }
118
122
  if (record.attempts.some((attempt, index) => !attempt || !ATTEMPT_ID.test(attempt.id)
@@ -123,6 +127,36 @@ function validateRecord(record, expectedId) {
123
127
  || !validAttemptStageTimestamps(attempt.stageTimestamps))) {
124
128
  fail('IRIS_TASK_INVALID', 'Task Attempt 记录结构无效');
125
129
  }
130
+ try {
131
+ if (record.semanticsVersion === 3) {
132
+ checkedNetworkRequirement(record.networkRequirement);
133
+ if (!Array.isArray(record.networkRoutes) || record.networkRoutes.length > MAX_NETWORK_ROUTE_FACTS) throw new Error('routes');
134
+ for (const attempt of record.attempts) {
135
+ checkedNetworkRequirement(attempt.networkRequirement);
136
+ if (!sameNetworkRequirement(record.networkRequirement, attempt.networkRequirement)) throw new Error('attempt requirement');
137
+ }
138
+ const keys = new Set();
139
+ for (const route of record.networkRoutes) {
140
+ const { phase, firstAt, lastAt, requestCount, ...fact } = checkedNetworkRouteRecord(route);
141
+ if (!sameNetworkRequirement(record.networkRequirement, { schemaVersion: 1, configId: fact.configId, catalogId: fact.catalogId })) throw new Error('route requirement');
142
+ const attempt = fact.attemptId === undefined ? undefined : record.attempts.find(value => value.id === fact.attemptId);
143
+ if (fact.attemptId !== undefined && (!attempt || attempt.providerId !== fact.providerId)) throw new Error('route attempt');
144
+ const key = networkRouteFactKey(fact);
145
+ if (keys.has(key)) throw new Error('duplicate route');
146
+ keys.add(key);
147
+ }
148
+ } else if (record.networkRequirement !== undefined || record.networkRoutes !== undefined
149
+ || record.attempts.some(attempt => attempt.networkRequirement !== undefined)) throw new Error('legacy requirement');
150
+ } catch (_) { fail('IRIS_TASK_INVALID', 'Task 或 Attempt 的网络要求、版本或路线事实无效'); }
151
+ try {
152
+ for (const item of [record, ...record.attempts]) if (item.pluginDependency !== undefined) {
153
+ if (checkedPluginDependency(item.pluginDependency).kind !== 'providers' || !item.providerBinding) fail('IRIS_TASK_INVALID', 'Task 插件依赖需要 Provider 与端点绑定');
154
+ }
155
+ assertPluginDependencyMatches(record.pluginDependency, record.attempts.at(-1)?.pluginDependency);
156
+ } catch (error) {
157
+ if (!(error instanceof PluginContractError)) throw error;
158
+ fail('IRIS_TASK_INVALID', 'Task 或 Attempt 的插件依赖无效或不一致');
159
+ }
126
160
  const violations = semanticViolations(record);
127
161
  if (violations.length) fail('IRIS_TASK_INVALID', 'Task 事实语义冲突:' + violations.join(', '));
128
162
  return record;
@@ -178,11 +212,13 @@ function newTaskRecord(input, id) {
178
212
  const retriedFrom = input.retriedFrom === undefined ? undefined : String(input.retriedFrom);
179
213
  if (retriedFrom !== undefined && !TASK_ID.test(retriedFrom)) fail('IRIS_TASK_INPUT_INVALID', 'retriedFrom 必须是合法 Task ID');
180
214
  const sourceArtifactId = input.sourceArtifactId;
181
- if (sourceArtifactId !== undefined && (capability !== 'image' || !ARTIFACT_ID.test(sourceArtifactId))) fail('IRIS_TASK_INPUT_INVALID', '改图 sourceArtifactId 必须是合法 Artifact ID');
215
+ if (sourceArtifactId !== undefined && (!['image', 'transcribe'].includes(capability) || !ARTIFACT_ID.test(sourceArtifactId))) fail('IRIS_TASK_INPUT_INVALID', '来源须为改图或转写的合法 Artifact ID');
182
216
  const now = new Date().toISOString();
217
+ const networkRequirement = input.networkRequirement === undefined ? undefined : checkedNetworkRequirement(input.networkRequirement);
183
218
  return {
184
219
  schemaVersion: 0,
185
- semanticsVersion: 2,
220
+ semanticsVersion: networkRequirement ? 3 : 2,
221
+ ...(networkRequirement ? { networkRequirement, networkRoutes: [] } : {}),
186
222
  id,
187
223
  capability,
188
224
  status: 'running',
@@ -210,9 +246,11 @@ export function createCoreTask(dataRoot, input = {}) {
210
246
  /** Flow 只能接回自己持久预留的 Task;原有 Task 没有该字段,读取语义不变。 */
211
247
  export function assertFlowTaskMatches(task, run) {
212
248
  const providerId = parseModelRef(run.input.model_ref).providerId;
213
- if (task.id !== run.edit.taskId || task.capability !== 'image' || task.sourceArtifactId !== run.source.artifactId
249
+ if (task.id !== flowStep(run).taskId || task.capability !== flowCapability(run) || task.sourceArtifactId !== run.source?.artifactId
214
250
  || !validFlowBinding(task.flowBinding) || task.flowBinding.runId !== run.id
251
+ || task.flowBinding.stepId !== flowStepId(run)
215
252
  || task.flowBinding.inputDigest !== run.inputDigest
253
+ || !sameNetworkRequirement(task.networkRequirement, run.networkRequirement)
216
254
  || task.attempts.some(attempt => attempt.providerId !== providerId
217
255
  || attempt.model !== run.input.model_ref || attempt.providerBinding !== run.providerBinding)
218
256
  || (task.attempts.length && (task.providerId !== providerId || task.modelRef !== run.input.model_ref || task.providerBinding !== run.providerBinding))) {
@@ -228,13 +266,13 @@ export function findFlowTask(dataRoot, run) {
228
266
  if (!stat.isDirectory() || stat.isSymbolicLink()) fail('IRIS_FLOW_TASK_CONFLICT', '流程 Task 目录不安全');
229
267
  } catch (error) { if (error.code !== 'ENOENT') throw error; }
230
268
  }
231
- try { fs.lstatSync(taskFile(dataRoot, run.edit.taskId)); }
269
+ try { fs.lstatSync(taskFile(dataRoot, flowStep(run).taskId)); }
232
270
  catch (error) {
233
271
  if (error.code !== 'ENOENT') throw error;
234
- if (run.edit.claimed) fail('IRIS_FLOW_TASK_CONFLICT', '流程已建立的 Task 丢失,禁止重建或重新生成');
272
+ if (flowStep(run).claimed) fail('IRIS_FLOW_TASK_CONFLICT', '流程已建立的 Task 丢失,禁止重建或重新生成');
235
273
  return null;
236
274
  }
237
- return assertFlowTaskMatches(readRecord(dataRoot, run.edit.taskId), run);
275
+ return assertFlowTaskMatches(readRecord(dataRoot, flowStep(run).taskId), run);
238
276
  }
239
277
 
240
278
  /** 仅从已提交的 Run 读取 ID;排他发布,新旧身份均不能覆盖。 */
@@ -242,7 +280,8 @@ export function claimFlowTask(dataRoot, runId) {
242
280
  const run = readFlowRun(dataRoot, runId);
243
281
  const previous = findFlowTask(dataRoot, run);
244
282
  if (previous) return previous;
245
- return writeRecord(dataRoot, { ...newTaskRecord({ capability: 'image', sourceArtifactId: run.source.artifactId }, run.edit.taskId),
283
+ return writeRecord(dataRoot, { ...newTaskRecord({ capability: flowCapability(run), sourceArtifactId: run.source?.artifactId,
284
+ ...(run.networkRequirement ? { networkRequirement: run.networkRequirement } : {}) }, flowStep(run).taskId),
246
285
  flowBinding: flowTaskBinding(run) }, true);
247
286
  }
248
287
 
@@ -255,9 +294,7 @@ export function inspectCoreTask(dataRoot, id) {
255
294
  * skipInvalid 只服务用户侧只读投影:单条损坏记录跳过并计入 dropped,
256
295
  * 其余记录照常返回;默认严格模式不变,损坏记录仍使整体读取失败。
257
296
  */
258
- export function listCoreTasks(dataRoot, { offset = 0, limit = 50, skipInvalid = false, status, capability, provider_id, model_ref, outcome, delivery_state } = {}) {
259
- const start = Math.max(0, Math.trunc(Number(offset) || 0));
260
- const size = Math.max(1, Math.min(200, Math.trunc(Number(limit) || 50)));
297
+ function taskRecords(dataRoot, skipInvalid = false) {
261
298
  const directory = taskDirectory(dataRoot);
262
299
  let names;
263
300
  for (const managed of [
@@ -267,10 +304,7 @@ export function listCoreTasks(dataRoot, { offset = 0, limit = 50, skipInvalid =
267
304
  try { stat = fs.lstatSync(managed); }
268
305
  catch (error) {
269
306
  if (error?.code === 'ENOENT') {
270
- return Object.freeze({
271
- schemaVersion: 0, total: 0, offset: start, limit: size, dropped: 0,
272
- tasks: Object.freeze([])
273
- });
307
+ return { records: [], dropped: 0 };
274
308
  }
275
309
  fail('IRIS_TASK_STORE_INVALID', 'Task Store 无法读取');
276
310
  }
@@ -288,6 +322,20 @@ export function listCoreTasks(dataRoot, { offset = 0, limit = 50, skipInvalid =
288
322
  dropped += 1;
289
323
  }
290
324
  }
325
+ return { records, dropped };
326
+ }
327
+
328
+ /** Strict single-pass admission view, including pending intents after a crash. No mutation or pagination rescan. */
329
+ export function coreRemoteTaskIdentities(dataRoot) {
330
+ return taskRecords(dataRoot).records.filter(task => !['succeeded', 'failed', 'canceled'].includes(task.outcome)
331
+ && (['accepted', 'unknown'].includes(task.acceptance) || task.phase === 'submitting' && task.attempts.length))
332
+ .map(task => ({ providerId: task.providerId, modelRef: task.modelRef }));
333
+ }
334
+
335
+ export function listCoreTasks(dataRoot, { offset = 0, limit = 50, skipInvalid = false, status, capability, provider_id, model_ref, outcome, delivery_state } = {}) {
336
+ const start = Math.max(0, Math.trunc(Number(offset) || 0));
337
+ const size = Math.max(1, Math.min(200, Math.trunc(Number(limit) || 50)));
338
+ const { records, dropped } = taskRecords(dataRoot, skipInvalid);
291
339
  records.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)));
292
340
  const matches = records.filter(record => Object.entries({ status, capability, providerId: provider_id, modelRef: model_ref, outcome, deliveryState: delivery_state })
293
341
  .every(([field, value]) => value === undefined || record[field] === value));
@@ -302,13 +350,17 @@ export function beginCoreAttempt(dataRoot, taskId, fields = {}) {
302
350
  const providerId = String(fields.providerId || '').trim();
303
351
  const model = String(fields.model || '').trim();
304
352
  const parsed = parseModelRef(model);
353
+ const pluginDependency = fields.pluginDependency === undefined ? undefined : checkedPluginDependency(fields.pluginDependency);
354
+ const networkRequirement = fields.networkRequirement === undefined ? undefined : checkedNetworkRequirement(fields.networkRequirement);
305
355
  if (!providerId || !parsed || parsed.providerId !== providerId
306
356
  || (fields.providerBinding !== undefined && !PROVIDER_BINDING.test(fields.providerBinding))
307
- || (fields.selectionReason !== undefined && !ATTEMPT_SELECTION_REASONS.has(fields.selectionReason))) {
357
+ || (fields.selectionReason !== undefined && !ATTEMPT_SELECTION_REASONS.has(fields.selectionReason))
358
+ || (pluginDependency && (pluginDependency.kind !== 'providers' || !fields.providerBinding))) {
308
359
  fail('IRIS_ATTEMPT_INPUT_INVALID', 'Attempt 的 providerId 与复合 model 必须一致');
309
360
  }
310
361
  let created;
311
362
  mutate(dataRoot, taskId, (task) => {
363
+ if (!sameNetworkRequirement(task.networkRequirement, networkRequirement)) fail('IRIS_NETWORK_REQUIREMENT_MISMATCH', '新 Attempt 必须保持原 Task 的网络要求,不会隐式升级或解除');
312
364
  if (task.acceptance === 'accepted' || task.acceptance === 'unknown') {
313
365
  fail('IRIS_TASK_RESUBMIT_FORBIDDEN', 'Task 已受理或受理未知,禁止创建新 Attempt');
314
366
  }
@@ -325,7 +377,9 @@ export function beginCoreAttempt(dataRoot, taskId, fields = {}) {
325
377
  startedAt: submitStartedAt,
326
378
  stageTimestamps: { queuedAt, submitStartedAt },
327
379
  ...(fields.selectionReason !== undefined ? { selectionReason: fields.selectionReason } : {}),
328
- ...(fields.providerBinding !== undefined ? { providerBinding: fields.providerBinding } : {})
380
+ ...(fields.providerBinding !== undefined ? { providerBinding: fields.providerBinding } : {}),
381
+ ...(networkRequirement ? { networkRequirement } : {}),
382
+ ...(pluginDependency ? { pluginDependency } : {})
329
383
  };
330
384
  task.attempts.push(created);
331
385
  task.activeAttemptId = created.id;
@@ -333,6 +387,8 @@ export function beginCoreAttempt(dataRoot, taskId, fields = {}) {
333
387
  task.modelRef = model;
334
388
  if (created.providerBinding) task.providerBinding = created.providerBinding;
335
389
  else delete task.providerBinding;
390
+ if (created.pluginDependency) task.pluginDependency = created.pluginDependency;
391
+ else delete task.pluginDependency;
336
392
  task.phase = 'submitting';
337
393
  task.acceptance = 'none';
338
394
  task.watchState = 'idle';
@@ -346,6 +402,37 @@ export function beginCoreAttempt(dataRoot, taskId, fields = {}) {
346
402
  return Object.freeze(clone(created));
347
403
  }
348
404
 
405
+ /** Persist before dispatch. A record proves an attempted route, never acceptance or public IP. */
406
+ export function recordCoreNetworkRoute(dataRoot, taskId, input) {
407
+ const supplied = checkedNetworkRouteFact(input);
408
+ return mutate(dataRoot, taskId, task => {
409
+ const requirement = { schemaVersion: 1, configId: supplied.configId, catalogId: supplied.catalogId };
410
+ if (task.semanticsVersion !== 3 || !sameNetworkRequirement(task.networkRequirement, requirement)) {
411
+ fail('IRIS_NETWORK_REQUIREMENT_MISMATCH', '路线事实必须属于原 Task 的已保存网络要求;不会隐式转换旧任务');
412
+ }
413
+ const attemptId = supplied.attemptId ?? task.activeAttemptId;
414
+ const fact = checkedNetworkRouteFact({ ...supplied, ...(attemptId ? { attemptId } : {}) });
415
+ const attempt = attemptId ? task.attempts.find(value => value.id === attemptId) : undefined;
416
+ if (attemptId && (!attempt || attempt.providerId !== fact.providerId)
417
+ || !attemptId && task.providerId !== undefined && task.providerId !== fact.providerId) {
418
+ fail('IRIS_NETWORK_ROUTE_INVALID', '路线事实与 Task 原 Provider 或 Attempt 不一致');
419
+ }
420
+ const key = networkRouteFactKey(fact);
421
+ const previous = task.networkRoutes.find(value => {
422
+ const { phase, firstAt, lastAt, requestCount, ...identity } = value;
423
+ return networkRouteFactKey(identity) === key;
424
+ });
425
+ const now = timestampAfter(task.updatedAt, previous?.lastAt);
426
+ if (previous) {
427
+ if (previous.requestCount === Number.MAX_SAFE_INTEGER) fail('IRIS_NETWORK_ROUTE_LIMIT', '路线事实计数已超出支持范围,停止派发');
428
+ previous.requestCount++; previous.lastAt = now;
429
+ } else {
430
+ if (task.networkRoutes.length === MAX_NETWORK_ROUTE_FACTS) fail('IRIS_NETWORK_ROUTE_LIMIT', 'Task 不同路线事实已达128项,停止派发并保留历史');
431
+ task.networkRoutes.push({ ...fact, phase: 'dispatch_started', firstAt: now, lastAt: now, requestCount: 1 });
432
+ }
433
+ });
434
+ }
435
+
349
436
  export function recordCoreAttemptResult(dataRoot, taskId, snapshot) {
350
437
  return mutate(dataRoot, taskId, (task) => {
351
438
  const attempt = task.attempts.find((item) => item.id === snapshot?.id);
@@ -519,7 +606,7 @@ export function failCoreDelivery(dataRoot, taskId, error) {
519
606
  task.phase = 'terminal';
520
607
  task.watchState = 'idle';
521
608
  task.deliveryState = 'failed';
522
- task.lastError = safeError(error, { stage: 'download', category: 'local_io', acceptance: 'accepted' });
609
+ task.lastError = safeError(error, { stage: 'download', category: error?.category || 'local_io', acceptance: 'accepted' });
523
610
  task.finishedAt = new Date().toISOString();
524
611
  });
525
612
  }
@@ -599,6 +686,11 @@ export function recoverCoreTask(dataRoot, taskId) {
599
686
  });
600
687
  });
601
688
  }
689
+ // 拒绝受理已落盘、最终失败尚未落盘时,只收口原 Attempt;恢复绝不接着试下一候选。
690
+ if (current.phase === 'terminal' && current.acceptance === 'not_accepted' && current.outcome === 'none') {
691
+ return finalizeCoreNoAcceptance(dataRoot, taskId,
692
+ current.lastError || '供应商明确未受理;提交在最终状态落盘前中断');
693
+ }
602
694
  if (current.outcome === 'succeeded' && ['pending', 'downloading'].includes(current.deliveryState)) {
603
695
  return mutate(dataRoot, taskId, (task) => {
604
696
  task.phase = 'terminal';
@@ -44,6 +44,22 @@ function optionalRecord(file) {
44
44
  catch (error) { if (error?.code === 'ENOENT') return null; throw error; }
45
45
  }
46
46
 
47
+ /** Recognize only an unpublished preparation from a proven-dead owner. Keep it
48
+ * as evidence: initialization may ignore this metadata, but never removes it. */
49
+ export function isAbandonedWriterPreparation(root, name) {
50
+ const match = /^\.iris-runtime-writer-v0\.owner\.json\.prepare-([1-9][0-9]*)-[a-f0-9]{12}$/.exec(name);
51
+ if (!match) return false;
52
+ try {
53
+ const file = path.join(root, name), stat = fs.lstatSync(file);
54
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) return false;
55
+ const owner = checkedOwner(readRecord(file).value);
56
+ return String(owner.pid) === match[1] && writerOwnerStatus(owner.pid) === 'missing';
57
+ } catch (error) {
58
+ if (error.code && !['ENOENT', 'EACCES', 'EPERM'].includes(error.code)) throw error;
59
+ return false;
60
+ }
61
+ }
62
+
47
63
  /** 完整私有记录先在根中准备,再以 hard link 原子发布;已有目标绝不覆盖。 */
48
64
  export function publishLeaseRecord(root, target, value) {
49
65
  const prepared = privateSibling(path.join(root, WRITER_INTENT_FILE), 'prepare');
@@ -3,8 +3,11 @@ import crypto from 'node:crypto';
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { atomicCreatePrivate, atomicWritePrivate } from './private-storage.js';
6
- import { inspectFlowRoot, validateFlowCreation } from './flow-store.js';
7
- import { WRITER_LEASE_DIR, WRITER_INTENT_FILE } from './core-writer-lease.js';
6
+ import { validateFlowCreation } from './flow-store.js';
7
+ import { inspectLocalRequestRoot, initializeLocalRequestRoot, requestDirectory } from './local-request-root.js';
8
+ import { normalizeComfyParameters } from './comfyui-workflow.js';
9
+ import { AUDIO_TYPES, MAX_AUDIO_INPUT_BYTES, normalizeAudioFlowInput } from './audio-flow-input.js';
10
+ import { VIDEO_RECIPE, normalizeVideoFlowInput } from './video-flow-input.js';
8
11
 
9
12
  const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/;
10
13
  const ID = /^req_[a-f0-9]{64}$/;
@@ -13,6 +16,8 @@ const SHA = /^[a-f0-9]{64}$/;
13
16
  const DIGEST = /^sha256:[a-f0-9]{64}$/;
14
17
  const TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
15
18
  const MAX_IMAGE = 20 * 1024 * 1024;
19
+ export const CREATION_KINDS = Object.freeze(['image-import', 'flow-create', 'generation-create', 'audio-import', 'speech-create', 'transcription-create', 'video-create']);
20
+ export const isImportCreation = kind => ['image-import', 'audio-import'].includes(kind);
16
21
  const iso = value => typeof value === 'string' && Number.isFinite(Date.parse(value)) && new Date(value).toISOString() === value;
17
22
  const hash = value => crypto.createHash('sha256').update(value).digest('hex');
18
23
  const copy = value => structuredClone(value);
@@ -22,16 +27,6 @@ function keys(value, allowed, required = allowed) {
22
27
  }
23
28
  export function creationFail(code, message) { throw Object.assign(new Error(message), { code }); }
24
29
  const invalid = () => creationFail('IRIS_CREATION_STORE_INVALID', '创建请求区未知、损坏或尚未完整初始化');
25
- function directory(root, segments, create = false) {
26
- let current = root;
27
- for (const segment of segments) {
28
- current = path.join(current, segment);
29
- if (create && !fs.existsSync(current)) fs.mkdirSync(current, { mode: 0o700 });
30
- const stat = fs.lstatSync(current);
31
- if (!stat.isDirectory() || stat.isSymbolicLink()) invalid();
32
- }
33
- return current;
34
- }
35
30
  function jsonFile(file) {
36
31
  const stat = fs.lstatSync(file);
37
32
  if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 128 * 1024) invalid();
@@ -39,54 +34,29 @@ function jsonFile(file) {
39
34
  }
40
35
 
41
36
  export function inspectCreationRoot(root, { allowAbsent = false } = {}) {
42
- let base;
43
- try { base = directory(root, ['creation-requests']); }
44
- catch (error) {
45
- if (error.code === 'ENOENT' && !fs.existsSync(path.join(root, 'creation-requests'))) {
46
- if (allowAbsent) return null;
47
- creationFail('IRIS_CREATION_ROOT_REQUIRED', '请先在空的专用 Flow 根显式执行 api init');
48
- }
49
- if (error.code === 'ENOENT') invalid();
50
- throw error;
51
- }
52
- try {
53
- if (fs.readdirSync(base).some(name => name !== 'v0')) invalid();
54
- const version = directory(root, ['creation-requests', 'v0']);
55
- if (fs.readdirSync(version).some(name => !['root.json', 'requests'].includes(name)
56
- && !/^root\.json\.tmp-[1-9][0-9]*-[a-f0-9]{12}$/.test(name))) invalid();
57
- const marker = jsonFile(path.join(version, 'root.json'));
58
- if (!keys(marker, ['schemaVersion', 'kind', 'rootId', 'createdAt']) || marker.schemaVersion !== 0
59
- || marker.kind !== 'iris-creation-requests' || !UUID.test(marker.rootId) || !iso(marker.createdAt)) invalid();
60
- directory(root, ['creation-requests', 'v0', 'requests']);
61
- return copy(marker);
62
- } catch (error) { if (error.code === 'ENOENT') invalid(); throw error; }
37
+ return inspectLocalRequestRoot(root, { allowAbsent });
63
38
  }
64
39
 
65
40
  export function initializeCreationRoot(root) {
66
- const existing = inspectCreationRoot(root, { allowAbsent: true });
67
- if (existing) { listCreationRequests(root); return existing; }
68
- inspectFlowRoot(root);
69
- if (fs.readdirSync(root).some(name => !['flow-store', WRITER_LEASE_DIR, WRITER_INTENT_FILE].includes(name))
70
- || fs.readdirSync(path.join(root, 'flow-store')).some(name => name !== 'v0')
71
- || fs.readdirSync(path.join(root, 'flow-store/v0')).some(name => !['root.json', 'runs'].includes(name))
72
- || fs.readdirSync(path.join(root, 'flow-store/v0/runs')).length) {
73
- creationFail('IRIS_CREATION_ROOT_NOT_EMPTY', '请求区仅在新的空 Flow 根初始化;不转换已有创作数据');
74
- }
75
- const marker = { schemaVersion: 0, kind: 'iris-creation-requests', rootId: crypto.randomUUID(), createdAt: new Date().toISOString() };
76
- directory(root, ['creation-requests', 'v0', 'requests'], true);
77
- atomicCreatePrivate(path.join(root, 'creation-requests/v0/root.json'), JSON.stringify(marker, null, 2) + '\n');
78
- return copy(marker);
41
+ const marker = initializeLocalRequestRoot(root, 0);
42
+ listCreationRequests(root);
43
+ return marker;
79
44
  }
80
45
 
81
46
  export function creationRequestId(marker, kind, key) {
82
47
  if (!UUID.test(key)) creationFail('IRIS_CREATION_KEY_INVALID', 'Idempotency-Key 须为单个小写 UUIDv4');
83
- if (!['image-import', 'flow-create'].includes(kind)) throw new TypeError('未知本地创建类型');
48
+ if (!CREATION_KINDS.includes(kind)) throw new TypeError('未知本地创建类型');
84
49
  return 'req_' + hash(JSON.stringify(['creation-requests/v0', marker.rootId, kind, key]));
85
50
  }
86
51
  export function creationInputDigest(kind, input) {
87
- return 'sha256:' + hash(JSON.stringify(kind === 'image-import'
88
- ? ['image-import/v0', input.mediaType, input.size, input.sha256]
89
- : ['flow-create/v0', 'image-edit/v0', input.source_artifact_id, input.model_ref, input.prompt, input.size ?? null, 1]));
52
+ if (kind === 'video-create') return 'sha256:' + hash(JSON.stringify(['video-create/v0', normalizeVideoFlowInput(input)]));
53
+ if (['speech-create', 'transcription-create'].includes(kind)) return 'sha256:' + hash(JSON.stringify([kind + '/v0',
54
+ normalizeAudioFlowInput(kind === 'speech-create' ? 'speech/v0' : 'transcribe/v0', input)]));
55
+ return 'sha256:' + hash(JSON.stringify(isImportCreation(kind)
56
+ ? [kind + '/v0', input.mediaType, input.size, input.sha256]
57
+ : kind === 'generation-create' ? ['generation-create/v0', 'image-generate/v0', input.model_ref, input.prompt, input.size ?? null, 1,
58
+ ...(input.workflow_parameters !== undefined ? [['comfyui-parameters/v0', normalizeComfyParameters(input.workflow_parameters)]] : [])]
59
+ : ['flow-create/v0', 'image-edit/v0', input.source_artifact_id, input.model_ref, input.prompt, input.size ?? null, 1]));
90
60
  }
91
61
  function validate(record, id, marker) {
92
62
  if (!keys(record, ['schemaVersion', 'id', 'rootId', 'kind', 'inputDigest', 'createdAt', 'intent', 'state', 'completedAt', 'result'],
@@ -96,14 +66,20 @@ function validate(record, id, marker) {
96
66
  || !['prepared', 'succeeded'].includes(record.state)) invalid();
97
67
  const intent = record.intent;
98
68
  let expectedResult, expectedDigest;
99
- if (record.kind === 'image-import') {
69
+ if (isImportCreation(record.kind)) {
70
+ const audio = record.kind === 'audio-import';
100
71
  if (!keys(intent, ['artifactId', 'mediaType', 'size', 'sha256', 'metadata', 'createdAt'])
101
- || !ARTIFACT.test(intent.artifactId) || !TYPES.has(intent.mediaType) || !SHA.test(intent.sha256)
102
- || !Number.isSafeInteger(intent.size) || intent.size < 1 || intent.size > MAX_IMAGE
103
- || !keys(intent.metadata, ['width', 'height']) || !['width', 'height'].every(key => Number.isSafeInteger(intent.metadata[key]) && intent.metadata[key] > 0)) invalid();
72
+ || audio && ![3, 4].includes(marker.schemaVersion)
73
+ || !ARTIFACT.test(intent.artifactId) || !(audio ? Object.hasOwn(AUDIO_TYPES, intent.mediaType) : TYPES.has(intent.mediaType)) || !SHA.test(intent.sha256)
74
+ || !Number.isSafeInteger(intent.size) || intent.size < 1 || intent.size > (audio ? MAX_AUDIO_INPUT_BYTES : MAX_IMAGE)
75
+ || (audio ? !keys(intent.metadata, []) : !keys(intent.metadata, ['width', 'height']) || !['width', 'height'].every(key => Number.isSafeInteger(intent.metadata[key]) && intent.metadata[key] > 0))) invalid();
104
76
  expectedDigest = creationInputDigest(record.kind, intent);
105
77
  expectedResult = { artifactId: intent.artifactId };
106
- } else if (record.kind === 'flow-create') {
78
+ } else if (['flow-create', 'generation-create', 'speech-create', 'transcription-create', 'video-create'].includes(record.kind)) {
79
+ if (record.kind === 'video-create' ? marker.schemaVersion !== 4 || intent.recipe !== VIDEO_RECIPE
80
+ : record.kind === 'generation-create' ? ![2, 3, 4].includes(marker.schemaVersion) || intent.recipe !== 'image-generate/v0'
81
+ : record.kind === 'flow-create' ? intent.recipe !== undefined
82
+ : ![3, 4].includes(marker.schemaVersion) || intent.recipe !== (record.kind === 'speech-create' ? 'speech/v0' : 'transcribe/v0')) invalid();
107
83
  try { validateFlowCreation(intent); } catch (_) { invalid(); }
108
84
  expectedDigest = creationInputDigest(record.kind, intent.input);
109
85
  expectedResult = { runId: intent.runId };
@@ -118,7 +94,7 @@ function validate(record, id, marker) {
118
94
  export function readCreationRequest(root, id, marker = inspectCreationRoot(root)) {
119
95
  if (!ID.test(id)) invalid();
120
96
  let record;
121
- try { record = jsonFile(path.join(root, 'creation-requests/v0/requests', id + '.json')); }
97
+ try { record = jsonFile(path.join(requestDirectory(root, marker, 'requests'), id + '.json')); }
122
98
  catch (error) { if (error.code === 'ENOENT') return null; throw error; }
123
99
  return copy(validate(record, id, marker));
124
100
  }
@@ -126,7 +102,7 @@ export function listCreationRequests(root) {
126
102
  const marker = inspectCreationRoot(root, { allowAbsent: true });
127
103
  if (!marker) return [];
128
104
  const records = [];
129
- for (const name of fs.readdirSync(path.join(root, 'creation-requests/v0/requests'))) {
105
+ for (const name of fs.readdirSync(requestDirectory(root, marker, 'requests'))) {
130
106
  if (/^req_[a-f0-9]{64}\.json\.tmp-[1-9][0-9]*-[a-f0-9]{12}$/.test(name)) continue;
131
107
  if (!/^req_[a-f0-9]{64}\.json$/.test(name)) invalid();
132
108
  const record = readCreationRequest(root, name.slice(0, -5), marker);
@@ -138,22 +114,23 @@ export function listCreationRequests(root) {
138
114
  export function publishCreationRequest(root, record) {
139
115
  const marker = inspectCreationRoot(root);
140
116
  validate(record, record.id, marker);
141
- atomicCreatePrivate(path.join(root, 'creation-requests/v0/requests', record.id + '.json'), JSON.stringify(record, null, 2) + '\n');
117
+ atomicCreatePrivate(path.join(requestDirectory(root, marker, 'requests'), record.id + '.json'), JSON.stringify(record, null, 2) + '\n');
142
118
  return copy(record);
143
119
  }
144
120
  export function completeCreationRequest(root, record) {
145
121
  const current = readCreationRequest(root, record.id);
146
122
  if (!current || JSON.stringify(current) !== JSON.stringify(record) || current.state !== 'prepared') invalid();
147
123
  const finished = { ...record, state: 'succeeded', completedAt: new Date().toISOString(),
148
- result: record.kind === 'image-import' ? { artifactId: record.intent.artifactId } : { runId: record.intent.runId } };
149
- validate(finished, record.id, inspectCreationRoot(root));
150
- atomicWritePrivate(path.join(root, 'creation-requests/v0/requests', record.id + '.json'), JSON.stringify(finished, null, 2) + '\n');
124
+ result: isImportCreation(record.kind) ? { artifactId: record.intent.artifactId } : { runId: record.intent.runId } };
125
+ const marker = inspectCreationRoot(root);
126
+ validate(finished, record.id, marker);
127
+ atomicWritePrivate(path.join(requestDirectory(root, marker, 'requests'), record.id + '.json'), JSON.stringify(finished, null, 2) + '\n');
151
128
  return copy(finished);
152
129
  }
153
130
  export function creationReferences(root) {
154
131
  return listCreationRequests(root).filter(record => record.state === 'prepared').map(record => ({
155
- requestId: record.id, taskIds: record.kind === 'flow-create' ? [record.intent.taskId] : [],
156
- artifactIds: record.kind === 'image-import' ? [record.intent.artifactId]
157
- : [record.intent.source.artifactId, record.intent.comparisonArtifactId]
132
+ requestId: record.id, taskIds: !isImportCreation(record.kind) ? [record.intent.taskId] : [],
133
+ artifactIds: isImportCreation(record.kind) ? [record.intent.artifactId]
134
+ : [record.intent.source?.artifactId, record.intent.comparisonArtifactId].filter(Boolean)
158
135
  }));
159
136
  }
package/lib/doctor.js CHANGED
@@ -224,9 +224,9 @@ export async function doctor(options = {}) {
224
224
  const nodeVersion = options.nodeVersion || process.versions.node;
225
225
  const checks = [];
226
226
 
227
- checks.push(atLeast(parseVersion(nodeVersion), [22, 0, 0])
227
+ checks.push(atLeast(parseVersion(nodeVersion), [22, 1, 0])
228
228
  ? result('node', 'ok', `Node.js ${nodeVersion}`)
229
- : result('node', 'error', `Node.js ${nodeVersion} 低于最低要求 22.0.0`, '升级 Node.js 后再运行 Iris。'));
229
+ : result('node', 'error', `Node.js ${nodeVersion} 低于最低要求 22.1.0`, '升级 Node.js 后再运行 Iris。'));
230
230
  try { await sharpLoader(); checks.push(result('sharp', 'ok', 'sharp 可加载')); }
231
231
  catch (_) { checks.push(result('sharp', 'error', 'sharp 无法加载', '在当前平台重新安装 @mokuyoaxis/dsh-iris,确认 sharp 有可用的 ARM64/系统构建。')); }
232
232
  for (const command of ['ffmpeg', 'ffprobe']) {
@@ -2,10 +2,11 @@
2
2
 
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
+ import { finished } from 'node:stream/promises';
5
6
  import { runtimeProviders, assignments, irisHome, recordRateLimit, recordAvailability, refreshAutomaticDirectories } from './config.js';
6
7
  import { withDshProviderConfig } from './dsh-provider-config.js';
7
8
  import { createCommandService } from './command-service.js';
8
- import { createCoreArtifact, inspectCoreArtifact, listCoreArtifacts, listCoreArtifactMetadata, readCoreArtifactBytes } from './core-artifacts.js';
9
+ import { createCoreArtifact, inspectCoreArtifact, listCoreArtifactMetadata, readCoreArtifactBytes, readCoreArtifactStream } from './core-artifacts.js';
9
10
  import { createApplicationRuntime } from './application-runtime.js';
10
11
  import { normalizeCoreOptions } from './core-contract.js';
11
12
  import { readSummaryFrames } from './summary-input.js';
@@ -375,7 +376,9 @@ export function createDshRuntime({ dataRoot: requestedRoot, getHost = () => unde
375
376
  ? { text: input.text, ...(input.voice !== undefined ? { voice: input.voice } : {}) }
376
377
  : input.audio_url !== undefined
377
378
  ? { audio_url: input.audio_url }
378
- : { prompt: input.prompt, ...(input.sourceArtifactId !== undefined ? { source_artifact_id: input.sourceArtifactId } : {}) },
379
+ : { prompt: input.prompt,
380
+ ...(input.videoMode !== undefined ? { video_mode: input.videoMode } : {}),
381
+ ...(input.sourceArtifactId !== undefined ? { source_artifact_id: input.sourceArtifactId } : {}) },
379
382
  model_ref: input.modelRef,
380
383
  confirm_billing: input.confirmBilling
381
384
  });
@@ -503,7 +506,7 @@ export function createDshRuntime({ dataRoot: requestedRoot, getHost = () => unde
503
506
  return true;
504
507
  }
505
508
 
506
- /** DSH 启动时只接管仍有明确远端受理证据的 Core 图片 Task。 */
509
+ /** DSH 启动时恢复本地拒绝终态;仅为可查询且有远端受理证据的 Task 继续盯守。 */
507
510
  async function resumeProviderTaskWatchesForDsh(options = {}) {
508
511
  options.signal.throwIfAborted();
509
512
  if (!fs.existsSync(dataRoot)) return Object.freeze([]);
@@ -518,11 +521,14 @@ export function createDshRuntime({ dataRoot: requestedRoot, getHost = () => unde
518
521
  }));
519
522
  const resumed = [];
520
523
  for (const original of records) {
521
- if (!WATCHABLE_CAPABILITIES.has(original.capability)) continue;
524
+ const incompleteRejection = original.phase === 'terminal'
525
+ && original.acceptance === 'not_accepted' && original.outcome === 'none';
526
+ if (!WATCHABLE_CAPABILITIES.has(original.capability) && !incompleteRejection) continue;
522
527
  try {
523
528
  options.signal.throwIfAborted();
524
529
  if (observableProviderTask(original)) await configuredAdapterForTask(original, options.signal);
525
530
  const needsRecovery = (original.phase === 'submitting' && original.acceptance === 'none')
531
+ || incompleteRejection
526
532
  || original.cancelState === 'requested'
527
533
  || (original.outcome === 'succeeded' && ['pending', 'downloading'].includes(original.deliveryState))
528
534
  || (original.acceptance === 'accepted' && original.watchState === 'active');
@@ -597,6 +603,7 @@ export function createDshRuntime({ dataRoot: requestedRoot, getHost = () => unde
597
603
  schemaVersion: 0, available: false, readOnly: true, degraded: false, droppedTasks: 0,
598
604
  tasks: Object.freeze({ total: 0, recent: Object.freeze([]) }),
599
605
  artifacts: Object.freeze({ total: 0, recent: Object.freeze([]) }),
606
+ activity: Object.freeze({ running: 0, paused: 0, attention: 0, truncated: false, recent: Object.freeze([]) }),
600
607
  userTasks: Object.freeze([])
601
608
  }));
602
609
  }
@@ -604,25 +611,39 @@ export function createDshRuntime({ dataRoot: requestedRoot, getHost = () => unde
604
611
  const tasks = listCoreTasks(dataRoot, { limit: options.limit || 12, skipInvalid: true });
605
612
  let artifacts = { total: 0, artifacts: Object.freeze([]) };
606
613
  let artifactDegraded = false;
607
- try { artifacts = listCoreArtifacts(dataRoot, { limit: options.limit || 12 }); }
614
+ try {
615
+ const metadata = listCoreArtifactMetadata(dataRoot), limit = Math.max(1, Math.min(200, Number(options.limit) || 12));
616
+ artifacts = { total: metadata.artifacts.length, artifacts: metadata.artifacts.slice(0, limit),
617
+ dropped: metadata.dropped, legacySkipped: metadata.legacySkipped };
618
+ artifactDegraded = metadata.dropped > 0;
619
+ }
608
620
  catch (_) { artifactDegraded = true; }
621
+ const records = listAllCoreTaskRecords(dataRoot), prefs = readCoreAttentionPrefs();
609
622
  const userTasks = decorateCoreUserRows(
610
623
  tasks.tasks.map((task) => projectCoreTaskUserRow(task, artifacts.artifacts)),
611
- listAllCoreTaskRecords(dataRoot),
612
- readCoreAttentionPrefs()
624
+ records, prefs
613
625
  );
626
+ const visible = decorateCoreUserRows(records.map(task => projectCoreTaskUserRow(task, artifacts.artifacts)), records, prefs)
627
+ .filter(row => !row.suppressed && row.disposition !== 'hidden' && !row.historical);
628
+ const running = visible.filter(row => row.userState === 'running');
629
+ const paused = visible.filter(row => row.userState === 'observation_paused');
630
+ const attention = visible.filter(row => ['attention', 'delivery_failed'].includes(row.userState) && row.disposition !== 'acknowledged');
614
631
  return Object.freeze({
615
632
  schemaVersion: 0, available: true, readOnly: true,
616
633
  degraded: artifactDegraded || tasks.dropped > 0,
617
634
  droppedTasks: tasks.dropped,
618
635
  tasks: Object.freeze({ total: tasks.total, recent: tasks.tasks }),
619
- artifacts: Object.freeze({ total: artifacts.total, recent: artifacts.artifacts }),
636
+ artifacts: Object.freeze({ total: artifacts.total, recent: artifacts.artifacts,
637
+ ...(artifacts.dropped ? { dropped: artifacts.dropped } : {}),
638
+ ...(artifacts.legacySkipped ? { legacySkipped: artifacts.legacySkipped } : {}) }),
639
+ activity: Object.freeze({ running: running.length, paused: paused.length, attention: attention.length,
640
+ truncated: records.length < tasks.total, recent: Object.freeze([...running, ...paused].slice(0, 10)) }),
620
641
  userTasks: Object.freeze(userTasks)
621
642
  });
622
643
  }), options.signal);
623
644
  }
624
645
 
625
- /** bounded:全量扫描仅供自动静默派生;超界仍有界退出。 */
646
+ /** bounded:自动静默与活动摘要复用同一扫描;超界明确标注统计不完整。 */
626
647
  const SUCCESSOR_SCAN_LIMIT = 2000;
627
648
  function listAllCoreTaskRecords(dataRoot) {
628
649
  const all = [];
@@ -687,6 +708,36 @@ export function createDshRuntime({ dataRoot: requestedRoot, getHost = () => unde
687
708
  }
688
709
  }
689
710
 
711
+ /** Hold the reader and DSH lifetime until the media stream has closed; HEAD only hashes. */
712
+ async function streamCoreArtifactMediaForDsh(artifactId, consume, options = {}) {
713
+ const id = String(artifactId || '').trim();
714
+ if (!/^artifact_[a-f0-9]{24}$/.test(id) || !fs.existsSync(dataRoot)) {
715
+ throw new DshCoreAdapterError('IRIS_DSH_ARTIFACT_NOT_FOUND', 'Core Artifact 不存在');
716
+ }
717
+ try {
718
+ return await withReader(runtime => runtime.run('inspect', async ({ dataRoot, signal }) => {
719
+ const result = options.headOnly ? { artifact: inspectCoreArtifact(dataRoot, id) }
720
+ : readCoreArtifactStream(dataRoot, id, { signal });
721
+ const closed = result.stream && finished(result.stream, { cleanup: true });
722
+ void closed?.catch(() => {}); // The consumer reports stream errors; observe them during setup too.
723
+ try {
724
+ const type = result.artifact.mediaType;
725
+ if (!type.startsWith('image/') && !type.startsWith('audio/') && !['video/mp4', 'video/webm', 'text/plain', 'application/json'].includes(type)) {
726
+ throw new DshCoreAdapterError('IRIS_DSH_ARTIFACT_UNSUPPORTED', '当前 DSH 媒体预览不支持此 Artifact 类型');
727
+ }
728
+ return await consume(result);
729
+ } finally {
730
+ result.stream?.destroy();
731
+ await closed?.catch(() => {}); // Drain the handle even after an early response or cancellation.
732
+ }
733
+ }), options.signal);
734
+ } catch (error) {
735
+ if (options.signal?.aborted || error?.code === 'IRIS_APP_CLOSED' || error?.code === 'IRIS_CORE_LEASE_INVALID') throw error;
736
+ if (error instanceof DshCoreAdapterError) throw error;
737
+ throw new DshCoreAdapterError('IRIS_DSH_ARTIFACT_NOT_FOUND', 'Core Artifact 不存在');
738
+ }
739
+ }
740
+
690
741
  /**
691
742
  * 将已经 ready 的 Core 图片投影为 DSH attachment blocks。重复调用只重复宿主展示,
692
743
  * 不修改 Task/Artifact,也不触发 submit、poll 或 download。
@@ -769,6 +820,7 @@ export function createDshRuntime({ dataRoot: requestedRoot, getHost = () => unde
769
820
  maintainWorkbenchForDsh: (...args) => invoke(maintainWorkbenchForDsh, 2, args),
770
821
  coreSnapshotForDsh: (...args) => invoke(coreSnapshotForDsh, 0, args),
771
822
  readCoreArtifactMediaForDsh: (...args) => invoke(readCoreArtifactMediaForDsh, 1, args),
823
+ streamCoreArtifactMediaForDsh: (...args) => invoke(streamCoreArtifactMediaForDsh, 2, args),
772
824
  projectCoreTaskForDsh: (...args) => invoke(projectCoreTaskForDsh, 2, args),
773
825
  });
774
826
  }