@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
@@ -0,0 +1,136 @@
1
+ // Owner-only editing boundary. No file I/O, secrets in projections, or implicit model authorization.
2
+ import { CAPABILITIES } from './capability.js';
3
+ import { providerModels, modelPool, functionPolicy, savedAssignmentRefs, resolvePoolModel } from './models.js';
4
+ import { BUILTIN_PROVIDER_PROTOCOLS, normalizeMediaEndpoint, normalizeImageProtocol, normalizeMusicProtocol, selectMediaProtocol, providerForModelBinding, isConfiguredProvider } from './provider-protocol.js';
5
+ import { clearModelCapabilityHealth, resetHealthOnCriticalChange } from './provider-health.js';
6
+ import { checkedComfyWorkflow, projectComfyWorkflow } from './comfyui-workflow.js';
7
+ import { validateProviderCredential, providerCredentialStatus, scrubProviderSecrets } from './provider-credentials.js';
8
+
9
+ const CAPS = Object.values(CAPABILITIES);
10
+ const fail = message => { throw Object.assign(new Error(message), { code: 'IRIS_CONFIG_INPUT_INVALID' }); };
11
+ function object(value, fields) {
12
+ if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(key => !fields.includes(key))) fail('设置包含不支持的字段');
13
+ }
14
+ function string(value, max, empty = false) {
15
+ if (typeof value !== 'string' || value.length > max || /[\u0000-\u001f\u007f]/.test(value) || !empty && !value.trim()) fail('设置文本为空、过长或含控制字符');
16
+ return value.trim();
17
+ }
18
+ function capabilities(value) {
19
+ if (!Array.isArray(value) || value.some(cap => !CAPS.includes(cap)) || new Set(value).size !== value.length) fail('模型用途必须是已知能力的非重复数组');
20
+ return [...value];
21
+ }
22
+ const protocols = registry => registry ? registry.snapshot().protocols : BUILTIN_PROVIDER_PROTOCOLS;
23
+
24
+ export function projectProviderSettings(catalog, revision, registry) {
25
+ const pool = modelPool(catalog.providers, { includeHostVision: true });
26
+ const result = { contractVersion: 0, source: 'explicit-provider-config', revision,
27
+ protocols: protocols(registry).map(({ id, label, capabilities, configuration }) => ({ id, label, capabilities, configuration })),
28
+ capabilities: CAPS,
29
+ providers: catalog.providers.map(provider => ({ id: provider.id, name: provider.name || provider.id,
30
+ baseUrl: provider.baseUrl || '', mediaBaseUrl: provider.mediaBaseUrl || '',
31
+ enabled: provider.enabled !== false, auth: provider.auth === 'none' ? 'none' : 'bearer',
32
+ hasCredential: providerCredentialStatus(provider) === 'ready', configured: isConfiguredProvider(provider),
33
+ ...(provider.apiKeyEnv !== undefined ? { apiKeyEnv: provider.apiKeyEnv, credentialStatus: providerCredentialStatus(provider) } : {}),
34
+ mediaProtocol: selectMediaProtocol(provider).mediaProtocol,
35
+ models: providerModels(provider).map(model => ({ id: model.id, ref: model.ref, capabilities: model.capabilities,
36
+ imageProtocol: model.imageProtocol || 'auto', musicProtocol: model.musicProtocol || '',
37
+ ...(provider.models?.find(entry => entry?.id === model.id)?.comfyWorkflow ? {
38
+ comfyWorkflow: projectComfyWorkflow(provider.models.find(entry => entry?.id === model.id).comfyWorkflow) } : {}),
39
+ catalogStatus: model.catalogStatus || 'unknown', unavailableCapabilities: model.unavailableCapabilities || [],
40
+ rateLimited: model.rateLimited === true,
41
+ verified: Object.fromEntries(Object.entries(model.verified || {}).map(([cap, value]) => [cap, {
42
+ ok: value.ok === true, at: value.at, category: value.category
43
+ }])) })) })),
44
+ assignments: Object.fromEntries(CAPS.map(cap => {
45
+ const policy = functionPolicy(catalog.assignments?.[cap], cap);
46
+ return [cap, { ...policy, models: savedAssignmentRefs(pool, cap, policy.models) }];
47
+ })) };
48
+ // A mistakenly pasted key in a display field must not become a key read-back route.
49
+ return scrubProviderSecrets(result, catalog.providers);
50
+ }
51
+
52
+ export function applyProviderSettings(catalog, input, registry) {
53
+ object(input, ['expectedRevision', 'provider', 'credential', 'assignments']);
54
+ object(input.provider, ['id', 'name', 'baseUrl', 'mediaBaseUrl', 'mediaProtocol', 'enabled', 'auth', 'models']);
55
+ object(input.credential, ['action', 'value', 'name']);
56
+ const value = input.provider, id = string(value.id, 160);
57
+ const matches = catalog.providers.filter(item => item.id === id);
58
+ if (matches.length > 1) fail('供应商 ID 重复,请在本机核对');
59
+ const previous = matches[0];
60
+ const next = { ...previous, id, name: string(value.name, 80), baseUrl: string(value.baseUrl, 2048),
61
+ mediaBaseUrl: string(value.mediaBaseUrl ?? '', 2048, true), mediaProtocol: string(value.mediaProtocol, 80),
62
+ enabled: value.enabled, auth: value.auth };
63
+ next.protocolInferred = false;
64
+ if (typeof next.enabled !== 'boolean' || !['bearer', 'none'].includes(next.auth)) fail('供应商启用或认证方式无效');
65
+ try { normalizeMediaEndpoint(next.baseUrl); if (next.mediaBaseUrl) normalizeMediaEndpoint(next.mediaBaseUrl); }
66
+ catch (_) { fail('地址必须是无凭据、查询或片段的 HTTP(S) URL'); }
67
+ if (!protocols(registry).some(item => item.id === next.mediaProtocol) && previous?.mediaProtocol !== next.mediaProtocol) fail('协议未注册,请先显式加载对应扩展');
68
+ const credential = input.credential;
69
+ if (!['keep', 'replace', 'clear', 'env'].includes(credential.action)
70
+ || credential.action !== 'replace' && Object.hasOwn(credential, 'value')
71
+ || credential.action !== 'env' && Object.hasOwn(credential, 'name')) fail('密钥操作必须是 keep、replace、clear 或 env,并只提供对应字段');
72
+ if (credential.action === 'replace') { next.apiKey = string(credential.value, 8192); delete next.apiKeyEnv; }
73
+ if (credential.action === 'env') { next.apiKeyEnv = string(credential.name, 128); delete next.apiKey; }
74
+ if (credential.action === 'clear') { delete next.apiKey; delete next.apiKeyEnv; }
75
+ validateProviderCredential(next);
76
+ if (!Array.isArray(value.models) || value.models.length > 256) fail('单次最多保存 256 个模型');
77
+ const existing = providerModels(previous || {});
78
+ const entries = Array.isArray(previous?.models) ? previous.models.map(model => typeof model === 'string' ? { id: model } : { ...model })
79
+ : existing.map(({ id, capabilities }) => ({ id, capabilities }));
80
+ const ids = new Set();
81
+ for (const model of value.models) {
82
+ object(model, ['id', 'capabilities', 'imageProtocol', 'musicProtocol', 'comfyWorkflow']);
83
+ const modelId = string(model.id, 512);
84
+ if (ids.has(modelId)) fail('模型 ID 重复');
85
+ ids.add(modelId);
86
+ const old = entries.find(entry => entry.id === modelId), oldView = existing.find(entry => entry.id === modelId);
87
+ const updated = { ...old, id: modelId, capabilities: capabilities(model.capabilities) };
88
+ if (model.comfyWorkflow !== undefined) updated.comfyWorkflow = checkedComfyWorkflow(model.comfyWorkflow);
89
+ if (!oldView || JSON.stringify(oldView.capabilities) !== JSON.stringify(updated.capabilities)) updated.source = 'manual';
90
+ if (model.imageProtocol !== undefined) {
91
+ const protocol = model.imageProtocol === old?.imageProtocol ? model.imageProtocol : normalizeImageProtocol(model.imageProtocol, registry);
92
+ if (protocol === 'auto') delete updated.imageProtocol; else updated.imageProtocol = protocol;
93
+ }
94
+ if (model.musicProtocol !== undefined) {
95
+ if (model.musicProtocol === '') delete updated.musicProtocol;
96
+ else updated.musicProtocol = model.musicProtocol === old?.musicProtocol ? model.musicProtocol : normalizeMusicProtocol(model.musicProtocol, registry);
97
+ }
98
+ if (old) entries[entries.indexOf(old)] = updated; else entries.push(updated);
99
+ }
100
+ next.models = entries; // Omitted existing records survive. The Web has no hard-delete operation.
101
+ if (previous) {
102
+ const previousConnection = { ...previous, auth: previous.auth === 'none' ? 'none' : 'bearer',
103
+ mediaBaseUrl: previous.mediaBaseUrl || '', mediaProtocol: selectMediaProtocol(previous).mediaProtocol };
104
+ resetHealthOnCriticalChange(previousConnection, next, next);
105
+ for (const model of existing) {
106
+ const updated = next.models.find(entry => entry.id === model.id);
107
+ const connectionChanged = ['apiKey', 'apiKeyEnv', 'baseUrl', 'mediaBaseUrl', 'auth', 'mediaProtocol'].some(key => previousConnection[key] !== next[key]);
108
+ for (const cap of model.capabilities) if (connectionChanged || !updated.capabilities?.includes(cap)
109
+ || cap === 'image-gen' && JSON.stringify(previous.models?.find(entry => entry?.id === model.id)?.comfyWorkflow) !== JSON.stringify(updated.comfyWorkflow)
110
+ || cap === 'image-gen' && providerForModelBinding(previous, model.id, cap).mediaProtocol !== providerForModelBinding(next, model.id, cap).mediaProtocol
111
+ || cap === 'music-gen' && previous.models?.find(entry => entry.id === model.id)?.musicProtocol !== updated.musicProtocol) {
112
+ clearModelCapabilityHealth(next, model.id, cap);
113
+ }
114
+ }
115
+ catalog.providers[catalog.providers.indexOf(previous)] = next;
116
+ } else catalog.providers.push(next);
117
+ if (input.assignments !== undefined) applyFunctionPolicies(catalog, input.assignments);
118
+ return catalog;
119
+ }
120
+
121
+ export function applyFunctionPolicies(catalog, assignments) {
122
+ object(assignments, CAPS);
123
+ const pool = modelPool(catalog.providers, { includeHostVision: true });
124
+ catalog.assignments ||= {};
125
+ for (const [cap, assignment] of Object.entries(assignments)) {
126
+ object(assignment, ['mode', 'models', 'hostVision']);
127
+ let policy;
128
+ try { policy = functionPolicy(assignment, cap); } catch (_) { fail('功能策略无效'); }
129
+ if (!Object.hasOwn(assignment, 'mode') || new Set(policy.models).size !== policy.models.length || policy.models.length > 256) fail('功能策略或模型顺序无效');
130
+ // Existing invalid refs may be retained for diagnosis, but never newly authorized.
131
+ const oldRefs = savedAssignmentRefs(pool, cap, functionPolicy(catalog.assignments[cap], cap).models);
132
+ for (const ref of policy.models) if (!resolvePoolModel(pool, ref, cap) && !oldRefs.includes(ref)) fail('用途引用了不存在或未标注该能力的模型');
133
+ catalog.assignments[cap] = { mode: policy.mode, models: policy.models, ...(cap === 'vision' ? { hostVision: policy.hostVision } : {}) };
134
+ }
135
+ return catalog;
136
+ }
@@ -4,6 +4,7 @@ import crypto from 'node:crypto';
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import sharp from 'sharp';
7
+ import { isAudioRecipe, normalizeAudioFlowInput } from './audio-flow-input.js';
7
8
  import { adoptCoreArtifactFile, createCoreArtifact } from './core-artifacts.js';
8
9
  import {
9
10
  activateCoreWatch,
@@ -19,6 +20,7 @@ import {
19
20
  recordCoreAttemptResult,
20
21
  recordCoreCancelResult,
21
22
  recordCoreDownloadCompleted,
23
+ recordCoreNetworkRoute,
22
24
  recordCorePollResult,
23
25
  recordCoreWatchError,
24
26
  recoverCoreTask,
@@ -34,8 +36,11 @@ import { ProviderContractError, providerErrorRecord, submitWithAcceptanceBoundar
34
36
  import { imageEditCandidates, readImageEditArtifact } from './image-edit-input.js';
35
37
  import { createDeliveryCache, newDeliveryPayloadPath, readDeliveryCache,
36
38
  readDeliveryPayload, recordDeliveryPayload } from './core-delivery-cache.js';
37
- import { readFlowRun, flowFail } from './flow-store.js';
39
+ import { readFlowRun, flowFail, flowStep } from './flow-store.js';
38
40
  import { normalizeGenerationInput } from './generation-input.js';
41
+ import { assertPluginDependencyMatches } from './plugin-contract.js';
42
+ import { checkedNetworkRequirement, sameNetworkRequirement, createNetworkAttemptBudget } from './network-policy.js';
43
+ import { assertProviderMediaSize } from './provider-response.js';
39
44
 
40
45
  export const PROVIDER_TASK_RUNNER_VERSION = 0;
41
46
 
@@ -46,7 +51,7 @@ export const PROVIDER_TASK_RUNNER_VERSION = 0;
46
51
  */
47
52
  export const DELIVERY_PROFILES = Object.freeze({
48
53
  image: Object.freeze({ kind: 'generated-image', mediaTypes: Object.freeze(['image/png', 'image/jpeg', 'image/webp']), defaultMediaType: 'image/png' }),
49
- video: Object.freeze({ kind: 'generated-video', mediaTypes: Object.freeze(['video/mp4']), defaultMediaType: 'video/mp4' }),
54
+ video: Object.freeze({ kind: 'generated-video', mediaTypes: Object.freeze(['video/mp4', 'video/webm']), defaultMediaType: 'video/mp4' }),
50
55
  // TTS 同步完成型:submit 内 completed → 同一次调用直接交付(无 remote 长轮询)。
51
56
  tts: Object.freeze({ kind: 'generated-audio', mediaTypes: Object.freeze(['audio/mpeg', 'audio/wav']), defaultMediaType: 'audio/mpeg' }),
52
57
  music: Object.freeze({ kind: 'generated-audio', mediaTypes: Object.freeze(['audio/mpeg', 'audio/wav']), defaultMediaType: 'audio/mpeg' }),
@@ -55,6 +60,20 @@ export const DELIVERY_PROFILES = Object.freeze({
55
60
  });
56
61
  const runtimeTaskGates = new WeakMap();
57
62
 
63
+ // The Core operation owns the root and route persistence, including when the
64
+ // network manager is supplied by a caller rather than created by CLI/Web.
65
+ function networkOperationContext(runtime, dataRoot, taskId, context) {
66
+ const task = inspectCoreTask(dataRoot, taskId);
67
+ return { ...context, taskId, modelRef: task.modelRef, providerId: task.providerId,
68
+ ...(runtime.reportScheduling ? { onSchedulingState: runtime.reportScheduling } : {}),
69
+ recordNetworkRoute: fact => runtime.checkpoint ? runtime.checkpoint(() => recordCoreNetworkRoute(dataRoot, taskId, fact)) : recordCoreNetworkRoute(dataRoot, taskId, fact) };
70
+ }
71
+
72
+ function invokeNetwork(runtime, adapter, name, request, context) {
73
+ const invoke = () => invokeProviderOperation(adapter, name, request, context);
74
+ return runtime.networkWait ? runtime.networkWait(invoke, name) : invoke();
75
+ }
76
+
58
77
  export class ProviderTaskRunnerError extends Error {
59
78
  constructor(code, message) {
60
79
  super(message);
@@ -73,12 +92,17 @@ function checkAdapterForTask(task, adapter) {
73
92
  if (!adapter || adapter.id !== task.providerId) {
74
93
  throw new ProviderTaskRunnerError('IRIS_PROVIDER_TASK_IDENTITY_MISMATCH', 'Provider Adapter 与 Task 已持久化身份不一致');
75
94
  }
95
+ assertPluginDependencyMatches(task.pluginDependency, adapter.pluginDependency);
96
+ const requirement = adapter.networkRequirement === undefined ? undefined : checkedNetworkRequirement(adapter.networkRequirement);
97
+ if (!sameNetworkRequirement(task.networkRequirement, requirement)) {
98
+ throw new ProviderTaskRunnerError('IRIS_NETWORK_REQUIREMENT_MISMATCH', 'Provider 网络要求与原 Task 不一致;不会隐式升级、解除或借用其他网络配置');
99
+ }
76
100
  return adapter;
77
101
  }
78
102
 
79
103
  function normalizedCandidates(capability, candidates) {
80
104
  if (!Array.isArray(candidates) || !candidates.length) throw new TypeError('至少需要一个 Provider 候选');
81
- return candidates.map((candidate, index) => {
105
+ const normalized = candidates.map((candidate, index) => {
82
106
  const adapter = candidate?.adapter;
83
107
  const model = String(candidate?.model || '').trim();
84
108
  const parsed = parseModelRef(model);
@@ -86,12 +110,24 @@ function normalizedCandidates(capability, candidates) {
86
110
  if (!adapter || !adapter.capabilities?.includes(capability) || !parsed
87
111
  || parsed.providerId !== adapter.id || !parsed.modelId
88
112
  || (candidate.providerBinding !== undefined && !/^sha256:[a-f0-9]{64}$/.test(candidate.providerBinding))
89
- || (selectionReason !== undefined && !['explicit', 'assignment', 'pool'].includes(selectionReason))) {
113
+ || (selectionReason !== undefined && !['explicit', 'assignment', 'pool'].includes(selectionReason))
114
+ || (adapter.pluginDependency && !candidate.providerBinding)) {
90
115
  throw new TypeError('Provider 候选 ' + (index + 1) + ' 不支持 Task capability 或模型复合身份无效');
91
116
  }
92
117
  if (candidate.prepareInput !== undefined && typeof candidate.prepareInput !== 'function') throw new TypeError('候选 prepareInput 必须是函数');
93
- return { adapter, model, providerModel: parsed.modelId, providerBinding: candidate.providerBinding, selectionReason, prepareInput: candidate.prepareInput };
118
+ if (candidate.supportsImageEdit !== undefined && typeof candidate.supportsImageEdit !== 'boolean') throw new TypeError('候选 supportsImageEdit 必须是布尔值');
119
+ const networkRequirement = adapter.networkRequirement === undefined ? undefined : checkedNetworkRequirement(adapter.networkRequirement);
120
+ if (candidate.networkRequirement !== undefined && !sameNetworkRequirement(checkedNetworkRequirement(candidate.networkRequirement), networkRequirement)) {
121
+ throw new ProviderTaskRunnerError('IRIS_NETWORK_REQUIREMENT_MISMATCH', '候选与 Adapter 的网络要求不一致');
122
+ }
123
+ return { adapter, model, providerModel: parsed.modelId, providerBinding: candidate.providerBinding, pluginDependency: adapter.pluginDependency, selectionReason, prepareInput: candidate.prepareInput,
124
+ ...(networkRequirement ? { networkRequirement } : {}),
125
+ ...(candidate.supportsImageEdit !== undefined ? { supportsImageEdit: candidate.supportsImageEdit } : {}) };
94
126
  });
127
+ if (normalized.some(candidate => !sameNetworkRequirement(candidate.networkRequirement, normalized[0].networkRequirement))) {
128
+ throw new ProviderTaskRunnerError('IRIS_NETWORK_REQUIREMENT_MISMATCH', '同一 Task 的所有模型候选必须属于同一网络要求');
129
+ }
130
+ return normalized;
95
131
  }
96
132
 
97
133
  function stagingFile(dataRoot) {
@@ -115,7 +151,20 @@ async function generatedImageMediaType(bytes) {
115
151
  return mediaType;
116
152
  }
117
153
 
118
- async function deliverArtifacts(dataRoot, taskId, adapter, remoteArtifacts, signal) {
154
+ /** Confirm the saved container, independently of an adapter's declared MIME; never buffer long audio. */
155
+ function generatedAudioMediaType(file) {
156
+ const header = Buffer.alloc(12), fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
157
+ let size;
158
+ try { size = fs.readSync(fd, header, 0, header.length, 0); }
159
+ finally { fs.closeSync(fd); }
160
+ if (size >= 12 && header.toString('ascii', 0, 4) === 'RIFF' && header.toString('ascii', 8, 12) === 'WAVE') return 'audio/wav';
161
+ if (size >= 10 && (header.toString('ascii', 0, 3) === 'ID3' || header[0] === 0xff && (header[1] & 0xe0) === 0xe0)) return 'audio/mpeg';
162
+ throw new ProviderContractError('供应商音频不是可识别的 WAV 或 MP3 容器', {
163
+ stage: 'download', category: 'protocol', acceptance: 'accepted'
164
+ });
165
+ }
166
+
167
+ async function deliverArtifacts(runtime, dataRoot, taskId, adapter, remoteArtifacts, signal) {
119
168
  beginCoreDelivery(dataRoot, taskId);
120
169
  const artifactIds = [];
121
170
  const stagedFiles = [];
@@ -141,22 +190,25 @@ async function deliverArtifacts(dataRoot, taskId, adapter, remoteArtifacts, sign
141
190
  }
142
191
  const target = stagingFile(dataRoot);
143
192
  stagedFiles.push({ target, mediaType });
144
- const downloaded = await invokeProviderOperation(adapter, 'download', {
193
+ const downloaded = await invokeNetwork(runtime, adapter, 'download', {
145
194
  capability: task.capability,
146
195
  artifact: remote,
147
196
  targetPath: target,
148
197
  signal
149
- }, { signal, taskId });
198
+ }, networkOperationContext(runtime, dataRoot, taskId, { signal, attemptId: task.activeAttemptId }));
150
199
  const stat = fs.lstatSync(target);
151
200
  if (!stat.isFile() || stat.isSymbolicLink() || stat.size !== downloaded.bytes) {
152
201
  throw Object.assign(new Error('Provider 物化结果与文件证据不一致'), {
153
202
  stage: 'download', category: 'local_io', acceptance: 'accepted'
154
203
  });
155
204
  }
205
+ assertProviderMediaSize(stat.size, task.capability);
156
206
  const staged = stagedFiles.at(-1);
157
207
  if (task.capability === 'image') {
158
208
  staged.bytes = fs.readFileSync(target);
159
209
  staged.mediaType = await generatedImageMediaType(staged.bytes);
210
+ } else if (['tts', 'music'].includes(task.capability)) {
211
+ staged.mediaType = generatedAudioMediaType(target);
160
212
  }
161
213
  }
162
214
  recordCoreDownloadCompleted(dataRoot, taskId);
@@ -182,30 +234,34 @@ async function deliverArtifacts(dataRoot, taskId, adapter, remoteArtifacts, sign
182
234
  }
183
235
  }
184
236
 
185
- async function observeWithinRoot(dataRoot, taskId, adapter, signal) {
237
+ async function observeWithinRoot(runtime, dataRoot, taskId, adapter, signal) {
186
238
  const before = inspectCoreTask(dataRoot, taskId);
187
239
  checkAdapterForTask(before, adapter);
188
240
  activateCoreWatch(dataRoot, taskId);
189
241
  let result;
190
242
  try {
191
- result = await invokeProviderOperation(adapter, 'poll', {
243
+ result = await invokeNetwork(runtime, adapter, 'poll', {
192
244
  capability: before.capability,
193
245
  remoteTaskId: before.remoteTaskId,
194
246
  signal
195
- }, { signal, taskId });
247
+ }, networkOperationContext(runtime, dataRoot, taskId, { signal, attemptId: before.activeAttemptId }));
196
248
  } catch (error) {
197
249
  return recordCoreWatchError(dataRoot, taskId, error);
198
250
  }
199
- const cachedFlowResult = Boolean(before.flowBinding && result.kind === 'succeeded');
200
- if (cachedFlowResult) createDeliveryCache(dataRoot, before, result.artifacts);
251
+ let cachedFlowResult = false, cacheError;
252
+ if (before.flowBinding && result.kind === 'succeeded') {
253
+ try { createDeliveryCache(dataRoot, before, result.artifacts); cachedFlowResult = true; }
254
+ catch (error) { cacheError = error; }
255
+ }
201
256
  const recorded = recordCorePollResult(dataRoot, taskId, result, { deliveryCache: cachedFlowResult });
202
257
  if (result.kind !== 'succeeded') return recorded;
203
- if (cachedFlowResult) return deliverCachedArtifacts(dataRoot, taskId, adapter, signal);
204
- return deliverArtifacts(dataRoot, taskId, adapter, result.artifacts, signal);
258
+ if (cacheError) return failCoreDelivery(dataRoot, taskId, cacheError);
259
+ if (cachedFlowResult) return deliverCachedArtifacts(runtime, dataRoot, taskId, adapter, signal);
260
+ return deliverArtifacts(runtime, dataRoot, taskId, adapter, result.artifacts, signal);
205
261
  }
206
262
 
207
263
  /** 同步结果按已提交缓存补交付;已下载项不再访问 Provider,稳定 Artifact ID 防重复。 */
208
- async function deliverCachedArtifacts(dataRoot, taskId, adapter, signal) {
264
+ async function deliverCachedArtifacts(runtime, dataRoot, taskId, adapter, signal) {
209
265
  beginCoreDelivery(dataRoot, taskId);
210
266
  try {
211
267
  const task = inspectCoreTask(dataRoot, taskId);
@@ -222,16 +278,17 @@ async function deliverCachedArtifacts(dataRoot, taskId, adapter, signal) {
222
278
  }
223
279
  checkAdapterForTask(task, adapter);
224
280
  const target = newDeliveryPayloadPath(dataRoot, cache);
225
- const downloaded = await invokeProviderOperation(adapter, 'download', {
281
+ const downloaded = await invokeNetwork(runtime, adapter, 'download', {
226
282
  capability: task.capability, artifact: item.source, targetPath: target, signal
227
- }, { signal, taskId });
283
+ }, networkOperationContext(runtime, dataRoot, taskId, { signal, attemptId: task.activeAttemptId }));
228
284
  const stat = fs.lstatSync(target);
229
285
  if (!stat.isFile() || stat.isSymbolicLink() || !stat.size || stat.size !== downloaded.bytes) {
230
286
  throw new Error('Provider 物化结果与文件证据不一致');
231
287
  }
288
+ assertProviderMediaSize(stat.size, task.capability);
232
289
  const mediaType = task.capability === 'image'
233
290
  ? await generatedImageMediaType(fs.readFileSync(target))
234
- : declaredMediaType;
291
+ : ['tts', 'music'].includes(task.capability) ? generatedAudioMediaType(target) : declaredMediaType;
235
292
  if (!profile.mediaTypes.includes(mediaType)) throw new Error('交付媒体类型不在 Profile 白名单');
236
293
  recordDeliveryPayload(dataRoot, cache, index, target, mediaType);
237
294
  }
@@ -244,7 +301,7 @@ async function deliverCachedArtifacts(dataRoot, taskId, adapter, signal) {
244
301
  const sourcePath = readDeliveryPayload(dataRoot, cache, index);
245
302
  const mediaType = task.capability === 'image'
246
303
  ? await generatedImageMediaType(fs.readFileSync(sourcePath))
247
- : (item.payload.mediaType || profile.defaultMediaType);
304
+ : ['tts', 'music'].includes(task.capability) ? generatedAudioMediaType(sourcePath) : (item.payload.mediaType || profile.defaultMediaType);
248
305
  if (!profile.mediaTypes.includes(mediaType)) throw new Error('交付媒体类型不在 Profile 白名单');
249
306
  const artifact = adoptCoreArtifactFile(dataRoot, {
250
307
  artifactId: item.artifactId, sourcePath, createdAt: cache.createdAt,
@@ -266,10 +323,11 @@ async function deliverCachedArtifacts(dataRoot, taskId, adapter, signal) {
266
323
  */
267
324
  export function createProviderTaskRunner(runtime) {
268
325
  checkRuntime(runtime);
269
- let activeTasks = runtimeTaskGates.get(runtime);
326
+ const gate = runtime.taskGate || runtime;
327
+ let activeTasks = runtimeTaskGates.get(gate);
270
328
  if (!activeTasks) {
271
329
  activeTasks = new Set();
272
- runtimeTaskGates.set(runtime, activeTasks);
330
+ runtimeTaskGates.set(gate, activeTasks);
273
331
  }
274
332
 
275
333
  async function runExclusive(taskId, operation, callback) {
@@ -293,28 +351,43 @@ export function createProviderTaskRunner(runtime) {
293
351
  if (signal.aborted) throw new ProviderTaskRunnerError('IRIS_PROVIDER_ABORTED', '提交已取消');
294
352
  const capability = String(input.capability || '').trim();
295
353
  let candidates = normalizedCandidates(capability, input.candidates);
354
+ const networkRequirement = candidates[0].networkRequirement;
355
+ const networkBudget = createNetworkAttemptBudget();
296
356
  let providerInput = input.providerInput ?? {};
357
+ if (providerInput.workflow_parameters !== undefined && candidates.some(candidate => candidate.adapter.protocol !== 'comfyui')) {
358
+ throw new ProviderTaskRunnerError('IRIS_COMFYUI_PARAMETERS_INVALID', '工作流参数仅用于明确的 ComfyUI 模板');
359
+ }
297
360
  let flow;
298
361
  if (input.flowRunId !== undefined) {
299
362
  flow = readFlowRun(dataRoot, input.flowRunId);
300
- const expected = normalizeGenerationInput('image', flow.input).providerInput;
301
- if (capability !== 'image' || !flow.edit.claimed || input.sourceArtifactId !== flow.source.artifactId
363
+ const audio = isAudioRecipe(flow.recipe), video = flow.recipe === 'video-generate/v0';
364
+ const expected = audio ? Object.fromEntries(Object.entries(normalizeAudioFlowInput(flow.recipe, flow.input)).filter(([key]) => key !== 'model_ref'))
365
+ : normalizeGenerationInput(video ? 'video' : 'image', flow.input).providerInput;
366
+ const expectedCapability = video ? 'video' : audio ? flow.recipe === 'speech/v0' ? 'tts' : 'transcribe' : 'image';
367
+ if (capability !== expectedCapability || !flowStep(flow).claimed || input.sourceArtifactId !== flow.source?.artifactId
302
368
  || Object.keys(providerInput).length !== Object.keys(expected).length
303
- || Object.keys(expected).some(key => providerInput[key] !== expected[key])
369
+ || Object.keys(expected).some(key => key === 'workflow_parameters'
370
+ ? JSON.stringify(providerInput[key]) !== JSON.stringify(expected[key]) : providerInput[key] !== expected[key])
304
371
  || candidates.length !== 1 || candidates[0].model !== flow.input.model_ref
305
- || candidates[0].providerBinding !== flow.providerBinding) {
372
+ || candidates[0].providerBinding !== flow.providerBinding || !sameNetworkRequirement(networkRequirement, flow.networkRequirement)) {
306
373
  flowFail('IRIS_FLOW_INPUT_CONFLICT', '提交与流程已保存的输入或模型绑定冲突');
307
374
  }
308
375
  }
309
- if (input.sourceArtifactId !== undefined) {
310
- if (capability !== 'image') throw new TypeError('sourceArtifactId 仅用于图片编辑');
376
+ if (input.sourceArtifactId !== undefined && capability === 'image') {
311
377
  candidates = imageEditCandidates(candidates);
312
378
  const image = await readImageEditArtifact(dataRoot, input.sourceArtifactId, signal);
313
379
  providerInput = { ...providerInput, image };
314
380
  }
381
+ if (input.sourceArtifactId !== undefined && !['image', 'transcribe'].includes(capability)) throw new TypeError('来源作品仅用于图片编辑或转写');
382
+ if (runtime.scheduler) await runtime.scheduler.beforeSubmit(runtime, dataRoot, { modelRef: candidates[0].model },
383
+ () => candidates[0].adapter.schedulingReadyAt?.(candidates[0].providerModel));
315
384
  const task = flow ? claimFlowTask(dataRoot, flow.id)
316
- : createCoreTask(dataRoot, { capability, retriedFrom: input.retriedFrom, sourceArtifactId: input.sourceArtifactId });
385
+ : createCoreTask(dataRoot, { capability, retriedFrom: input.retriedFrom, sourceArtifactId: input.sourceArtifactId,
386
+ ...(networkRequirement ? { networkRequirement } : {}) });
317
387
  if (flow && task.attempts.length) flowFail('IRIS_FLOW_TASK_ALREADY_STARTED', '该流程 Task 已有提交意图,禁止重新 submit');
388
+ if (activeTasks.has(task.id)) throw new ProviderTaskRunnerError('IRIS_PROVIDER_TASK_BUSY', '该 Task 已有生命周期操作正在执行');
389
+ activeTasks.add(task.id);
390
+ try {
318
391
  const completedResults = new Map();
319
392
  const wrapped = candidates.map(({ adapter, model, providerModel, prepareInput }) => ({
320
393
  id: adapter.id,
@@ -322,17 +395,22 @@ export function createProviderTaskRunner(runtime) {
322
395
  adapter,
323
396
  submit: async (providerInput, context) => {
324
397
  if (prepareInput) {
325
- try { providerInput = await prepareInput({ providerInput, signal }); }
326
- catch (error) { throw new ProviderContractError('视频输入准备失败:' + (error.safeMessage || error.message), {
398
+ try {
399
+ const prepareContext = networkOperationContext(runtime, dataRoot, task.id, { providerInput, signal, attempt: context.attempt, attemptId: context.attempt.id, networkBudget });
400
+ const prepare = () => prepareInput(prepareContext);
401
+ providerInput = await (runtime.networkWait ? runtime.networkWait(prepare, 'prepareInput') : prepare());
402
+ }
403
+ catch (error) { throw new ProviderContractError((capability === 'video' ? '视频' : '音频') + '输入准备失败:' + (error.safeMessage || error.message), {
327
404
  ...providerErrorRecord(error), stage: error.stage || 'prepare', category: error.category || 'local_io', acceptance: 'not_accepted'
328
405
  }); }
329
406
  }
330
407
  if (signal.aborted) throw new ProviderContractError('提交已取消', { stage: 'prepare', category: 'aborted', acceptance: 'not_accepted' });
331
- const result = await invokeProviderOperation(
408
+ const result = await invokeNetwork(
409
+ runtime,
332
410
  adapter,
333
411
  'submit',
334
412
  { capability, model: providerModel, input: providerInput, signal },
335
- { ...context, signal, taskId: task.id }
413
+ networkOperationContext(runtime, dataRoot, task.id, { ...context, signal, attemptId: context.attempt.id, networkBudget })
336
414
  );
337
415
  if (result.kind === 'completed') completedResults.set(context.attempt.id, result.artifacts);
338
416
  return result;
@@ -342,9 +420,13 @@ export function createProviderTaskRunner(runtime) {
342
420
  signal,
343
421
  beforeAttempt: async (attempt) => {
344
422
  const candidate = candidates[attempt.ordinal - 1];
423
+ if (attempt.ordinal > 1 && runtime.scheduler) await runtime.scheduler.beforeSubmit(runtime, dataRoot, { modelRef: candidate.model },
424
+ () => candidate.adapter.schedulingReadyAt?.(candidate.providerModel));
345
425
  return beginCoreAttempt(dataRoot, task.id, {
346
426
  ...attempt,
347
427
  ...(candidate.providerBinding !== undefined ? { providerBinding: candidate.providerBinding } : {}),
428
+ ...(candidate.networkRequirement ? { networkRequirement: candidate.networkRequirement } : {}),
429
+ ...(candidate.pluginDependency ? { pluginDependency: candidate.pluginDependency } : {}),
348
430
  ...(candidate.selectionReason !== undefined ? { selectionReason: candidate.selectionReason } : {})
349
431
  });
350
432
  },
@@ -372,7 +454,7 @@ export function createProviderTaskRunner(runtime) {
372
454
  } else if (submitted.result.kind === 'completed') {
373
455
  return Object.freeze({
374
456
  taskId: task.id,
375
- task: await deliverCachedArtifacts(dataRoot, task.id, submitted.candidate.adapter, signal),
457
+ task: await deliverCachedArtifacts(runtime, dataRoot, task.id, submitted.candidate.adapter, signal),
376
458
  attempts: submitted.attempts
377
459
  });
378
460
  }
@@ -381,6 +463,7 @@ export function createProviderTaskRunner(runtime) {
381
463
  task: inspectCoreTask(dataRoot, task.id),
382
464
  attempts: submitted.attempts
383
465
  });
466
+ } finally { activeTasks.delete(task.id); }
384
467
  });
385
468
  }
386
469
 
@@ -395,7 +478,7 @@ export function createProviderTaskRunner(runtime) {
395
478
  checkAdapterForTask(current, adapter);
396
479
  if (current.watchState === 'active') recoverCoreTask(dataRoot, taskId);
397
480
  }
398
- return observeWithinRoot(dataRoot, taskId, adapter, signal);
481
+ return observeWithinRoot(runtime, dataRoot, taskId, adapter, signal);
399
482
  });
400
483
  }
401
484
 
@@ -404,14 +487,14 @@ export function createProviderTaskRunner(runtime) {
404
487
  const task = inspectCoreTask(dataRoot, taskId);
405
488
  if (adapter || !task.deliveryCache) checkAdapterForTask(task, adapter);
406
489
  prepareCoreRedelivery(dataRoot, taskId);
407
- if (task.deliveryCache) return deliverCachedArtifacts(dataRoot, taskId, adapter, signal);
490
+ if (task.deliveryCache) return deliverCachedArtifacts(runtime, dataRoot, taskId, adapter, signal);
408
491
  let result;
409
492
  try {
410
- result = await invokeProviderOperation(adapter, 'poll', {
493
+ result = await invokeNetwork(runtime, adapter, 'poll', {
411
494
  capability: task.capability,
412
495
  remoteTaskId: task.remoteTaskId,
413
496
  signal
414
- }, { signal, taskId, redelivery: true });
497
+ }, networkOperationContext(runtime, dataRoot, taskId, { signal, attemptId: task.activeAttemptId, redelivery: true }));
415
498
  } catch (error) {
416
499
  return failCoreDelivery(dataRoot, taskId, error);
417
500
  }
@@ -421,7 +504,7 @@ export function createProviderTaskRunner(runtime) {
421
504
  { stage: 'download', category: 'protocol', acceptance: 'accepted' }
422
505
  ));
423
506
  }
424
- return deliverArtifacts(dataRoot, taskId, adapter, result.artifacts, signal);
507
+ return deliverArtifacts(runtime, dataRoot, taskId, adapter, result.artifacts, signal);
425
508
  });
426
509
  }
427
510
 
@@ -433,23 +516,26 @@ export function createProviderTaskRunner(runtime) {
433
516
  if (!hasProviderOperation(adapter, 'cancel')) {
434
517
  return recordCoreCancelResult(dataRoot, taskId, { kind: 'not_supported' });
435
518
  }
436
- const result = await invokeProviderOperation(adapter, 'cancel', {
519
+ const result = await invokeNetwork(runtime, adapter, 'cancel', {
437
520
  capability: task.capability,
438
521
  remoteTaskId: task.remoteTaskId,
439
522
  signal
440
- }, { signal, taskId });
523
+ }, networkOperationContext(runtime, dataRoot, taskId, { signal, attemptId: task.activeAttemptId }));
441
524
  return recordCoreCancelResult(dataRoot, taskId, result);
442
525
  });
443
526
  }
444
527
 
445
528
  function recover(taskId, adapter) {
446
529
  return runExclusive(taskId, 'recover', async ({ dataRoot, signal }) => {
530
+ const before = inspectCoreTask(dataRoot, taskId);
531
+ if (before.acceptance === 'accepted' && before.remoteTaskId && before.outcome === 'none'
532
+ && ['accepted', 'running'].includes(before.phase)) checkAdapterForTask(before, adapter);
447
533
  const recovered = recoverCoreTask(dataRoot, taskId);
448
534
  if (recovered.acceptance === 'accepted' && recovered.remoteTaskId
449
535
  && recovered.outcome === 'none'
450
536
  && ['accepted', 'running'].includes(recovered.phase)) {
451
537
  checkAdapterForTask(recovered, adapter);
452
- return observeWithinRoot(dataRoot, taskId, adapter, signal);
538
+ return observeWithinRoot(runtime, dataRoot, taskId, adapter, signal);
453
539
  }
454
540
  return recovered;
455
541
  });
@@ -462,6 +548,9 @@ export function createProviderTaskRunner(runtime) {
462
548
  observe,
463
549
  redeliver,
464
550
  cancel,
465
- recover
551
+ recover,
552
+ assertIdle(taskId) {
553
+ if (activeTasks.has(taskId)) throw new ProviderTaskRunnerError('IRIS_PROVIDER_TASK_BUSY', '该 Task 已有生命周期操作正在执行');
554
+ }
466
555
  });
467
556
  }
@@ -0,0 +1,16 @@
1
+ // Bind all hops of an existing media transport to an instance-owned request function.
2
+ import * as transport from './adapters.js';
3
+
4
+ const REQUEST_METHODS = Object.freeze([
5
+ 'generateImageMultimodal', 'startImageGeneration', 'submitImage', 'submitVideo', 'uploadTempFile',
6
+ 'pollTask', 'synthesizeTts', 'openAiGenerateImage', 'openAiChatGenerateImage', 'openAiResponsesGenerateImage',
7
+ 'listModels', 'visionStream', 'submitTranscription', 'pollTranscriptionTask', 'generateMusic'
8
+ ]);
9
+
10
+ export function createProviderTransport(fetchPort) {
11
+ if (typeof fetchPort !== 'function') throw new TypeError('Provider transport 需要明确的 fetchPort');
12
+ const bound = { ...transport };
13
+ for (const name of REQUEST_METHODS) bound[name] = input => transport[name]({ ...input, fetchPort });
14
+ bound.downloadTo = (url, filePath, options) => transport.downloadTo(url, filePath, { ...options, fetchPort });
15
+ return Object.freeze(bound);
16
+ }
package/lib/render.js CHANGED
@@ -22,6 +22,7 @@ import { imageDimensions } from './pixels.js';
22
22
 
23
23
  const PNG_DATA_PREFIX = 'data:image/png;base64,';
24
24
  const MAX_SCREENSHOT_BYTES = 64 * 1024 * 1024;
25
+ const pendingRenders = new Map();
25
26
 
26
27
  async function screenshotPng(result) {
27
28
  const dataUrl = result?.dataUrl;
@@ -127,6 +128,8 @@ export class BrowserHtmlRenderer {
127
128
  const htmlPath = path.join(dir, 'index.html');
128
129
  writePrivateFile(htmlPath, content);
129
130
  const url = `${address.baseUrl}/iris/render/${path.basename(dir)}/index.html`;
131
+ const delivery = { served: false };
132
+ pendingRenders.set(path.basename(dir), delivery);
130
133
  try {
131
134
  // open 只接受可选的会话名称;取消发生在打开期间时,取得会话后仍须关闭。
132
135
  const session = await b.open();
@@ -136,6 +139,9 @@ export class BrowserHtmlRenderer {
136
139
  throwIfAborted(signal);
137
140
  await waitForRender(this.waitMs, signal); // 等页面加载(无加载完成事件可用的折中)
138
141
  throwIfAborted(signal);
142
+ if (!delivery.served) throw Object.assign(new RenderError('未确认 Iris 已返回本次 HTML 页面;请检查渲染路由和 DSH_WEB_BASE。'), {
143
+ code: 'IRIS_RENDER_ROUTE_UNCONFIRMED'
144
+ });
139
145
  // 不要求浏览器写入 Iris 私有目录,兼容 downloadDir 内的保存路径限制。
140
146
  const result = await b.screenshot(session, { fullPage: fullPage !== false, format: 'png' }, signal);
141
147
  throwIfAborted(signal);
@@ -146,6 +152,7 @@ export class BrowserHtmlRenderer {
146
152
  try { await b.close(session); } catch (_) { /* 幂等 */ }
147
153
  }
148
154
  } finally {
155
+ pendingRenders.delete(path.basename(dir));
149
156
  fs.rmSync(dir, { recursive: true, force: true }); // 用完即清
150
157
  }
151
158
  }
@@ -196,7 +203,10 @@ export function serveRender(req, res) {
196
203
  'Referrer-Policy': 'no-referrer',
197
204
  'X-Content-Type-Options': 'nosniff'
198
205
  });
199
- res.end(method === 'HEAD' ? undefined : fs.readFileSync(abs));
206
+ const body = method === 'HEAD' ? undefined : fs.readFileSync(abs);
207
+ const delivery = file === 'index.html' && method === 'GET' ? pendingRenders.get(subdir) : undefined;
208
+ if (delivery) res.once('finish', () => { delivery.served = true; });
209
+ res.end(body);
200
210
  } catch (err) {
201
211
  if (!res.headersSent) res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
202
212
  res.end('render error');