@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
@@ -12,10 +12,12 @@ const ASSETS = new Map([
12
12
  ['/creation.js', ['creation.js', 'text/javascript; charset=utf-8']],
13
13
  ['/gallery.js', ['gallery.js', 'text/javascript; charset=utf-8']],
14
14
  ['/plugin-panel.js', ['plugin-panel.js', 'text/javascript; charset=utf-8']],
15
+ ['/plugin-status.js', ['plugin-status.js', 'text/javascript; charset=utf-8']],
15
16
  ['/plugin-surface.css', ['plugin-surface.css', 'text/css; charset=utf-8']],
16
17
  ['/flow-view.js', ['flow-view.js', 'text/javascript; charset=utf-8']],
17
18
  ['/connections.js', ['connections.js', 'text/javascript; charset=utf-8']],
18
19
  ['/providers.js', ['providers.js', 'text/javascript; charset=utf-8']],
20
+ ['/network.js', ['network.js', 'text/javascript; charset=utf-8']],
19
21
  ['/comfyui.js', ['comfyui.js', 'text/javascript; charset=utf-8']],
20
22
  ['/appearance.js', ['appearance.js', 'text/javascript; charset=utf-8']],
21
23
  ['/styles.css', ['styles.css', 'text/css; charset=utf-8']],
package/lib/mcp-http.js CHANGED
@@ -4,10 +4,47 @@ import { createCoreServiceClient } from './core-service-client.js';
4
4
  import { createMcpServerFactory, reportMcpClientInfo } from './mcp-server.js';
5
5
  import { projectedMcpClient } from './core-connections.js';
6
6
 
7
+ const MAX_REQUEST_BYTES = 9 * 1024 * 1024, MAX_ACTIVE_MESSAGES = 4;
8
+
9
+ async function readBody(request) {
10
+ if (Number(request.headers['content-length']) > MAX_REQUEST_BYTES) return { tooLarge: true };
11
+ const chunks = []; let size = 0;
12
+ // Keep the Node stream available to discard unread bytes after an early rejection.
13
+ // An unconsumed Web-stream bridge can pause that drain at its own high-water mark.
14
+ for await (const chunk of request.iterator({ destroyOnReturn: false })) {
15
+ size += chunk.length;
16
+ if (size > MAX_REQUEST_BYTES) return { tooLarge: true };
17
+ chunks.push(chunk);
18
+ }
19
+ return { tooLarge: false, text: new TextDecoder().decode(Buffer.concat(chunks, size)) };
20
+ }
21
+
22
+ function rejectRequest(request, response, status, code, message, rpcCode = -32000) {
23
+ if (response.destroyed) return Promise.resolve();
24
+ const bytes = Buffer.from(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: rpcCode, message, data: { code } } }));
25
+ return new Promise(resolve => {
26
+ let cleanupRequest = () => {};
27
+ const finish = () => { cleanupRequest(); response.off('finish', finish); response.off('close', finish); resolve(); };
28
+ response.once('finish', finish); response.once('close', finish);
29
+ response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': bytes.length,
30
+ 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer',
31
+ Connection: 'close', ...(status === 429 ? { 'Retry-After': '1' } : {}) });
32
+ if (!request.complete && !request.destroyed) {
33
+ // As in the Core HTTP edge, deliver the error before closing an unread upload.
34
+ // Discard without buffering; the HTTP server's existing timeouts bound the drain.
35
+ const end = () => { cleanupRequest(); response.end(); };
36
+ cleanupRequest = () => { request.off('end', end); request.off('close', end); };
37
+ request.once('end', end); request.once('close', end);
38
+ if (request.method === 'HEAD') response.flushHeaders(); else response.write(bytes);
39
+ request.resume();
40
+ } else response.end(request.method === 'HEAD' ? undefined : bytes);
41
+ });
42
+ }
43
+
7
44
  /** Standard HTTP MCP edge. Core owns every business write; this edge only owns its local connections. */
8
45
  export function createMcpHttpGateway({ getOrigin, authenticate, availableScopes, version }) {
9
- const entries = new Map(), lifetime = new AbortController();
10
- let state = 'new', createMcpHandler, disposal, starting;
46
+ const entries = new Map(), jobs = new Set(), lifetime = new AbortController();
47
+ let state = 'new', createMcpHandler, disposal, starting, activeMessages = 0;
11
48
  function start() {
12
49
  if (state !== 'new') return Promise.reject(Object.assign(new Error('MCP HTTP 入口只能显式启动一次'), { code: 'IRIS_MCP_CLOSED' }));
13
50
  state = 'starting';
@@ -32,7 +69,7 @@ export function createMcpHttpGateway({ getOrigin, authenticate, availableScopes,
32
69
  const server = factory();
33
70
  if (context.authInfo?.extra?.irisObservation) context.authInfo.extra.irisObservation.server = server;
34
71
  return server;
35
- }, { legacy: 'stateless', maxRequestBodySize: 9 * 1024 * 1024,
72
+ }, { legacy: 'stateless', maxRequestBodySize: MAX_REQUEST_BYTES,
36
73
  maxSubscriptions: 4, onerror: () => console.error('[iris] MCP HTTP protocol error; private input omitted') });
37
74
  return { client, handler };
38
75
  } catch (error) {
@@ -43,34 +80,69 @@ export function createMcpHttpGateway({ getOrigin, authenticate, availableScopes,
43
80
  entries.set(grant.connectionId, entry);
44
81
  return entry.ready;
45
82
  }
46
- async function handle(request, response, accessToken) {
83
+ async function handleRequest(request, response, accessToken) {
47
84
  if (state !== 'open') throw Object.assign(new Error('MCP HTTP 服务正在关闭'), { code: 'IRIS_API_UNAVAILABLE' });
48
85
  const grant = authenticate(accessToken);
49
- const { handler, client } = await entryFor(grant, accessToken);
50
86
  const disconnected = new AbortController();
51
87
  const onClose = () => { if (!response.writableFinished) disconnected.abort(); };
52
88
  response.once('close', onClose);
53
89
  const signal = AbortSignal.any([disconnected.signal, lifetime.signal]);
90
+ const onAbort = () => { request.destroy(); response.destroy(); };
91
+ signal.addEventListener('abort', onAbort, { once: true });
92
+ let reserved = 0;
54
93
  try {
94
+ if (activeMessages >= MAX_ACTIVE_MESSAGES) return await rejectRequest(request, response, 429,
95
+ 'IRIS_MCP_HTTP_BUSY', 'MCP HTTP is busy; retry after an active response finishes.');
96
+ activeMessages++; reserved = 1;
55
97
  const headers = new Headers();
56
98
  for (const [name, value] of Object.entries(request.headers)) {
57
99
  if (Array.isArray(value)) for (const item of value) headers.append(name, item);
58
100
  else if (value !== undefined) headers.set(name, value);
59
101
  }
102
+ let parsedBody;
103
+ if (request.method === 'POST') {
104
+ const body = await readBody(request);
105
+ if (body.tooLarge) return await rejectRequest(request, response, 413,
106
+ 'IRIS_MCP_HTTP_BODY_TOO_LARGE', 'MCP request body exceeds the 9 MiB limit.');
107
+ try { parsedBody = JSON.parse(body.text); }
108
+ catch (error) {
109
+ if (!(error instanceof SyntaxError)) throw error;
110
+ return await rejectRequest(request, response, 400, 'IRIS_MCP_HTTP_PARSE_ERROR', 'Parse error: invalid JSON.', -32700);
111
+ }
112
+ // One HTTP batch can otherwise fan out into many complete base64 media responses.
113
+ const count = Array.isArray(parsedBody) ? Math.max(1, parsedBody.length) : 1;
114
+ if (count > MAX_ACTIVE_MESSAGES) return await rejectRequest(request, response, 400,
115
+ 'IRIS_MCP_HTTP_BATCH_TOO_LARGE', 'MCP HTTP batches must not exceed 4 messages.', -32600);
116
+ if (activeMessages + count - reserved > MAX_ACTIVE_MESSAGES) return await rejectRequest(request, response, 429,
117
+ 'IRIS_MCP_HTTP_BUSY', 'MCP HTTP is busy; retry after an active response finishes.');
118
+ activeMessages += count - reserved; reserved = count;
119
+ }
120
+ signal.throwIfAborted();
60
121
  const input = new Request(getOrigin() + '/mcp', { method: request.method, headers, signal,
61
- ...(['GET', 'HEAD'].includes(request.method) ? {} : { body: Readable.toWeb(request), duplex: 'half' }) });
122
+ ...(['GET', 'HEAD', 'POST'].includes(request.method) ? {} : { body: Readable.toWeb(request), duplex: 'half' }) });
123
+ const { handler, client } = await entryFor(grant, accessToken);
124
+ signal.throwIfAborted();
62
125
  const observation = {};
63
- const result = await handler.fetch(input, { authInfo: { token: accessToken, clientId: grant.connectionId,
126
+ const result = await handler.fetch(input, { ...(request.method === 'POST' ? { parsedBody } : {}),
127
+ authInfo: { token: accessToken, clientId: grant.connectionId,
64
128
  scopes: grant.scopes, extra: { irisObservation: observation } } });
65
129
  if (result.ok && observation.server) void reportMcpClientInfo(client, projectedMcpClient(observation.server.server.getClientVersion()), 'http');
66
130
  if (signal.aborted || response.destroyed) { await result.body?.cancel(); return; }
67
131
  response.writeHead(result.status, { ...Object.fromEntries(result.headers), 'Cache-Control': 'no-store',
68
132
  'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer' });
69
- if (result.body && request.method !== 'HEAD') await pipeline(Readable.fromWeb(result.body), response);
133
+ if (result.body && request.method !== 'HEAD') await pipeline(Readable.fromWeb(result.body), response, { signal });
70
134
  else response.end();
71
135
  } catch (error) {
72
136
  if (!signal.aborted) throw error;
73
- } finally { response.off('close', onClose); }
137
+ } finally {
138
+ activeMessages -= reserved;
139
+ response.off('close', onClose); signal.removeEventListener('abort', onAbort);
140
+ }
141
+ }
142
+ function handle(request, response, accessToken) {
143
+ const job = handleRequest(request, response, accessToken); jobs.add(job);
144
+ void job.then(() => jobs.delete(job), () => jobs.delete(job));
145
+ return job;
74
146
  }
75
147
  function dispose() {
76
148
  if (disposal) return disposal;
@@ -82,6 +154,7 @@ export function createMcpHttpGateway({ getOrigin, authenticate, availableScopes,
82
154
  if (starting) await Promise.allSettled([starting]);
83
155
  const ready = await Promise.allSettled(pending.map(entry => entry.ready));
84
156
  const results = await Promise.allSettled([...disconnecting, ...ready.filter(result => result.status === 'fulfilled').map(result => result.value.handler.close())]);
157
+ await Promise.allSettled([...jobs]);
85
158
  entries.clear(); state = 'closed';
86
159
  const failed = results.find(result => result.status === 'rejected');
87
160
  if (failed) throw failed.reason;
package/lib/mcp-server.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { bridgeToolsForScopes, callCoreBridgeTool } from './core-bridge-tools.js';
2
2
  import { projectedMcpClient } from './core-connections.js';
3
3
  import { coreConnectionDiagnostic } from './core-connection-diagnostics.js';
4
+ import { VIDEO_TYPES } from './video-flow-input.js';
4
5
 
5
6
  /** Client labels are optional observations. Their failure must not delay a Core
6
7
  * command or discard its receipt; command authorization remains in Core. */
@@ -13,7 +14,7 @@ export async function reportMcpClientInfo(client, clientInfo, transport) {
13
14
 
14
15
  /** One official-SDK tool factory for both standard transports and protocol eras. */
15
16
  export async function createMcpServerFactory({ client, version, transport = 'stdio' }) {
16
- const [{ McpServer }, { z }] = await Promise.all([import('@modelcontextprotocol/server'), import('zod')]);
17
+ const [{ McpServer, ResourceTemplate, ResourceNotFoundError }, { z }] = await Promise.all([import('@modelcontextprotocol/server'), import('zod')]);
17
18
  const snapshot = client.snapshot();
18
19
  const tools = bridgeToolsForScopes(snapshot.scopes, snapshot.capabilities, snapshot.tools, snapshot.pluginOperations);
19
20
  const factory = () => {
@@ -22,6 +23,23 @@ export async function createMcpServerFactory({ client, version, transport = 'std
22
23
  if (transport === 'stdio') server.server.oninitialized = () => {
23
24
  void reportMcpClientInfo(client, projectedMcpClient(server.server.getClientVersion()), transport);
24
25
  };
26
+ if (tools.some(tool => tool.name === 'iris_core_video')) {
27
+ server.registerResource('iris-video', new ResourceTemplate('iris://video/{artifactId}', { list: undefined }),
28
+ { title: 'Saved Iris video', description: 'Original saved MP4/WebM, up to 32 MiB. Every read requires the current Core video grant.' },
29
+ async (uri, variables, context) => {
30
+ const artifactId = variables.artifactId;
31
+ try {
32
+ if (typeof artifactId !== 'string' || !/^artifact_[a-f0-9]{24}$/.test(artifactId) || uri.href !== 'iris://video/' + artifactId) {
33
+ throw Object.assign(new Error('Invalid video reference'), { code: 'IRIS_CONNECTION_INPUT_INVALID' });
34
+ }
35
+ const { video } = await callCoreBridgeTool(client, 'iris_core_video', { artifactId }, { signal: context.mcpReq.signal });
36
+ return { contents: [{ uri: uri.href, mimeType: video.mediaType, blob: video.bytes.toString('base64') }] };
37
+ } catch (error) {
38
+ const diagnostic = coreConnectionDiagnostic(error, { stage: 'tool' });
39
+ throw new ResourceNotFoundError('iris://video', diagnostic.code + ': ' + diagnostic.message + ' ' + diagnostic.action);
40
+ }
41
+ });
42
+ }
25
43
  for (const tool of tools) server.registerTool(tool.name, {
26
44
  description: tool.description, inputSchema: z.fromJSONSchema(tool.parameters),
27
45
  annotations: { readOnlyHint: tool.scope === 'read', destructiveHint: tool.scope === 'stop',
@@ -34,6 +52,10 @@ export async function createMcpServerFactory({ client, version, transport = 'std
34
52
  { type: 'text', text: JSON.stringify({ artifactId: result.image.artifactId, bytes: result.image.bytes.length }) }] };
35
53
  if (result.audio) return { content: [{ type: 'audio', data: result.audio.bytes.toString('base64'), mimeType: result.audio.mediaType },
36
54
  { type: 'text', text: JSON.stringify({ artifactId: result.audio.artifactId, bytes: result.audio.bytes.length }) }] };
55
+ if (result.video) return { content: [{ type: 'resource_link', uri: 'iris://video/' + result.video.artifactId,
56
+ name: 'iris-' + result.video.artifactId + '.' + VIDEO_TYPES[result.video.mediaType], mimeType: result.video.mediaType, size: result.video.bytes.length },
57
+ { type: 'text', text: JSON.stringify({ artifactId: result.video.artifactId, bytes: result.video.bytes.length,
58
+ note: 'Read this saved resource with resources/read. Playback depends on the client; this reference does not prove it was watched.' }) }] };
37
59
  return { content: [{ type: 'text', text: JSON.stringify(result.value) }], structuredContent: result.value };
38
60
  } catch (error) {
39
61
  const diagnostic = coreConnectionDiagnostic(error, { stage: 'tool', references: args });
@@ -0,0 +1,194 @@
1
+ // Instance-owned admission. No durable work loop and no automatic request replay.
2
+ import { AsyncResource } from 'node:async_hooks';
3
+ import { performance } from 'node:perf_hooks';
4
+ import { parseModelRef } from './models.js';
5
+ import { coreRemoteTaskIdentities, CoreTaskError } from './core-tasks.js';
6
+ import { checkedSchedulingPolicy, schedulingError } from './scheduling-policy.js';
7
+ import { retryAfterDelay } from './provider-health.js';
8
+
9
+ export function createMediaScheduler(input) {
10
+ const policy = checkedSchedulingPolicy(input), http = [], scopes = new Map(), changes = new Set(), remoteVersions = new Map();
11
+ let timer, closed = false, active = 0, sent = 0, completed = 0, pumping = false;
12
+ function selected(context) {
13
+ const parsed = parseModelRef(context.modelRef || context.attempt?.model || '');
14
+ const providerId = context.providerId || parsed?.providerId;
15
+ const provider = policy.providers.find(value => value.providerId === providerId);
16
+ const model = provider?.models.find(value => value.modelId === parsed?.modelId);
17
+ return [['global', policy], ...(provider ? [[JSON.stringify([providerId]), provider]] : []),
18
+ ...(model ? [[JSON.stringify([providerId, parsed.modelId]), model]] : [])];
19
+ }
20
+ function state(key) {
21
+ if (!scopes.has(key)) scopes.set(key, { active: 0, times: [], cooldownUntil: 0 });
22
+ return scopes.get(key);
23
+ }
24
+ function remove(job) { const index = http.indexOf(job); if (index >= 0) http.splice(index, 1); job.signal?.removeEventListener('abort', job.abort); }
25
+ function pump() {
26
+ if (pumping) return;
27
+ pumping = true;
28
+ try { drain(); } finally { pumping = false; }
29
+ }
30
+ function drain() {
31
+ clearTimeout(timer); timer = undefined;
32
+ let next = Infinity;
33
+ for (const job of [...http]) {
34
+ const now = performance.now();
35
+ if (now >= job.deadline) {
36
+ remove(job); job.fail(schedulingError('IRIS_SCHEDULER_QUEUE_TIMEOUT', 'HTTP 派发等候超时;本次请求未发送')); continue;
37
+ }
38
+ const relevant = selected(job.context).map(([key, limits]) => ({ ...limits, state: state(key) }));
39
+ let ready = true, at = now + cooldownDelay(job.context);
40
+ for (const entry of relevant) {
41
+ entry.state.times = entry.state.times.filter(time => now - time < 1000);
42
+ if (entry.httpConcurrency && entry.state.active >= entry.httpConcurrency) ready = false;
43
+ if (entry.requestsPerSecond && entry.state.times.length >= entry.requestsPerSecond) at = Math.max(at, entry.state.times[0] + 1000);
44
+ at = Math.max(at, entry.state.cooldownUntil);
45
+ }
46
+ if (!ready || at > now) {
47
+ job.context.onSchedulingState?.(cooldownDelay(job.context) > 0 ? 'cooldown' : at > now ? 'rate_limit' : 'http_queue');
48
+ next = Math.min(next, job.deadline, at > now ? at : Infinity); continue;
49
+ }
50
+ remove(job); active++; sent++;
51
+ for (const entry of relevant) { entry.state.active++; entry.state.times.push(now); }
52
+ let released = false;
53
+ const release = () => {
54
+ if (released) return; released = true; active--; completed++;
55
+ for (const entry of relevant) entry.state.active--;
56
+ pump();
57
+ };
58
+ // Timestamp and the actual dispatcher invocation share this stack, not a reservation promise.
59
+ job.context.onSchedulingState?.('http_running');
60
+ job.start(release);
61
+ }
62
+ if (http.length && Number.isFinite(next)) timer = setTimeout(pump, Math.max(1, Math.ceil(next - performance.now())));
63
+ }
64
+ function dispatch(context, start, fail, signal) {
65
+ if (closed) { fail(schedulingError('IRIS_SCHEDULER_CLOSED', '调度实例已关闭')); return; }
66
+ if (signal?.aborted) { fail(signal.reason); return; }
67
+ if (http.length >= policy.queueLimit) { fail(schedulingError('IRIS_SCHEDULER_QUEUE_FULL', 'HTTP 等候队列已满;本次请求未发送')); return; }
68
+ const resource = new AsyncResource('IrisHttpDispatch');
69
+ const job = { context, signal, deadline: performance.now() + policy.queueTimeoutMs,
70
+ start(release) { try { resource.runInAsyncScope(start, undefined, release); } finally { resource.emitDestroy(); } },
71
+ fail(error) { try { resource.runInAsyncScope(fail, undefined, error); } finally { resource.emitDestroy(); } } };
72
+ job.abort = () => { remove(job); job.fail(signal.reason); pump(); };
73
+ signal?.addEventListener('abort', job.abort, { once: true });
74
+ http.push(job); pump();
75
+ }
76
+ function cooldown(context, delay) {
77
+ if (!Number.isFinite(delay) || delay < 0) return;
78
+ // A model-specific 429 blocks future requests for that model, never unrelated providers.
79
+ const entries = selected(context), target = entries.at(-1);
80
+ // Unconfigured provider/model limits still need an isolated cooldown scope.
81
+ const parsed = parseModelRef(context.modelRef || context.attempt?.model || '');
82
+ const key = parsed ? JSON.stringify([parsed.providerId, parsed.modelId]) : context.providerId ? JSON.stringify([context.providerId]) : target[0];
83
+ state(key).cooldownUntil = Math.max(state(key).cooldownUntil, performance.now() + delay);
84
+ changed(); pump();
85
+ }
86
+ function cooldownDelay(context) {
87
+ const parsed = parseModelRef(context.modelRef || context.attempt?.model || '');
88
+ const ids = [JSON.stringify([context.providerId || parsed?.providerId]), ...(parsed ? [JSON.stringify([parsed.providerId, parsed.modelId])] : [])];
89
+ return Math.max(0, ...ids.map(key => (scopes.get(key)?.cooldownUntil || 0) - performance.now()));
90
+ }
91
+ function changed(dataRoot) {
92
+ if (dataRoot) {
93
+ if (!changes.size) return;
94
+ let version;
95
+ try { version = JSON.stringify([...remoteCounts(dataRoot)].sort(([a], [b]) => a.localeCompare(b))); }
96
+ catch (error) {
97
+ if (!(error instanceof CoreTaskError)) throw error;
98
+ // Wake the admissions that need this count, without turning a read error
99
+ // into a failed release of an unrelated writer turn.
100
+ for (const reject of [...changes]) reject(error);
101
+ return;
102
+ }
103
+ if (remoteVersions.get(dataRoot) === version) return;
104
+ remoteVersions.set(dataRoot, version);
105
+ }
106
+ for (const resolve of [...changes]) resolve();
107
+ }
108
+ function change(signal, timeoutMs) {
109
+ return new Promise((resolve, reject) => {
110
+ let timeout;
111
+ const done = (error) => { clearTimeout(timeout); changes.delete(wake); signal?.removeEventListener('abort', abort); error ? reject(error) : resolve(); };
112
+ const wake = error => done(error), abort = () => done(signal.reason);
113
+ if (signal?.aborted) { reject(signal.reason); return; }
114
+ changes.add(wake); signal?.addEventListener('abort', abort, { once: true });
115
+ timeout = setTimeout(wake, Math.max(1, timeoutMs));
116
+ });
117
+ }
118
+ function remoteCounts(dataRoot) {
119
+ const result = new Map([['global', 0]]);
120
+ for (const task of coreRemoteTaskIdentities(dataRoot)) {
121
+ result.set('global', result.get('global') + 1);
122
+ const parsed = parseModelRef(task.modelRef || '');
123
+ if (parsed) for (const key of [JSON.stringify([parsed.providerId]), JSON.stringify([parsed.providerId, parsed.modelId])]) result.set(key, (result.get(key) || 0) + 1);
124
+ }
125
+ return result;
126
+ }
127
+ async function beforeSubmit(runtime, dataRoot, context, readyAt) {
128
+ const deadline = performance.now() + policy.queueTimeoutMs;
129
+ for (;;) {
130
+ if (closed) throw schedulingError('IRIS_SCHEDULER_CLOSED', '调度实例已关闭');
131
+ runtime.signal.throwIfAborted();
132
+ const counts = remoteCounts(dataRoot), full = selected(context).some(([key, limits]) => limits.remoteLimit && (counts.get(key) || 0) >= limits.remoteLimit);
133
+ const delay = Math.max(cooldownDelay(context), (readyAt?.() || 0) - Date.now());
134
+ if (!full && delay <= 0) return;
135
+ const left = deadline - performance.now();
136
+ if (left <= 0) throw schedulingError('IRIS_SCHEDULER_QUEUE_TIMEOUT', full
137
+ ? '远端未结束任务名额等候超时;未发送新的提交,请查询原任务'
138
+ : '模型冷却等候超时;未发送新的提交');
139
+ // A bounded wait is released by another Core commit or the known cooldown/deadline.
140
+ await runtime.networkWait(() => change(runtime.signal, Math.min(left, delay > 0 ? delay : left)), full ? 'remote_capacity' : 'cooldown');
141
+ }
142
+ }
143
+ function dispatcher(target, context, signal) {
144
+ let dispatches = 0;
145
+ return { get dispatches() { return dispatches; }, dispatch(options, handler) {
146
+ let abortRemote, finished = false, release;
147
+ const queued = new AbortController();
148
+ const combined = signal ? AbortSignal.any([signal, queued.signal]) : queued.signal;
149
+ const end = () => { if (finished) return false; finished = true; release?.(); return true; };
150
+ const fail = error => { if (end()) handler.onError(error); };
151
+ // Give fetch its abort port while still queued. The real dispatcher supplies the socket abort later.
152
+ handler.onConnect(error => { queued.abort(error); abortRemote?.(error); });
153
+ dispatch(context, done => {
154
+ release = done;
155
+ const wrapped = {
156
+ onConnect(abort) { abortRemote = abort; if (combined.aborted) abort(combined.reason); },
157
+ onHeaders(status, headers, resume, statusText) {
158
+ if (status === 429) {
159
+ let retry;
160
+ for (let i = 0; i < headers.length; i += 2) if (String(headers[i]).toLowerCase() === 'retry-after') retry = String(headers[i + 1]);
161
+ cooldown(context, retryAfterDelay(retry));
162
+ }
163
+ return handler.onHeaders(status, headers, resume, statusText);
164
+ },
165
+ onData: chunk => handler.onData(chunk),
166
+ onComplete(trailers) { if (end()) handler.onComplete(trailers); },
167
+ onError: fail,
168
+ ...(handler.onResponseStarted ? { onResponseStarted: () => handler.onResponseStarted() } : {}),
169
+ ...(handler.onBodySent ? { onBodySent: (...args) => handler.onBodySent(...args) } : {}),
170
+ ...(handler.onUpgrade ? { onUpgrade(...args) { end(); return handler.onUpgrade(...args); } } : {}),
171
+ ...(handler.onRequestUpgrade ? { onRequestUpgrade(...args) { end(); return handler.onRequestUpgrade(...args); } } : {})
172
+ };
173
+ try { dispatches++; target.dispatch(options, wrapped); } catch (error) { fail(error); }
174
+ }, fail, combined);
175
+ return true;
176
+ } };
177
+ }
178
+ function snapshot(dataRoot) {
179
+ let remote;
180
+ if (dataRoot) {
181
+ try { remote = { remoteOutstanding: remoteCounts(dataRoot).get('global') }; }
182
+ catch (error) {
183
+ if (!(error instanceof CoreTaskError)) throw error;
184
+ remote = { remoteOutstanding: null, remoteError: error.code };
185
+ }
186
+ }
187
+ return Object.freeze({ enabled: true, httpActive: active, httpQueued: http.length, requestsStarted: sent, requestsCompleted: completed,
188
+ ...remote,
189
+ limits: { httpConcurrency: policy.httpConcurrency, requestsPerSecond: policy.requestsPerSecond, remoteLimit: policy.remoteLimit,
190
+ queueLimit: policy.queueLimit, queueTimeoutMs: policy.queueTimeoutMs } });
191
+ }
192
+ return Object.freeze({ policy, dispatch, dispatcher, beforeSubmit, changed, cooldown, snapshot,
193
+ dispose() { closed = true; clearTimeout(timer); for (const job of [...http]) { remove(job); job.fail(schedulingError('IRIS_SCHEDULER_CLOSED', '调度实例已关闭')); } changed(); } });
194
+ }
@@ -5,12 +5,21 @@ import { createConfiguredProviderAdapter } from './provider-adapters.js';
5
5
  import { invokeProviderOperation } from './provider-adapter.js';
6
6
  import { redactProviderMessage } from './provider-contract.js';
7
7
  import { MODEL_DIRECTORY_TTL_MS, MODEL_DIRECTORY_RETRY_MS, currentModelDirectory, modelDirectoryBinding } from './model-directory.js';
8
+ import { assertDiscoveryCredentials } from './provider-credentials.js';
9
+ import { assertProviderNetwork } from './provider-network.js';
10
+ import { providerNetworkRequirement, sameNetworkRequirement, networkPolicyError } from './network-policy.js';
8
11
 
9
12
  const pending = new Map();
10
13
  const registryPending = new WeakMap();
14
+ const networkPending = new WeakMap();
11
15
 
12
16
  /** 只有完整发现成功才替换快照;相同连接合并网络读取,各调用方保存自己的配置。 */
13
- export async function refreshModelDirectory(provider, { force = false, signal, save, discover = listModels, now = Date.now(), registry } = {}) {
17
+ export async function refreshModelDirectory(provider, { force = false, signal, save, discover = listModels, now = Date.now(), registry, network } = {}) {
18
+ if (network !== undefined) assertProviderNetwork(network);
19
+ const requirement = providerNetworkRequirement(provider);
20
+ if (requirement && (!network || !sameNetworkRequirement(requirement, network.networkRequirement) || provider.id !== network.providerId)) {
21
+ throw networkPolicyError('IRIS_NETWORK_CONTEXT_REQUIRED', '模型目录需要供应商匹配的网络上下文,不会使用默认出口');
22
+ }
14
23
  if (!providerProtocolDescriptor(selectMediaProtocol(provider).mediaProtocol, registry)) {
15
24
  return { refreshed: false, error: '当前协议未提供模型目录发现' };
16
25
  }
@@ -24,27 +33,35 @@ export async function refreshModelDirectory(provider, { force = false, signal, s
24
33
  }
25
34
  signal?.throwIfAborted();
26
35
  let calls = pending;
36
+ let registries = registryPending;
37
+ if (network) {
38
+ if (!networkPending.has(network)) networkPending.set(network, { calls: new Map(), registries: new WeakMap() });
39
+ const scoped = networkPending.get(network);
40
+ calls = scoped.calls; registries = scoped.registries;
41
+ }
27
42
  if (registry) {
28
- if (!registryPending.has(registry)) registryPending.set(registry, new Map());
29
- calls = registryPending.get(registry);
43
+ if (!registries.has(registry)) registries.set(registry, new Map());
44
+ calls = registries.get(registry);
30
45
  discover = async ({ signal, timeoutMs }) => (await invokeProviderOperation(
31
- createConfiguredProviderAdapter(provider, { registry }), 'discover', { signal, timeoutMs })).models;
46
+ createConfiguredProviderAdapter(provider, { registry, ...(network ? { network } : {}) }), 'discover', { signal, timeoutMs })).models;
32
47
  }
33
- const binding = modelDirectoryBinding(provider);
48
+ const binding = modelDirectoryBinding(provider), key = providerApiKey(provider);
34
49
  let job = calls.get(binding);
35
50
  if (!job) {
36
51
  const budget = AbortSignal.timeout(15000);
37
- job = discover({ key: providerApiKey(provider), baseUrl: providerMediaBaseUrl(provider),
38
- signal: signal ? AbortSignal.any([signal, budget]) : budget, timeoutMs: 15000 });
52
+ const request = { key, baseUrl: providerMediaBaseUrl(provider),
53
+ signal: signal ? AbortSignal.any([signal, budget]) : budget, timeoutMs: 15000 };
54
+ job = network && !registry
55
+ ? network.run(fetchPort => discover({ ...request, fetchPort })) : discover(request);
39
56
  calls.set(binding, job);
40
57
  job.finally(() => { if (calls.get(binding) === job) calls.delete(binding); }).catch(() => {});
41
58
  }
42
59
  let discovered;
43
60
  try {
44
61
  discovered = await job;
62
+ assertDiscoveryCredentials(discovered, key);
45
63
  } catch (error) {
46
64
  signal?.throwIfAborted();
47
- const key = providerApiKey(provider);
48
65
  const message = String(error?.message || error);
49
66
  const directory = { version: 1, binding, ...(previous || {}), failedAt: new Date(now).toISOString(),
50
67
  lastError: redactProviderMessage(key ? message.split(key).join('[REDACTED]') : message) };
@@ -1,3 +1,4 @@
1
+ import { inheritedProviderFetch } from './provider-network.js';
1
2
  import { ProviderContractError } from './provider-contract.js';
2
3
  import { readProviderText, PROVIDER_RESPONSE_LIMITS } from './provider-response.js';
3
4
 
@@ -25,10 +26,11 @@ export function musicArtifactsFromResponse(body) {
25
26
  return artifacts;
26
27
  }
27
28
 
28
- export async function generateMusic({ key, baseUrl, model, prompt, timeoutMs = 300000, signal }) {
29
+ export async function generateMusic({ key, baseUrl, model, prompt, timeoutMs = 300000, signal, fetchPort = inheritedProviderFetch }) {
29
30
  const timeout = AbortSignal.timeout(timeoutMs);
30
- const response = await fetch(String(baseUrl).replace(/\/+$/, '') + '/chat/completions', {
31
+ const response = await fetchPort(String(baseUrl).replace(/\/+$/, '') + '/chat/completions', {
31
32
  method: 'POST',
33
+ redirect: 'error',
32
34
  headers: { 'Content-Type': 'application/json', ...(key ? { Authorization: `Bearer ${key}` } : {}) },
33
35
  signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
34
36
  body: JSON.stringify({ model, stream: false, messages: [{ role: 'user', content: prompt }] })
@@ -0,0 +1,123 @@
1
+ // Owner CLI resources and explicit bindings. Editing never sends network traffic.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import crypto from 'node:crypto';
5
+ import { atomicCreatePrivate, atomicWritePrivate, ensurePrivateDir, privateSibling } from './private-storage.js';
6
+ import { NETWORK_CONFIG_VERSION, NETWORK_ROUTING_CONFIG_VERSION, NETWORK_CONFIG_MAX_BYTES, networkConfigFail as fail, networkFields, normalizeNetworkNode, normalizeNetworkPool, validateNetworkConfig, projectNetworkConfig } from './network-config.js';
7
+
8
+ const digest = bytes => crypto.createHash('sha256').update(bytes).digest('hex');
9
+ function absolute(file) {
10
+ if (typeof file !== 'string' || !path.isAbsolute(file)) fail('IRIS_NETWORK_CONFIG_INVALID', 'network-config 必须是显式绝对路径');
11
+ }
12
+ function io(action) {
13
+ try { return action(); }
14
+ catch (error) {
15
+ if (typeof error.code !== 'string' || typeof error.syscall !== 'string') throw error;
16
+ const detail = /^[A-Z][A-Z0-9_]{0,39}$/.test(error.code) ? `(${error.code})` : '';
17
+ fail('IRIS_NETWORK_CONFIG_IO', `网络配置文件操作失败${detail},请检查目录权限、可用空间与文件状态;原文件和已有备份不会自动清理`);
18
+ }
19
+ }
20
+ export function readNetworkConfig(file) {
21
+ absolute(file);
22
+ const info = io(() => fs.lstatSync(file));
23
+ if (!info.isFile() || info.isSymbolicLink()) fail('IRIS_NETWORK_CONFIG_INVALID', 'network-config 必须是普通文件,不能使用符号链接');
24
+ if (process.platform !== 'win32' && (info.mode & 0o077) !== 0) fail('IRIS_NETWORK_CONFIG_PERMISSIONS', 'network-config 权限过宽,请限制为仅当前用户可读写');
25
+ if (info.size > NETWORK_CONFIG_MAX_BYTES) fail('IRIS_NETWORK_CONFIG_INVALID', 'network-config 不能超过1 MiB');
26
+ const bytes = io(() => fs.readFileSync(file));
27
+ if (bytes.length > NETWORK_CONFIG_MAX_BYTES) fail('IRIS_NETWORK_CONFIG_INVALID', 'network-config 不能超过1 MiB');
28
+ let parsed;
29
+ try { parsed = JSON.parse(bytes.toString('utf8')); }
30
+ catch (_) { fail('IRIS_NETWORK_CONFIG_INVALID', 'network-config 不是有效 JSON;请修正原文件,不会自动回退'); }
31
+ return { config: validateNetworkConfig(parsed), bytes, revision: digest(bytes) };
32
+ }
33
+
34
+ const READS = ['show', 'check', 'nodes.list', 'pools.list', 'bindings.list'];
35
+ const WRITES = ['nodes.add', 'nodes.set', 'nodes.remove', 'pools.add', 'pools.set', 'pools.remove', 'bindings.set', 'bindings.remove'];
36
+ export function executeNetworkConfigCommand(file, command, input = {}, { signal, env = process.env } = {}) {
37
+ absolute(file); signal?.throwIfAborted();
38
+ if (['init', 'routing.init', 'routing.copy'].includes(command)) {
39
+ const copy = command === 'routing.copy', routing = command !== 'init';
40
+ networkFields(input, copy ? ['source'] : [], copy ? ['source'] : []);
41
+ const source = copy ? readNetworkConfig(input.source).config : undefined;
42
+ const config = { schemaVersion: routing ? NETWORK_ROUTING_CONFIG_VERSION : NETWORK_CONFIG_VERSION,
43
+ configId: crypto.randomUUID(), generation: 1, nodes: source?.nodes || [], pools: source?.pools || [], ...(routing ? { bindings: [] } : {}) };
44
+ io(() => {
45
+ ensurePrivateDir(path.dirname(file));
46
+ try { atomicCreatePrivate(file, JSON.stringify(config, null, 2) + '\n'); }
47
+ catch (error) { if (error.code === 'EEXIST') fail('IRIS_NETWORK_CONFIG_EXISTS', '网络配置已存在,初始化不会覆盖;请先读取并核对'); throw error; }
48
+ });
49
+ const saved = readNetworkConfig(file);
50
+ return { command, created: true, ...projectNetworkConfig(saved.config, saved.revision, env) };
51
+ }
52
+ if (!READS.includes(command) && !WRITES.includes(command)) fail('IRIS_NETWORK_CONFIG_INVALID', '不支持的网络配置命令');
53
+ const before = readNetworkConfig(file);
54
+ if (command.startsWith('bindings.') && before.config.schemaVersion !== NETWORK_ROUTING_CONFIG_VERSION) {
55
+ fail('IRIS_NETWORK_CONFIG_VERSION', '绑定只用于显式新建的 v2 网络配置;可用 routing.copy 另存新文件,原文件不会自动迁移');
56
+ }
57
+ if (READS.includes(command)) {
58
+ networkFields(input, []);
59
+ return { command, ...(command === 'check' ? { valid: true, networkTested: false } : {}), ...projectNetworkConfig(before.config, before.revision, env) };
60
+ }
61
+ const [group, verb] = command.split('.'), field = group === 'nodes' ? 'node' : 'pool';
62
+ const fields = group === 'bindings' ? ['expectedRevision', 'catalogId', 'providerId', ...(verb === 'set' ? ['poolId'] : ['confirm'])]
63
+ : verb === 'remove' ? ['expectedRevision', 'id', 'confirm'] : ['expectedRevision', field];
64
+ networkFields(input, fields, fields);
65
+ if (input.expectedRevision !== before.revision) fail('IRIS_NETWORK_CONFIG_CHANGED', '网络配置已变化,请重新读取后核对编辑');
66
+ const next = structuredClone(before.config), records = next[group];
67
+ if (group === 'bindings') {
68
+ const index = records.findIndex(binding => binding.catalogId === input.catalogId && binding.providerId === input.providerId);
69
+ if (verb === 'remove') {
70
+ if (input.confirm !== true) fail('IRIS_NETWORK_CONFIG_INVALID', '删除绑定需要明确 confirm:true');
71
+ if (index < 0) fail('IRIS_NETWORK_CONFIG_NOT_FOUND', '所选绑定不存在');
72
+ records.splice(index, 1);
73
+ } else {
74
+ const binding = { catalogId: input.catalogId, providerId: input.providerId, poolId: input.poolId };
75
+ if (index < 0) records.push(binding); else records[index] = binding;
76
+ }
77
+ } else if (verb === 'remove') {
78
+ if (input.confirm !== true) fail('IRIS_NETWORK_CONFIG_INVALID', '删除资源需要明确 confirm:true');
79
+ const index = records.findIndex(record => record.id === input.id);
80
+ if (index < 0) fail('IRIS_NETWORK_CONFIG_NOT_FOUND', '所选网络资源不存在');
81
+ if (group === 'nodes' && next.pools.some(pool => pool.candidates.some(candidate => candidate.kind === 'node' && candidate.nodeId === input.id))) {
82
+ fail('IRIS_NETWORK_CONFIG_IN_USE', '节点仍被池引用;请先显式编辑或移除引用,停用的池也保留引用');
83
+ }
84
+ if (group === 'pools' && next.bindings?.some(binding => binding.poolId === input.id)) {
85
+ fail('IRIS_NETWORK_CONFIG_IN_USE', '池仍被供应商绑定引用;请先显式解除绑定,停用的池也保留引用');
86
+ }
87
+ records.splice(index, 1);
88
+ } else {
89
+ const supplied = input[field];
90
+ networkFields(supplied, group === 'nodes' ? ['id', 'label', 'enabled', 'protocol', 'host', 'port', 'auth'] : ['id', 'label', 'enabled', 'mode', 'candidates'], ['id']);
91
+ const index = records.findIndex(record => record.id === supplied.id);
92
+ if (verb === 'add' && index !== -1) fail('IRIS_NETWORK_CONFIG_EXISTS', '同类网络资源 ID 已存在,不能覆盖');
93
+ if (verb === 'set' && index === -1) fail('IRIS_NETWORK_CONFIG_NOT_FOUND', '所选网络资源不存在');
94
+ const normalize = group === 'nodes' ? normalizeNetworkNode : normalizeNetworkPool;
95
+ const record = normalize(verb === 'set' ? { ...records[index], ...supplied } : supplied);
96
+ if (verb === 'set') records[index] = record; else records.push(record);
97
+ }
98
+ const validated = validateNetworkConfig(next);
99
+ const changed = JSON.stringify(validated) !== JSON.stringify(before.config);
100
+ let fd;
101
+ try { fd = io(() => {
102
+ try { return fs.openSync(file + '.lock', 'wx', 0o600); }
103
+ catch (error) { if (error.code === 'EEXIST') fail('IRIS_NETWORK_CONFIG_BUSY', '网络配置正在修改或留有未核查的锁;不会自动删除或抢占,请核查写入进程'); throw error; }
104
+ });
105
+ // Revalidate the schema before CAS; a newer writer must never be downgraded.
106
+ const current = readNetworkConfig(file);
107
+ if (current.revision !== before.revision) fail('IRIS_NETWORK_CONFIG_CHANGED', '网络配置在编辑期间变化,请重新读取后核对');
108
+ signal?.throwIfAborted();
109
+ if (!changed) return { command, changed: false, ...projectNetworkConfig(current.config, current.revision, env) };
110
+ if (validated.generation === Number.MAX_SAFE_INTEGER) fail('IRIS_NETWORK_CONFIG_INVALID', '网络配置修订已超出支持范围');
111
+ validated.generation++;
112
+ const encoded = Buffer.from(JSON.stringify(validated, null, 2) + '\n');
113
+ if (encoded.length > NETWORK_CONFIG_MAX_BYTES) fail('IRIS_NETWORK_CONFIG_INVALID', 'network-config 不能超过1 MiB');
114
+ const backupPath = privateSibling(file, 'backup');
115
+ io(() => {
116
+ fs.writeFileSync(backupPath, before.bytes, { flag: 'wx', mode: 0o600 });
117
+ atomicWritePrivate(file, encoded);
118
+ });
119
+ return { command, changed: true, backupPath, ...projectNetworkConfig(validated, digest(encoded), env) };
120
+ } finally {
121
+ if (fd !== undefined) io(() => { try { fs.closeSync(fd); } finally { fs.unlinkSync(file + '.lock'); } });
122
+ }
123
+ }