@mokuyoaxis/dsh-iris 0.3.0-alpha.1 → 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 (174) hide show
  1. package/CHANGELOG.md +63 -1
  2. package/README.en.md +20 -13
  3. package/README.md +20 -13
  4. package/bin/dsh-iris.js +143 -51
  5. package/docs/AGENT_CORE_QUICKSTART.md +2 -2
  6. package/docs/AGENT_QUICKSTART.md +1 -1
  7. package/docs/ARCHITECTURE.md +5 -5
  8. package/docs/ARTIFACT_MANIFEST.md +4 -0
  9. package/docs/AUDIO_FLOWS.md +20 -4
  10. package/docs/BETA_SCOPE.md +42 -0
  11. package/docs/CONTACT_SHEET.md +1 -1
  12. package/docs/CORE_CONNECTIONS.md +3 -1
  13. package/docs/CORE_QUICKSTART.md +7 -5
  14. package/docs/CORE_RUNTIME_CONTRACT.md +3 -3
  15. package/docs/CORE_SERVICE.md +9 -5
  16. package/docs/CORE_SKILLS.md +2 -2
  17. package/docs/DSH_COMPATIBILITY.md +11 -1
  18. package/docs/DSH_CORE_MIGRATION.md +6 -0
  19. package/docs/DSH_RUNTIME.md +2 -2
  20. package/docs/HEADLESS_CLI.md +29 -4
  21. package/docs/HOST_ADAPTER_CONTRACT.md +5 -3
  22. package/docs/IMAGE_FLOW.md +2 -2
  23. package/docs/IMAGE_GENERATION.md +1 -1
  24. package/docs/LOCAL_API.md +2 -2
  25. package/docs/LOCAL_API_CONTRACT.md +17 -15
  26. package/docs/LOCAL_CREATION.md +2 -2
  27. package/docs/LOCAL_OPERATIONS.md +2 -2
  28. package/docs/LOCAL_SELECTION.md +1 -1
  29. package/docs/LOCAL_WEB.md +11 -5
  30. package/docs/MCP.md +30 -9
  31. package/docs/MEDIA_SCHEDULING.md +72 -0
  32. package/docs/NETWORK_RESOURCES.md +106 -0
  33. package/docs/NETWORK_ROUTING.md +103 -0
  34. package/docs/NETWORK_WORKBENCH.md +65 -0
  35. package/docs/PANEL_EXTENSIONS.md +1 -1
  36. package/docs/PLUGINS.md +9 -5
  37. package/docs/PLUGIN_DEVELOPMENT.md +39 -0
  38. package/docs/PLUGIN_STARTUP.md +15 -2
  39. package/docs/PROVIDER_ADAPTER_CONTRACT.md +8 -2
  40. package/docs/PROVIDER_CREDENTIALS.md +67 -0
  41. package/docs/PROVIDER_EXTENSIONS.md +8 -4
  42. package/docs/PROVIDER_HEALTH.md +4 -2
  43. package/docs/PROVIDER_NETWORK.md +45 -0
  44. package/docs/PROVIDER_PLUGIN_CONTRACT.md +24 -0
  45. package/docs/PROVIDER_SETTINGS.md +13 -7
  46. package/docs/PROVIDER_SUBMISSION_CONTRACT.md +3 -1
  47. package/docs/ROADMAP.md +6 -6
  48. package/docs/SECURITY.md +1 -1
  49. package/docs/SYNC_DELIVERY_RECOVERY.md +5 -1
  50. package/docs/VIDEO_FLOWS.md +89 -0
  51. package/docs/VISION_MODEL.md +2 -0
  52. package/docs/WEB_PROTOTYPE.md +3 -3
  53. package/docs/WEB_RECOVERY.md +2 -2
  54. package/docs/WORKBENCH_ARTIFACTS.md +6 -0
  55. package/docs/WORKBENCH_LOGIN.md +2 -2
  56. package/docs/releases/0.3.0-beta.1.md +34 -0
  57. package/docs/screenshots.md +2 -2
  58. package/lib/actions.js +5 -4
  59. package/lib/adapters.js +58 -41
  60. package/lib/api.js +23 -13
  61. package/lib/application-runtime.js +44 -15
  62. package/lib/application-writer.js +193 -0
  63. package/lib/client.d.ts +3 -3
  64. package/lib/client.js +62 -25
  65. package/lib/comfyui-adapter.js +3 -2
  66. package/lib/comfyui-client.js +10 -1
  67. package/lib/command-service.js +18 -5
  68. package/lib/config.js +13 -9
  69. package/lib/configured-image-runtime.js +28 -9
  70. package/lib/connection-skill.js +9 -0
  71. package/lib/core-artifact-store.js +68 -26
  72. package/lib/core-bridge-tools.js +5 -3
  73. package/lib/core-capability-catalog.js +21 -11
  74. package/lib/core-delivery-cache.js +8 -3
  75. package/lib/core-service-client.js +6 -2
  76. package/lib/core-tasks.js +86 -11
  77. package/lib/creation-request-store.js +8 -5
  78. package/lib/doctor.js +2 -2
  79. package/lib/dsh-core-adapter.js +61 -9
  80. package/lib/dsh-provider-config.js +1 -1
  81. package/lib/dsh-service-bridge.js +1 -0
  82. package/lib/flow-store.js +64 -17
  83. package/lib/image-flow.js +39 -17
  84. package/lib/index.js +10 -5
  85. package/lib/local-api-projection.js +6 -2
  86. package/lib/local-api.js +137 -49
  87. package/lib/local-creation.js +12 -5
  88. package/lib/local-operations.js +2 -2
  89. package/lib/local-request-root.js +2 -2
  90. package/lib/local-web-assets.js +2 -0
  91. package/lib/mcp-http.js +82 -9
  92. package/lib/mcp-server.js +23 -1
  93. package/lib/media-scheduler.js +194 -0
  94. package/lib/model-directory-service.js +25 -8
  95. package/lib/music-transport.js +4 -2
  96. package/lib/network-config-service.js +123 -0
  97. package/lib/network-config.js +127 -0
  98. package/lib/network-doctor.js +94 -0
  99. package/lib/network-management.js +51 -0
  100. package/lib/network-policy.js +19 -0
  101. package/lib/network-route-evidence.js +38 -0
  102. package/lib/network-routing.js +283 -0
  103. package/lib/operation-runner.js +83 -38
  104. package/lib/operation-store.js +6 -6
  105. package/lib/plugins.d.ts +25 -6
  106. package/lib/plugins.js +2 -1
  107. package/lib/provider-adapter.js +2 -2
  108. package/lib/provider-adapters.js +37 -5
  109. package/lib/provider-catalog.js +33 -16
  110. package/lib/provider-config-format.js +51 -0
  111. package/lib/provider-config-service.js +52 -22
  112. package/lib/provider-context.d.ts +90 -0
  113. package/lib/provider-context.js +42 -0
  114. package/lib/provider-credentials.js +97 -0
  115. package/lib/provider-diagnostics.js +5 -5
  116. package/lib/provider-health.js +10 -5
  117. package/lib/provider-monitor.js +5 -3
  118. package/lib/provider-network.js +52 -0
  119. package/lib/provider-protocol.js +3 -2
  120. package/lib/provider-registry.js +15 -5
  121. package/lib/provider-response.js +10 -0
  122. package/lib/provider-settings.js +14 -12
  123. package/lib/provider-task-runner.js +113 -37
  124. package/lib/provider-transport.js +16 -0
  125. package/lib/render.js +11 -1
  126. package/lib/scheduling-policy.js +66 -0
  127. package/lib/tasks.js +3 -2
  128. package/lib/video-flow-input.js +16 -0
  129. package/lib/video-input.js +12 -5
  130. package/lib/vision-model-routing.js +2 -0
  131. package/lib/web/app.js +107 -46
  132. package/lib/web/connections.js +151 -12
  133. package/lib/web/creation.js +29 -25
  134. package/lib/web/flow-view.js +4 -3
  135. package/lib/web/gallery.js +17 -16
  136. package/lib/web/index.html +58 -5
  137. package/lib/web/media-client.js +18 -4
  138. package/lib/web/network.js +232 -0
  139. package/lib/web/plugin-panel.js +4 -2
  140. package/lib/web/plugin-status.js +140 -0
  141. package/lib/web/providers.js +34 -13
  142. package/lib/web/recovery.js +2 -2
  143. package/lib/web/styles.css +54 -1
  144. package/lib/web/transport.js +6 -1
  145. package/lib/workbench-artifacts.js +3 -2
  146. package/package.json +5 -2
  147. package/skills/iris-core/references/comfyui.md +1 -1
  148. package/templates/plugin-starter/LICENSE +21 -0
  149. package/templates/plugin-starter/README.md +74 -0
  150. package/templates/plugin-starter/assets/peach.png +0 -0
  151. package/templates/plugin-starter/assets/violet.png +0 -0
  152. package/templates/plugin-starter/index.mjs +27 -0
  153. package/templates/plugin-starter/package.json +18 -0
  154. package/templates/plugin-starter/scripts/core-cli.mjs +20 -0
  155. package/templates/plugin-starter/scripts/environment.mjs +65 -0
  156. package/templates/plugin-starter/scripts/mcp-probe.mjs +22 -0
  157. package/templates/plugin-starter/scripts/setup.mjs +42 -0
  158. package/templates/plugin-starter/scripts/smoke.mjs +76 -0
  159. package/templates/plugin-starter/web/board.css +27 -0
  160. package/templates/plugin-starter/web/board.mjs +160 -0
  161. package/templates/plugin-starter/web/png.mjs +23 -0
  162. package/templates/provider-starter/LICENSE +21 -0
  163. package/templates/provider-starter/README.md +50 -0
  164. package/templates/provider-starter/index.d.mts +5 -0
  165. package/templates/provider-starter/index.mjs +165 -0
  166. package/templates/provider-starter/package.json +13 -0
  167. package/templates/provider-starter/scripts/core-cli.mjs +22 -0
  168. package/templates/provider-starter/scripts/environment.mjs +122 -0
  169. package/templates/provider-starter/scripts/fixture.mjs +54 -0
  170. package/templates/provider-starter/scripts/mcp-probe.mjs +21 -0
  171. package/templates/provider-starter/scripts/protocol-smoke.mjs +48 -0
  172. package/templates/provider-starter/scripts/setup.mjs +44 -0
  173. package/templates/provider-starter/scripts/smoke.mjs +145 -0
  174. package/user_guide.md +3 -3
@@ -0,0 +1,52 @@
1
+ // Internal composition port. No proxy configuration, global mutation or automatic retry.
2
+ export function inheritedProviderFetch(...args) { return globalThis.fetch(...args); }
3
+
4
+ function closed() {
5
+ return Object.assign(new Error('Provider 网络客户端正在关闭或已关闭'), {
6
+ code: 'IRIS_PROVIDER_NETWORK_CLOSED', stage: 'validate', category: 'invalid_request', acceptance: 'not_accepted'
7
+ });
8
+ }
9
+
10
+ export function assertProviderNetwork(network) {
11
+ if (!network || typeof network.fetch !== 'function' || typeof network.run !== 'function') {
12
+ throw new TypeError('network 需要实例级 fetch 和完整操作 run');
13
+ }
14
+ return network;
15
+ }
16
+
17
+ /** The owner drains Core/registry operations before disposing this client. */
18
+ export function createProviderNetworkClient({ fetchPort, close } = {}) {
19
+ if (fetchPort !== undefined && typeof fetchPort !== 'function') throw new TypeError('fetchPort 必须是函数');
20
+ if (close !== undefined && typeof close !== 'function') throw new TypeError('close 必须是函数');
21
+ const active = new Set();
22
+ let state = 'open', disposal, cleanupFailed = false;
23
+ const snapshot = () => Object.freeze({ state, activeOperations: active.size, cleanupFailed });
24
+
25
+ // Used inside run: closing must still permit later pages, uploads and body reads.
26
+ const fetch = (...args) => {
27
+ if (state === 'closed') return Promise.reject(closed());
28
+ return (fetchPort || inheritedProviderFetch)(...args);
29
+ };
30
+ function run(action) {
31
+ if (typeof action !== 'function') throw new TypeError('网络操作必须是函数');
32
+ if (state !== 'open') return Promise.reject(closed());
33
+ const operation = Promise.resolve().then(() => action(fetch));
34
+ active.add(operation);
35
+ void operation.then(() => active.delete(operation), () => active.delete(operation));
36
+ return operation;
37
+ }
38
+ function dispose() {
39
+ if (!disposal) {
40
+ state = 'closing';
41
+ disposal = (async () => {
42
+ await Promise.allSettled([...active]);
43
+ try { await close?.(); }
44
+ catch (error) { cleanupFailed = true; throw error; }
45
+ finally { state = 'closed'; }
46
+ return snapshot();
47
+ })();
48
+ }
49
+ return disposal;
50
+ }
51
+ return Object.freeze({ fetch, run, dispose, snapshot });
52
+ }
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
  /** 供应商媒体协议判断与 DashScope 凭据边界。 */
3
3
  import { comfyWorkflowDigest } from './comfyui-workflow.js';
4
+ import { providerCredentialValue, providerCredentialStatus } from './provider-credentials.js';
4
5
 
5
6
  const DASH_SCOPE_HOST = /^dashscope(?:-[a-z0-9-]+)?\.aliyuncs\.com$/i;
6
7
  const REGIONAL_HOST = /^cn-hongkong\.dashscope\.aliyuncs\.com$/i;
@@ -114,7 +115,7 @@ export function dashscopeApiBase(baseUrl) {
114
115
 
115
116
  /** 无认证模式不发送保留的旧凭据;未声明模式保持 bearer 兼容。 */
116
117
  export function providerApiKey(provider) {
117
- return provider?.auth === 'none' ? '' : String(provider?.apiKey || '');
118
+ return provider?.auth === 'none' ? '' : providerCredentialValue(provider);
118
119
  }
119
120
 
120
121
  /** 配置管理与 headless 候选链共用可用性门,未声明 enabled 时默认启用。 */
@@ -122,7 +123,7 @@ export function isConfiguredProvider(provider) {
122
123
  return Boolean(provider && provider.enabled !== false
123
124
  && typeof provider.id === 'string' && provider.id.trim()
124
125
  && providerMediaBaseUrl(provider)
125
- && (provider.auth === 'none' || (typeof provider.apiKey === 'string' && provider.apiKey.trim())));
126
+ && (provider.auth === 'none' || providerCredentialStatus(provider) === 'ready'));
126
127
  }
127
128
 
128
129
  /** 已知需要其他接口的语音系列;自定义模型仍以明确配置和实测为准。 */
@@ -1,6 +1,9 @@
1
1
  import { defineProviderAdapter } from './provider-adapter.js';
2
2
  import { unsupportedProtocolError, providerApiKey } from './provider-protocol.js';
3
3
  import { checkedPluginDependency, assertPluginDependencyMatches } from './plugin-contract.js';
4
+ import { createProviderNetworkClient } from './provider-network.js';
5
+ import { createProviderContext, markProviderContextAdapter } from './provider-context.js';
6
+ import { resolveProviderCredential } from './provider-credentials.js';
4
7
 
5
8
  export const PROVIDER_EXTENSION_CONTRACT_VERSION = 0;
6
9
  const ID = /^[a-z][a-z0-9._-]{0,79}$/;
@@ -40,7 +43,8 @@ function checkedProtocol(value) {
40
43
  /** One registry owns its factory instances, active calls and serial asynchronous cleanup. */
41
44
  export async function createExtensionRegistry(factories) {
42
45
  const protocols = new Map(), extensions = [], active = new Set();
43
- let state = 'loading', disposal, cleanupFailed = false;
46
+ let state = 'loading', disposal, cleanupFailed = false, inherited;
47
+ const inheritedNetwork = () => inherited ||= createProviderNetworkClient();
44
48
 
45
49
  function dispose() {
46
50
  if (!disposal) {
@@ -48,6 +52,7 @@ export async function createExtensionRegistry(factories) {
48
52
  disposal = (async () => {
49
53
  await Promise.allSettled([...active]);
50
54
  const errors = [];
55
+ try { await inherited?.dispose(); } catch (error) { errors.push(error); }
51
56
  for (const extension of [...extensions].reverse()) {
52
57
  try { await extension.dispose?.(); } catch (error) { errors.push(error); }
53
58
  }
@@ -101,7 +106,10 @@ export async function createExtensionRegistry(factories) {
101
106
  if (state !== 'active') throw closed();
102
107
  const descriptor = protocols.get(id);
103
108
  if (!descriptor) throw unsupportedProtocolError(id);
104
- const value = descriptor.createAdapter({ ...provider, apiKey: providerApiKey(provider) }, options);
109
+ const author = descriptor.dependency ? createProviderContext(provider, options, inheritedNetwork) : undefined;
110
+ const resolved = author ? resolveProviderCredential(provider) : provider;
111
+ const connection = { ...resolved, apiKey: providerApiKey(resolved) };
112
+ const value = descriptor.createAdapter(author ? Object.freeze(connection) : connection, author?.context || options);
105
113
  if (!value || typeof value.then === 'function') throw invalid('createAdapter 必须同步返回 Adapter');
106
114
  if (value.pluginDependency !== undefined) assertPluginDependencyMatches(descriptor.dependency, value.pluginDependency);
107
115
  const adapter = defineProviderAdapter({ ...value,
@@ -109,9 +117,11 @@ export async function createExtensionRegistry(factories) {
109
117
  unsupported: Object.fromEntries(Object.entries(value.unsupported || {}).map(([name, reason]) => [name, reason?.reason ?? reason])) });
110
118
  if (adapter.id !== provider.id || adapter.protocol !== id || adapter.capabilities.some(cap =>
111
119
  !descriptor.capabilities.some(known => CAPS[known] === cap))) throw invalid('Adapter 身份或能力与注册协议不一致');
112
- return Object.freeze({ ...adapter, operations: Object.freeze(Object.fromEntries(Object.entries(adapter.operations)
113
- .map(([name, action]) => [name, name === 'mapError' ? action : wrap(action)]))),
114
- ...(adapter.prepareInput ? { prepareInput: wrap(adapter.prepareInput) } : {}) });
120
+ const operation = (name, action) => wrap(author ? (...args) => author.invoke(name, action, args) : action);
121
+ const result = Object.freeze({ ...adapter, operations: Object.freeze(Object.fromEntries(Object.entries(adapter.operations)
122
+ .map(([name, action]) => [name, name === 'mapError' ? action : operation(name, action)]))),
123
+ ...(adapter.prepareInput ? { prepareInput: operation('prepareInput', adapter.prepareInput) } : {}) });
124
+ return author ? markProviderContextAdapter(result) : result;
115
125
  },
116
126
  dispose
117
127
  });
@@ -3,6 +3,16 @@ import { ProviderContractError } from './provider-contract.js';
3
3
  export const PROVIDER_RESPONSE_LIMITS = Object.freeze({ json: 2 * 1024 * 1024, mediaJson: 128 * 1024 * 1024,
4
4
  image: 64 * 1024 * 1024, audio: 128 * 1024 * 1024, transcript: 8 * 1024 * 1024, video: 1024 * 1024 * 1024 });
5
5
 
6
+ /** Core materialization uses the same limits as provider downloads, including cached bytes. */
7
+ export function assertProviderMediaSize(size, capability) {
8
+ const maxBytes = { image: PROVIDER_RESPONSE_LIMITS.image, video: PROVIDER_RESPONSE_LIMITS.video,
9
+ tts: PROVIDER_RESPONSE_LIMITS.audio, music: PROVIDER_RESPONSE_LIMITS.audio, transcribe: PROVIDER_RESPONSE_LIMITS.transcript }[capability];
10
+ if (maxBytes === undefined) throw new TypeError('未知的媒体交付能力:' + capability);
11
+ if (size > maxBytes) throw Object.assign(new ProviderContractError('供应商媒体超过允许的大小(' + maxBytes + ' 字节)', {
12
+ stage: 'download', category: 'protocol', acceptance: 'accepted', retryable: false
13
+ }), { code: 'IRIS_PROVIDER_RESPONSE_TOO_LARGE' });
14
+ }
15
+
6
16
  /** Count decoded response bytes as well as Content-Length: chunked and compressed
7
17
  * responses cannot bypass the limit. Iterator cancellation closes the body. */
8
18
  export async function* boundedProviderBody(response, { maxBytes, stage = 'response', acceptance = 'unknown' }) {
@@ -1,9 +1,10 @@
1
- // Owner-only editing boundary. No I/O, secrets in projections, or implicit model authorization.
1
+ // Owner-only editing boundary. No file I/O, secrets in projections, or implicit model authorization.
2
2
  import { CAPABILITIES } from './capability.js';
3
3
  import { providerModels, modelPool, functionPolicy, savedAssignmentRefs, resolvePoolModel } from './models.js';
4
4
  import { BUILTIN_PROVIDER_PROTOCOLS, normalizeMediaEndpoint, normalizeImageProtocol, normalizeMusicProtocol, selectMediaProtocol, providerForModelBinding, isConfiguredProvider } from './provider-protocol.js';
5
5
  import { clearModelCapabilityHealth, resetHealthOnCriticalChange } from './provider-health.js';
6
6
  import { checkedComfyWorkflow, projectComfyWorkflow } from './comfyui-workflow.js';
7
+ import { validateProviderCredential, providerCredentialStatus, scrubProviderSecrets } from './provider-credentials.js';
7
8
 
8
9
  const CAPS = Object.values(CAPABILITIES);
9
10
  const fail = message => { throw Object.assign(new Error(message), { code: 'IRIS_CONFIG_INPUT_INVALID' }); };
@@ -28,7 +29,8 @@ export function projectProviderSettings(catalog, revision, registry) {
28
29
  providers: catalog.providers.map(provider => ({ id: provider.id, name: provider.name || provider.id,
29
30
  baseUrl: provider.baseUrl || '', mediaBaseUrl: provider.mediaBaseUrl || '',
30
31
  enabled: provider.enabled !== false, auth: provider.auth === 'none' ? 'none' : 'bearer',
31
- hasCredential: Boolean(provider.apiKey), configured: isConfiguredProvider(provider),
32
+ hasCredential: providerCredentialStatus(provider) === 'ready', configured: isConfiguredProvider(provider),
33
+ ...(provider.apiKeyEnv !== undefined ? { apiKeyEnv: provider.apiKeyEnv, credentialStatus: providerCredentialStatus(provider) } : {}),
32
34
  mediaProtocol: selectMediaProtocol(provider).mediaProtocol,
33
35
  models: providerModels(provider).map(model => ({ id: model.id, ref: model.ref, capabilities: model.capabilities,
34
36
  imageProtocol: model.imageProtocol || 'auto', musicProtocol: model.musicProtocol || '',
@@ -44,17 +46,13 @@ export function projectProviderSettings(catalog, revision, registry) {
44
46
  return [cap, { ...policy, models: savedAssignmentRefs(pool, cap, policy.models) }];
45
47
  })) };
46
48
  // A mistakenly pasted key in a display field must not become a key read-back route.
47
- const keys = catalog.providers.map(provider => provider.apiKey).filter(key => typeof key === 'string' && key);
48
- const scrub = value => typeof value === 'string' ? keys.reduce((s, key) => s.split(key).join('[redacted]'), value)
49
- : Array.isArray(value) ? value.map(scrub) : value && typeof value === 'object'
50
- ? Object.fromEntries(Object.entries(value).map(([key, item]) => [key, scrub(item)])) : value;
51
- return scrub(result);
49
+ return scrubProviderSecrets(result, catalog.providers);
52
50
  }
53
51
 
54
52
  export function applyProviderSettings(catalog, input, registry) {
55
53
  object(input, ['expectedRevision', 'provider', 'credential', 'assignments']);
56
54
  object(input.provider, ['id', 'name', 'baseUrl', 'mediaBaseUrl', 'mediaProtocol', 'enabled', 'auth', 'models']);
57
- object(input.credential, ['action', 'value']);
55
+ object(input.credential, ['action', 'value', 'name']);
58
56
  const value = input.provider, id = string(value.id, 160);
59
57
  const matches = catalog.providers.filter(item => item.id === id);
60
58
  if (matches.length > 1) fail('供应商 ID 重复,请在本机核对');
@@ -68,9 +66,13 @@ export function applyProviderSettings(catalog, input, registry) {
68
66
  catch (_) { fail('地址必须是无凭据、查询或片段的 HTTP(S) URL'); }
69
67
  if (!protocols(registry).some(item => item.id === next.mediaProtocol) && previous?.mediaProtocol !== next.mediaProtocol) fail('协议未注册,请先显式加载对应扩展');
70
68
  const credential = input.credential;
71
- if (!['keep', 'replace', 'clear'].includes(credential.action) || credential.action !== 'replace' && Object.hasOwn(credential, 'value')) fail('密钥操作必须是 keep、replace 或 clear');
72
- if (credential.action === 'replace') next.apiKey = string(credential.value, 8192);
73
- if (credential.action === 'clear') delete next.apiKey;
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);
74
76
  if (!Array.isArray(value.models) || value.models.length > 256) fail('单次最多保存 256 个模型');
75
77
  const existing = providerModels(previous || {});
76
78
  const entries = Array.isArray(previous?.models) ? previous.models.map(model => typeof model === 'string' ? { id: model } : { ...model })
@@ -102,7 +104,7 @@ export function applyProviderSettings(catalog, input, registry) {
102
104
  resetHealthOnCriticalChange(previousConnection, next, next);
103
105
  for (const model of existing) {
104
106
  const updated = next.models.find(entry => entry.id === model.id);
105
- const connectionChanged = ['apiKey', 'baseUrl', 'mediaBaseUrl', 'auth', 'mediaProtocol'].some(key => previousConnection[key] !== next[key]);
107
+ const connectionChanged = ['apiKey', 'apiKeyEnv', 'baseUrl', 'mediaBaseUrl', 'auth', 'mediaProtocol'].some(key => previousConnection[key] !== next[key]);
106
108
  for (const cap of model.capabilities) if (connectionChanged || !updated.capabilities?.includes(cap)
107
109
  || cap === 'image-gen' && JSON.stringify(previous.models?.find(entry => entry?.id === model.id)?.comfyWorkflow) !== JSON.stringify(updated.comfyWorkflow)
108
110
  || cap === 'image-gen' && providerForModelBinding(previous, model.id, cap).mediaProtocol !== providerForModelBinding(next, model.id, cap).mediaProtocol
@@ -20,6 +20,7 @@ import {
20
20
  recordCoreAttemptResult,
21
21
  recordCoreCancelResult,
22
22
  recordCoreDownloadCompleted,
23
+ recordCoreNetworkRoute,
23
24
  recordCorePollResult,
24
25
  recordCoreWatchError,
25
26
  recoverCoreTask,
@@ -38,6 +39,8 @@ import { createDeliveryCache, newDeliveryPayloadPath, readDeliveryCache,
38
39
  import { readFlowRun, flowFail, flowStep } from './flow-store.js';
39
40
  import { normalizeGenerationInput } from './generation-input.js';
40
41
  import { assertPluginDependencyMatches } from './plugin-contract.js';
42
+ import { checkedNetworkRequirement, sameNetworkRequirement, createNetworkAttemptBudget } from './network-policy.js';
43
+ import { assertProviderMediaSize } from './provider-response.js';
41
44
 
42
45
  export const PROVIDER_TASK_RUNNER_VERSION = 0;
43
46
 
@@ -48,7 +51,7 @@ export const PROVIDER_TASK_RUNNER_VERSION = 0;
48
51
  */
49
52
  export const DELIVERY_PROFILES = Object.freeze({
50
53
  image: Object.freeze({ kind: 'generated-image', mediaTypes: Object.freeze(['image/png', 'image/jpeg', 'image/webp']), defaultMediaType: 'image/png' }),
51
- 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' }),
52
55
  // TTS 同步完成型:submit 内 completed → 同一次调用直接交付(无 remote 长轮询)。
53
56
  tts: Object.freeze({ kind: 'generated-audio', mediaTypes: Object.freeze(['audio/mpeg', 'audio/wav']), defaultMediaType: 'audio/mpeg' }),
54
57
  music: Object.freeze({ kind: 'generated-audio', mediaTypes: Object.freeze(['audio/mpeg', 'audio/wav']), defaultMediaType: 'audio/mpeg' }),
@@ -57,6 +60,20 @@ export const DELIVERY_PROFILES = Object.freeze({
57
60
  });
58
61
  const runtimeTaskGates = new WeakMap();
59
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
+
60
77
  export class ProviderTaskRunnerError extends Error {
61
78
  constructor(code, message) {
62
79
  super(message);
@@ -76,12 +93,16 @@ function checkAdapterForTask(task, adapter) {
76
93
  throw new ProviderTaskRunnerError('IRIS_PROVIDER_TASK_IDENTITY_MISMATCH', 'Provider Adapter 与 Task 已持久化身份不一致');
77
94
  }
78
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
+ }
79
100
  return adapter;
80
101
  }
81
102
 
82
103
  function normalizedCandidates(capability, candidates) {
83
104
  if (!Array.isArray(candidates) || !candidates.length) throw new TypeError('至少需要一个 Provider 候选');
84
- return candidates.map((candidate, index) => {
105
+ const normalized = candidates.map((candidate, index) => {
85
106
  const adapter = candidate?.adapter;
86
107
  const model = String(candidate?.model || '').trim();
87
108
  const parsed = parseModelRef(model);
@@ -95,9 +116,18 @@ function normalizedCandidates(capability, candidates) {
95
116
  }
96
117
  if (candidate.prepareInput !== undefined && typeof candidate.prepareInput !== 'function') throw new TypeError('候选 prepareInput 必须是函数');
97
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
+ }
98
123
  return { adapter, model, providerModel: parsed.modelId, providerBinding: candidate.providerBinding, pluginDependency: adapter.pluginDependency, selectionReason, prepareInput: candidate.prepareInput,
124
+ ...(networkRequirement ? { networkRequirement } : {}),
99
125
  ...(candidate.supportsImageEdit !== undefined ? { supportsImageEdit: candidate.supportsImageEdit } : {}) };
100
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;
101
131
  }
102
132
 
103
133
  function stagingFile(dataRoot) {
@@ -121,7 +151,20 @@ async function generatedImageMediaType(bytes) {
121
151
  return mediaType;
122
152
  }
123
153
 
124
- 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) {
125
168
  beginCoreDelivery(dataRoot, taskId);
126
169
  const artifactIds = [];
127
170
  const stagedFiles = [];
@@ -147,22 +190,25 @@ async function deliverArtifacts(dataRoot, taskId, adapter, remoteArtifacts, sign
147
190
  }
148
191
  const target = stagingFile(dataRoot);
149
192
  stagedFiles.push({ target, mediaType });
150
- const downloaded = await invokeProviderOperation(adapter, 'download', {
193
+ const downloaded = await invokeNetwork(runtime, adapter, 'download', {
151
194
  capability: task.capability,
152
195
  artifact: remote,
153
196
  targetPath: target,
154
197
  signal
155
- }, { signal, taskId });
198
+ }, networkOperationContext(runtime, dataRoot, taskId, { signal, attemptId: task.activeAttemptId }));
156
199
  const stat = fs.lstatSync(target);
157
200
  if (!stat.isFile() || stat.isSymbolicLink() || stat.size !== downloaded.bytes) {
158
201
  throw Object.assign(new Error('Provider 物化结果与文件证据不一致'), {
159
202
  stage: 'download', category: 'local_io', acceptance: 'accepted'
160
203
  });
161
204
  }
205
+ assertProviderMediaSize(stat.size, task.capability);
162
206
  const staged = stagedFiles.at(-1);
163
207
  if (task.capability === 'image') {
164
208
  staged.bytes = fs.readFileSync(target);
165
209
  staged.mediaType = await generatedImageMediaType(staged.bytes);
210
+ } else if (['tts', 'music'].includes(task.capability)) {
211
+ staged.mediaType = generatedAudioMediaType(target);
166
212
  }
167
213
  }
168
214
  recordCoreDownloadCompleted(dataRoot, taskId);
@@ -188,30 +234,34 @@ async function deliverArtifacts(dataRoot, taskId, adapter, remoteArtifacts, sign
188
234
  }
189
235
  }
190
236
 
191
- async function observeWithinRoot(dataRoot, taskId, adapter, signal) {
237
+ async function observeWithinRoot(runtime, dataRoot, taskId, adapter, signal) {
192
238
  const before = inspectCoreTask(dataRoot, taskId);
193
239
  checkAdapterForTask(before, adapter);
194
240
  activateCoreWatch(dataRoot, taskId);
195
241
  let result;
196
242
  try {
197
- result = await invokeProviderOperation(adapter, 'poll', {
243
+ result = await invokeNetwork(runtime, adapter, 'poll', {
198
244
  capability: before.capability,
199
245
  remoteTaskId: before.remoteTaskId,
200
246
  signal
201
- }, { signal, taskId });
247
+ }, networkOperationContext(runtime, dataRoot, taskId, { signal, attemptId: before.activeAttemptId }));
202
248
  } catch (error) {
203
249
  return recordCoreWatchError(dataRoot, taskId, error);
204
250
  }
205
- const cachedFlowResult = Boolean(before.flowBinding && result.kind === 'succeeded');
206
- 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
+ }
207
256
  const recorded = recordCorePollResult(dataRoot, taskId, result, { deliveryCache: cachedFlowResult });
208
257
  if (result.kind !== 'succeeded') return recorded;
209
- if (cachedFlowResult) return deliverCachedArtifacts(dataRoot, taskId, adapter, signal);
210
- 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);
211
261
  }
212
262
 
213
263
  /** 同步结果按已提交缓存补交付;已下载项不再访问 Provider,稳定 Artifact ID 防重复。 */
214
- async function deliverCachedArtifacts(dataRoot, taskId, adapter, signal) {
264
+ async function deliverCachedArtifacts(runtime, dataRoot, taskId, adapter, signal) {
215
265
  beginCoreDelivery(dataRoot, taskId);
216
266
  try {
217
267
  const task = inspectCoreTask(dataRoot, taskId);
@@ -228,16 +278,17 @@ async function deliverCachedArtifacts(dataRoot, taskId, adapter, signal) {
228
278
  }
229
279
  checkAdapterForTask(task, adapter);
230
280
  const target = newDeliveryPayloadPath(dataRoot, cache);
231
- const downloaded = await invokeProviderOperation(adapter, 'download', {
281
+ const downloaded = await invokeNetwork(runtime, adapter, 'download', {
232
282
  capability: task.capability, artifact: item.source, targetPath: target, signal
233
- }, { signal, taskId });
283
+ }, networkOperationContext(runtime, dataRoot, taskId, { signal, attemptId: task.activeAttemptId }));
234
284
  const stat = fs.lstatSync(target);
235
285
  if (!stat.isFile() || stat.isSymbolicLink() || !stat.size || stat.size !== downloaded.bytes) {
236
286
  throw new Error('Provider 物化结果与文件证据不一致');
237
287
  }
288
+ assertProviderMediaSize(stat.size, task.capability);
238
289
  const mediaType = task.capability === 'image'
239
290
  ? await generatedImageMediaType(fs.readFileSync(target))
240
- : declaredMediaType;
291
+ : ['tts', 'music'].includes(task.capability) ? generatedAudioMediaType(target) : declaredMediaType;
241
292
  if (!profile.mediaTypes.includes(mediaType)) throw new Error('交付媒体类型不在 Profile 白名单');
242
293
  recordDeliveryPayload(dataRoot, cache, index, target, mediaType);
243
294
  }
@@ -250,7 +301,7 @@ async function deliverCachedArtifacts(dataRoot, taskId, adapter, signal) {
250
301
  const sourcePath = readDeliveryPayload(dataRoot, cache, index);
251
302
  const mediaType = task.capability === 'image'
252
303
  ? await generatedImageMediaType(fs.readFileSync(sourcePath))
253
- : (item.payload.mediaType || profile.defaultMediaType);
304
+ : ['tts', 'music'].includes(task.capability) ? generatedAudioMediaType(sourcePath) : (item.payload.mediaType || profile.defaultMediaType);
254
305
  if (!profile.mediaTypes.includes(mediaType)) throw new Error('交付媒体类型不在 Profile 白名单');
255
306
  const artifact = adoptCoreArtifactFile(dataRoot, {
256
307
  artifactId: item.artifactId, sourcePath, createdAt: cache.createdAt,
@@ -272,10 +323,11 @@ async function deliverCachedArtifacts(dataRoot, taskId, adapter, signal) {
272
323
  */
273
324
  export function createProviderTaskRunner(runtime) {
274
325
  checkRuntime(runtime);
275
- let activeTasks = runtimeTaskGates.get(runtime);
326
+ const gate = runtime.taskGate || runtime;
327
+ let activeTasks = runtimeTaskGates.get(gate);
276
328
  if (!activeTasks) {
277
329
  activeTasks = new Set();
278
- runtimeTaskGates.set(runtime, activeTasks);
330
+ runtimeTaskGates.set(gate, activeTasks);
279
331
  }
280
332
 
281
333
  async function runExclusive(taskId, operation, callback) {
@@ -299,6 +351,8 @@ export function createProviderTaskRunner(runtime) {
299
351
  if (signal.aborted) throw new ProviderTaskRunnerError('IRIS_PROVIDER_ABORTED', '提交已取消');
300
352
  const capability = String(input.capability || '').trim();
301
353
  let candidates = normalizedCandidates(capability, input.candidates);
354
+ const networkRequirement = candidates[0].networkRequirement;
355
+ const networkBudget = createNetworkAttemptBudget();
302
356
  let providerInput = input.providerInput ?? {};
303
357
  if (providerInput.workflow_parameters !== undefined && candidates.some(candidate => candidate.adapter.protocol !== 'comfyui')) {
304
358
  throw new ProviderTaskRunnerError('IRIS_COMFYUI_PARAMETERS_INVALID', '工作流参数仅用于明确的 ComfyUI 模板');
@@ -306,15 +360,16 @@ export function createProviderTaskRunner(runtime) {
306
360
  let flow;
307
361
  if (input.flowRunId !== undefined) {
308
362
  flow = readFlowRun(dataRoot, input.flowRunId);
309
- const audio = isAudioRecipe(flow.recipe), normalized = audio ? normalizeAudioFlowInput(flow.recipe, flow.input) : null;
310
- const expected = audio ? Object.fromEntries(Object.entries(normalized).filter(([key]) => key !== 'model_ref')) : normalizeGenerationInput('image', flow.input).providerInput;
311
- const expectedCapability = audio ? flow.recipe === 'speech/v0' ? 'tts' : 'transcribe' : 'image';
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';
312
367
  if (capability !== expectedCapability || !flowStep(flow).claimed || input.sourceArtifactId !== flow.source?.artifactId
313
368
  || Object.keys(providerInput).length !== Object.keys(expected).length
314
369
  || Object.keys(expected).some(key => key === 'workflow_parameters'
315
370
  ? JSON.stringify(providerInput[key]) !== JSON.stringify(expected[key]) : providerInput[key] !== expected[key])
316
371
  || candidates.length !== 1 || candidates[0].model !== flow.input.model_ref
317
- || candidates[0].providerBinding !== flow.providerBinding) {
372
+ || candidates[0].providerBinding !== flow.providerBinding || !sameNetworkRequirement(networkRequirement, flow.networkRequirement)) {
318
373
  flowFail('IRIS_FLOW_INPUT_CONFLICT', '提交与流程已保存的输入或模型绑定冲突');
319
374
  }
320
375
  }
@@ -324,9 +379,15 @@ export function createProviderTaskRunner(runtime) {
324
379
  providerInput = { ...providerInput, image };
325
380
  }
326
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));
327
384
  const task = flow ? claimFlowTask(dataRoot, flow.id)
328
- : createCoreTask(dataRoot, { capability, retriedFrom: input.retriedFrom, sourceArtifactId: input.sourceArtifactId });
385
+ : createCoreTask(dataRoot, { capability, retriedFrom: input.retriedFrom, sourceArtifactId: input.sourceArtifactId,
386
+ ...(networkRequirement ? { networkRequirement } : {}) });
329
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 {
330
391
  const completedResults = new Map();
331
392
  const wrapped = candidates.map(({ adapter, model, providerModel, prepareInput }) => ({
332
393
  id: adapter.id,
@@ -334,17 +395,22 @@ export function createProviderTaskRunner(runtime) {
334
395
  adapter,
335
396
  submit: async (providerInput, context) => {
336
397
  if (prepareInput) {
337
- try { providerInput = await prepareInput({ providerInput, signal }); }
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
+ }
338
403
  catch (error) { throw new ProviderContractError((capability === 'video' ? '视频' : '音频') + '输入准备失败:' + (error.safeMessage || error.message), {
339
404
  ...providerErrorRecord(error), stage: error.stage || 'prepare', category: error.category || 'local_io', acceptance: 'not_accepted'
340
405
  }); }
341
406
  }
342
407
  if (signal.aborted) throw new ProviderContractError('提交已取消', { stage: 'prepare', category: 'aborted', acceptance: 'not_accepted' });
343
- const result = await invokeProviderOperation(
408
+ const result = await invokeNetwork(
409
+ runtime,
344
410
  adapter,
345
411
  'submit',
346
412
  { capability, model: providerModel, input: providerInput, signal },
347
- { ...context, signal, taskId: task.id }
413
+ networkOperationContext(runtime, dataRoot, task.id, { ...context, signal, attemptId: context.attempt.id, networkBudget })
348
414
  );
349
415
  if (result.kind === 'completed') completedResults.set(context.attempt.id, result.artifacts);
350
416
  return result;
@@ -354,9 +420,12 @@ export function createProviderTaskRunner(runtime) {
354
420
  signal,
355
421
  beforeAttempt: async (attempt) => {
356
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));
357
425
  return beginCoreAttempt(dataRoot, task.id, {
358
426
  ...attempt,
359
427
  ...(candidate.providerBinding !== undefined ? { providerBinding: candidate.providerBinding } : {}),
428
+ ...(candidate.networkRequirement ? { networkRequirement: candidate.networkRequirement } : {}),
360
429
  ...(candidate.pluginDependency ? { pluginDependency: candidate.pluginDependency } : {}),
361
430
  ...(candidate.selectionReason !== undefined ? { selectionReason: candidate.selectionReason } : {})
362
431
  });
@@ -385,7 +454,7 @@ export function createProviderTaskRunner(runtime) {
385
454
  } else if (submitted.result.kind === 'completed') {
386
455
  return Object.freeze({
387
456
  taskId: task.id,
388
- task: await deliverCachedArtifacts(dataRoot, task.id, submitted.candidate.adapter, signal),
457
+ task: await deliverCachedArtifacts(runtime, dataRoot, task.id, submitted.candidate.adapter, signal),
389
458
  attempts: submitted.attempts
390
459
  });
391
460
  }
@@ -394,6 +463,7 @@ export function createProviderTaskRunner(runtime) {
394
463
  task: inspectCoreTask(dataRoot, task.id),
395
464
  attempts: submitted.attempts
396
465
  });
466
+ } finally { activeTasks.delete(task.id); }
397
467
  });
398
468
  }
399
469
 
@@ -408,7 +478,7 @@ export function createProviderTaskRunner(runtime) {
408
478
  checkAdapterForTask(current, adapter);
409
479
  if (current.watchState === 'active') recoverCoreTask(dataRoot, taskId);
410
480
  }
411
- return observeWithinRoot(dataRoot, taskId, adapter, signal);
481
+ return observeWithinRoot(runtime, dataRoot, taskId, adapter, signal);
412
482
  });
413
483
  }
414
484
 
@@ -417,14 +487,14 @@ export function createProviderTaskRunner(runtime) {
417
487
  const task = inspectCoreTask(dataRoot, taskId);
418
488
  if (adapter || !task.deliveryCache) checkAdapterForTask(task, adapter);
419
489
  prepareCoreRedelivery(dataRoot, taskId);
420
- if (task.deliveryCache) return deliverCachedArtifacts(dataRoot, taskId, adapter, signal);
490
+ if (task.deliveryCache) return deliverCachedArtifacts(runtime, dataRoot, taskId, adapter, signal);
421
491
  let result;
422
492
  try {
423
- result = await invokeProviderOperation(adapter, 'poll', {
493
+ result = await invokeNetwork(runtime, adapter, 'poll', {
424
494
  capability: task.capability,
425
495
  remoteTaskId: task.remoteTaskId,
426
496
  signal
427
- }, { signal, taskId, redelivery: true });
497
+ }, networkOperationContext(runtime, dataRoot, taskId, { signal, attemptId: task.activeAttemptId, redelivery: true }));
428
498
  } catch (error) {
429
499
  return failCoreDelivery(dataRoot, taskId, error);
430
500
  }
@@ -434,7 +504,7 @@ export function createProviderTaskRunner(runtime) {
434
504
  { stage: 'download', category: 'protocol', acceptance: 'accepted' }
435
505
  ));
436
506
  }
437
- return deliverArtifacts(dataRoot, taskId, adapter, result.artifacts, signal);
507
+ return deliverArtifacts(runtime, dataRoot, taskId, adapter, result.artifacts, signal);
438
508
  });
439
509
  }
440
510
 
@@ -446,23 +516,26 @@ export function createProviderTaskRunner(runtime) {
446
516
  if (!hasProviderOperation(adapter, 'cancel')) {
447
517
  return recordCoreCancelResult(dataRoot, taskId, { kind: 'not_supported' });
448
518
  }
449
- const result = await invokeProviderOperation(adapter, 'cancel', {
519
+ const result = await invokeNetwork(runtime, adapter, 'cancel', {
450
520
  capability: task.capability,
451
521
  remoteTaskId: task.remoteTaskId,
452
522
  signal
453
- }, { signal, taskId });
523
+ }, networkOperationContext(runtime, dataRoot, taskId, { signal, attemptId: task.activeAttemptId }));
454
524
  return recordCoreCancelResult(dataRoot, taskId, result);
455
525
  });
456
526
  }
457
527
 
458
528
  function recover(taskId, adapter) {
459
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);
460
533
  const recovered = recoverCoreTask(dataRoot, taskId);
461
534
  if (recovered.acceptance === 'accepted' && recovered.remoteTaskId
462
535
  && recovered.outcome === 'none'
463
536
  && ['accepted', 'running'].includes(recovered.phase)) {
464
537
  checkAdapterForTask(recovered, adapter);
465
- return observeWithinRoot(dataRoot, taskId, adapter, signal);
538
+ return observeWithinRoot(runtime, dataRoot, taskId, adapter, signal);
466
539
  }
467
540
  return recovered;
468
541
  });
@@ -475,6 +548,9 @@ export function createProviderTaskRunner(runtime) {
475
548
  observe,
476
549
  redeliver,
477
550
  cancel,
478
- recover
551
+ recover,
552
+ assertIdle(taskId) {
553
+ if (activeTasks.has(taskId)) throw new ProviderTaskRunnerError('IRIS_PROVIDER_TASK_BUSY', '该 Task 已有生命周期操作正在执行');
554
+ }
479
555
  });
480
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
+ }