@mokuyoaxis/dsh-iris 0.2.7 → 0.3.0-alpha.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 (160) hide show
  1. package/CHANGELOG.md +81 -1
  2. package/README.en.md +103 -312
  3. package/README.md +100 -310
  4. package/bin/dsh-iris.js +333 -65
  5. package/docs/AGENT_CORE_QUICKSTART.md +64 -0
  6. package/docs/AGENT_QUICKSTART.md +2 -0
  7. package/docs/ARCHITECTURE.md +13 -3
  8. package/docs/AUDIO_FLOWS.md +71 -0
  9. package/docs/CONTACT_SHEET.md +62 -0
  10. package/docs/CORE_CONNECTIONS.md +99 -0
  11. package/docs/CORE_QUICKSTART.md +108 -0
  12. package/docs/CORE_RUNTIME_CONTRACT.md +7 -2
  13. package/docs/CORE_SERVICE.md +97 -0
  14. package/docs/CORE_SKILLS.md +35 -3
  15. package/docs/DSH_COMPATIBILITY.md +10 -0
  16. package/docs/DSH_RC2_ADAPTATION.md +1 -1
  17. package/docs/DSH_RUNTIME.md +6 -2
  18. package/docs/HEADLESS_CLI.md +2 -2
  19. package/docs/IMAGE_FLOW.md +4 -0
  20. package/docs/IMAGE_GENERATION.md +127 -0
  21. package/docs/LOCAL_API.md +4 -2
  22. package/docs/LOCAL_API_CONTRACT.md +39 -14
  23. package/docs/LOCAL_CREATION.md +7 -1
  24. package/docs/LOCAL_OPERATIONS.md +46 -0
  25. package/docs/LOCAL_SELECTION.md +57 -0
  26. package/docs/LOCAL_WEB.md +201 -0
  27. package/docs/MCP.md +191 -0
  28. package/docs/MUSIC.md +2 -0
  29. package/docs/PANEL_EXTENSIONS.md +64 -0
  30. package/docs/PLUGINS.md +189 -0
  31. package/docs/PLUGIN_STARTUP.md +100 -0
  32. package/docs/PROVIDER_ADAPTER_CONTRACT.md +1 -1
  33. package/docs/PROVIDER_EXTENSIONS.md +78 -0
  34. package/docs/PROVIDER_HEALTH.md +3 -1
  35. package/docs/PROVIDER_SETTINGS.md +85 -0
  36. package/docs/PROVIDER_SUBMISSION_CONTRACT.md +6 -0
  37. package/docs/ROADMAP.md +17 -7
  38. package/docs/VISION_CLI.md +1 -1
  39. package/docs/WEB_PROTOTYPE.md +21 -6
  40. package/docs/WEB_RECOVERY.md +46 -0
  41. package/docs/WORKBENCH_LOGIN.md +63 -0
  42. package/docs/assets/screenshots/core-connections-preview.webp +0 -0
  43. package/docs/assets/screenshots/core-model-pools-preview.webp +0 -0
  44. package/docs/assets/screenshots/core-overview-preview.webp +0 -0
  45. package/docs/assets/screenshots/core-spectrum-preview.webp +0 -0
  46. package/docs/assets/screenshots/core-studio-dark-preview.webp +0 -0
  47. package/docs/assets/screenshots/core-studio-mobile-preview.webp +0 -0
  48. package/docs/assets/screenshots/core-studio-preview.webp +0 -0
  49. package/docs/releases/0.3.0-alpha.1.md +51 -0
  50. package/docs/screenshots.md +41 -1
  51. package/lib/actions.js +53 -2
  52. package/lib/adapters.js +20 -11
  53. package/lib/application-runtime.js +34 -7
  54. package/lib/audio-flow-input.js +37 -0
  55. package/lib/client.js +346 -53
  56. package/lib/comfyui-adapter.js +94 -0
  57. package/lib/comfyui-client.js +51 -0
  58. package/lib/comfyui-inspection.js +73 -0
  59. package/lib/comfyui-workflow.js +94 -0
  60. package/lib/config.js +36 -1
  61. package/lib/configured-image-runtime.js +50 -0
  62. package/lib/connection-skill.js +89 -0
  63. package/lib/connection-template.js +23 -0
  64. package/lib/core-artifact-store.js +16 -2
  65. package/lib/core-bridge-selection.js +31 -0
  66. package/lib/core-bridge-tools.js +83 -0
  67. package/lib/core-capability-catalog.js +182 -0
  68. package/lib/core-connection-diagnostics.js +49 -0
  69. package/lib/core-connection-store.js +277 -0
  70. package/lib/core-connections.js +225 -0
  71. package/lib/core-maintenance.js +9 -3
  72. package/lib/core-runtime.js +2 -1
  73. package/lib/core-service-client.js +170 -0
  74. package/lib/core-tasks.js +29 -12
  75. package/lib/core-writer-lease.js +16 -0
  76. package/lib/creation-request-store.js +38 -64
  77. package/lib/dsh-host-adapter.js +4 -1
  78. package/lib/dsh-service-bridge.js +66 -0
  79. package/lib/execution-ownership.js +43 -0
  80. package/lib/flow-store.js +105 -10
  81. package/lib/generation-input.js +10 -1
  82. package/lib/headless-vision.js +7 -7
  83. package/lib/image-edit-input.js +1 -1
  84. package/lib/image-flow.js +73 -29
  85. package/lib/index.js +37 -17
  86. package/lib/local-api-projection.js +10 -5
  87. package/lib/local-api-token-directory.js +31 -0
  88. package/lib/local-api.js +769 -67
  89. package/lib/local-creation.js +89 -24
  90. package/lib/local-operations.js +36 -0
  91. package/lib/local-request-root.js +86 -0
  92. package/lib/local-web-assets.js +37 -0
  93. package/lib/mcp-bridge.js +58 -0
  94. package/lib/mcp-check.js +26 -0
  95. package/lib/mcp-config.js +42 -0
  96. package/lib/mcp-delivery.js +46 -0
  97. package/lib/mcp-http.js +101 -0
  98. package/lib/mcp-server.js +47 -0
  99. package/lib/model-directory-service.js +16 -6
  100. package/lib/model-directory.js +3 -3
  101. package/lib/models.js +2 -2
  102. package/lib/music-transport.js +2 -1
  103. package/lib/operation-runner.js +236 -0
  104. package/lib/operation-store.js +216 -0
  105. package/lib/panel-extension.js +36 -0
  106. package/lib/plugin-contract.js +194 -0
  107. package/lib/plugin-execution.js +51 -0
  108. package/lib/plugin-launch.js +86 -0
  109. package/lib/plugin-operation-store.js +101 -0
  110. package/lib/plugin-operations.js +28 -0
  111. package/lib/plugin-registry.js +109 -0
  112. package/lib/plugin-selection.js +15 -0
  113. package/lib/plugin-source-image.js +5 -0
  114. package/lib/plugin-web-assets.js +54 -0
  115. package/lib/plugins.d.ts +288 -0
  116. package/lib/plugins.js +58 -0
  117. package/lib/provider-adapter.js +5 -1
  118. package/lib/provider-adapters.js +56 -18
  119. package/lib/provider-catalog.js +40 -29
  120. package/lib/provider-config-service.js +105 -23
  121. package/lib/provider-diagnostics.js +56 -0
  122. package/lib/provider-extensions.js +29 -0
  123. package/lib/provider-health.js +8 -1
  124. package/lib/provider-monitor.js +79 -0
  125. package/lib/provider-protocol.js +70 -20
  126. package/lib/provider-registry.js +118 -0
  127. package/lib/provider-response.js +28 -0
  128. package/lib/provider-settings.js +134 -0
  129. package/lib/provider-task-runner.js +22 -9
  130. package/lib/skill-installation.js +23 -11
  131. package/lib/vision-model-routing.js +3 -0
  132. package/lib/web/app.js +642 -0
  133. package/lib/web/appearance.js +111 -0
  134. package/lib/web/comfyui.js +146 -0
  135. package/lib/web/connections.js +377 -0
  136. package/lib/web/creation.js +355 -0
  137. package/lib/web/flow-view.js +33 -0
  138. package/lib/web/gallery.js +132 -0
  139. package/lib/web/index.html +291 -0
  140. package/lib/web/media-client.js +204 -0
  141. package/lib/web/plugin-panel.js +221 -0
  142. package/lib/web/plugin-surface.css +17 -0
  143. package/lib/web/providers.js +492 -0
  144. package/lib/web/recovery.js +105 -0
  145. package/lib/web/startup.js +18 -0
  146. package/lib/web/styles.css +518 -0
  147. package/lib/web/transport.js +96 -0
  148. package/lib/web/workbench-login.js +165 -0
  149. package/lib/workbench-auth-cli.js +47 -0
  150. package/lib/workbench-auth.js +200 -0
  151. package/package.json +20 -5
  152. package/plugins/contact-sheet/index.d.mts +4 -0
  153. package/plugins/contact-sheet/index.mjs +33 -0
  154. package/plugins/contact-sheet/web/board.css +14 -0
  155. package/plugins/contact-sheet/web/board.mjs +93 -0
  156. package/skills/iris-core/SKILL.md +8 -4
  157. package/skills/iris-core/references/cli.md +2 -2
  158. package/skills/iris-core/references/comfyui.md +69 -0
  159. package/skills/iris-core/references/tasks.md +2 -0
  160. package/user_guide.md +5 -1
@@ -0,0 +1,170 @@
1
+ import crypto from 'node:crypto';
2
+ import { checkedClientConnection, readPrivateConnectionJson, connectionFail } from './core-connections.js';
3
+ import { MAX_IMPORT_IMAGE_BYTES } from './image-import.js';
4
+ import { annotateCoreConnectionError } from './core-connection-diagnostics.js';
5
+ import { checkedPluginSelection, hasPluginSelection } from './plugin-selection.js';
6
+
7
+ const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/;
8
+ const RUN = /^run_[a-f0-9]{24}$/, OP = /^op_[a-f0-9]{64}$/, ARTIFACT = /^artifact_[a-f0-9]{24}$/;
9
+ const input = () => connectionFail('IRIS_CONNECTION_INPUT_INVALID', 'Core 服务工具输入无效');
10
+ function reference(value, pattern) { if (typeof value !== 'string' || !pattern.test(value)) input(); return value; }
11
+
12
+ /** A bounded client of one explicitly authorized Core. It never owns a Core writer or falls back. */
13
+ export function createCoreServiceClient({ connectionFile, connection, requestedScopes = ['read'], requestedCapabilities, requestedTools, requestedPluginOperations,
14
+ harnessVersion = 'unknown', fetch: fetchImpl = globalThis.fetch } = {}) {
15
+ if (connectionFile !== undefined && connection !== undefined) throw new TypeError('连接配置只能明确提供一种来源');
16
+ const runtimeInstanceId = crypto.randomUUID(), lifetime = new AbortController();
17
+ let state = 'new', config, negotiated, connectionPromise, metadataPromise, metadataKey, metadataAt = 0, disposal;
18
+ async function request(route, { method = 'GET', value, bytes, key, mediaType, binary = false, signal, handshake = false, disconnect = false } = {}) {
19
+ if (state !== 'open' && !(handshake && state === 'connecting') && !(disconnect && state === 'closing')) connectionFail('IRIS_CORE_SERVICE_DISCONNECTED', 'Core 服务尚未连接,请显式连接');
20
+ const headers = { Authorization: 'Bearer ' + config.accessToken, 'Iris-Connection-Id': config.connectionId, 'Iris-Runtime-Id': runtimeInstanceId };
21
+ if (key !== undefined) headers['Idempotency-Key'] = reference(key, UUID);
22
+ if (value !== undefined || bytes !== undefined) headers['Content-Type'] = mediaType || 'application/json';
23
+ const combined = AbortSignal.any([...(disconnect ? [] : [lifetime.signal]), AbortSignal.timeout(disconnect ? 2000 : 15000), ...(signal ? [signal] : [])]);
24
+ let response;
25
+ try {
26
+ response = await fetchImpl(config.origin + route, { method, headers, redirect: 'error', signal: combined,
27
+ ...(value !== undefined ? { body: JSON.stringify(value) } : bytes !== undefined ? { body: bytes } : {}) });
28
+ } catch (error) {
29
+ if (signal?.aborted || !disconnect && lifetime.signal.aborted) throw Object.assign(new Error('Core 请求等待已停止;已接收操作需按原 Key 查询'), { name: 'AbortError', code: 'IRIS_CORE_SERVICE_ABORTED' });
30
+ throw annotateCoreConnectionError(Object.assign(new Error('Core 服务无法连接,请检查服务状态'), {
31
+ code: combined.reason?.name === 'TimeoutError' ? 'IRIS_CORE_SERVICE_TIMEOUT' : 'IRIS_CORE_SERVICE_UNAVAILABLE' }), 'transport');
32
+ }
33
+ const limit = binary && binary !== 'text' && response.ok ? 64 * 1024 * 1024 : 1024 * 1024;
34
+ if (Number(response.headers.get('content-length')) > limit) { await response.body?.cancel(); connectionFail('IRIS_CORE_SERVICE_RESPONSE_INVALID', 'Core 响应超过有界大小'); }
35
+ const chunks = []; let size = 0;
36
+ try {
37
+ if (response.body) for await (const chunk of response.body) {
38
+ size += chunk.length;
39
+ if (size > limit) connectionFail('IRIS_CORE_SERVICE_RESPONSE_INVALID', 'Core 响应超过有界大小');
40
+ chunks.push(chunk);
41
+ }
42
+ } catch (error) {
43
+ if (error.code === 'IRIS_CORE_SERVICE_RESPONSE_INVALID') throw error;
44
+ connectionFail('IRIS_CORE_SERVICE_UNAVAILABLE', 'Core 响应中断;请读取原 Operation,不要自动再次提交');
45
+ }
46
+ const result = Buffer.concat(chunks, size);
47
+ if (binary && response.ok) {
48
+ const mediaType = response.headers.get('content-type');
49
+ const allowed = binary === 'audio' ? ['audio/wav', 'audio/mpeg', 'audio/ogg', 'audio/flac'] : binary === 'text' ? ['text/plain'] : ['image/png', 'image/jpeg', 'image/webp'];
50
+ if (!allowed.includes(mediaType)) connectionFail('IRIS_CORE_SERVICE_RESPONSE_INVALID', 'Core返回的作品类型与所选入口不一致');
51
+ return { bytes: result, mediaType };
52
+ }
53
+ let body;
54
+ try { body = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(result)); }
55
+ catch (_) { connectionFail('IRIS_CORE_SERVICE_RESPONSE_INVALID', 'Core 返回无效 JSON'); }
56
+ if (!response.ok) {
57
+ const code = /^IRIS_[A-Z_]+$/.test(body?.error?.code) ? body.error.code : 'IRIS_CORE_SERVICE_REJECTED';
58
+ throw annotateCoreConnectionError(Object.assign(new Error('Core 服务拒绝本次请求;请检查授权、原操作及服务状态'), {
59
+ code, status: response.status, ...(UUID.test(body?.error?.requestId) ? { requestId: body.error.requestId } : {}) }), handshake ? 'handshake' : disconnect ? 'disconnect' : 'tool');
60
+ }
61
+ if (body?.contractVersion !== 0) connectionFail('IRIS_CORE_SERVICE_RESPONSE_INVALID', 'Core 响应契约不受支持');
62
+ return body;
63
+ }
64
+ function connect() {
65
+ if (['closing', 'closed'].includes(state)) return Promise.reject(Object.assign(new Error('Core 连接已关闭'), { code: 'IRIS_CORE_SERVICE_DISCONNECTED' }));
66
+ if (connectionPromise) return connectionPromise;
67
+ connectionPromise = (async () => {
68
+ let stage = 'configuration';
69
+ try {
70
+ config = checkedClientConnection(connection ?? readPrivateConnectionJson(connectionFile));
71
+ state = 'connecting'; stage = 'handshake';
72
+ const pluginChoice = requestedPluginOperations === undefined ? config.pluginOperations : requestedPluginOperations;
73
+ const result = await request('/api/v0/connections/' + config.connectionId + '/handshake', { method: 'POST', handshake: true,
74
+ value: { contractVersion: 0, adapterId: config.adapterId, harnessKind: config.harnessKind, runtimeInstanceId,
75
+ harnessVersion, requestedScopes, requestedCapabilities: requestedCapabilities ?? config.capabilities,
76
+ requestedTools: requestedTools ?? config.tools,
77
+ ...(pluginChoice === undefined ? {} : { requestedPluginOperations: checkedPluginSelection(pluginChoice) }) } });
78
+ if (state !== 'connecting') connectionFail('IRIS_CORE_SERVICE_ABORTED', '连接启动等待已停止');
79
+ if (result.rootId !== config.rootId || result.connectionId !== config.connectionId || result.instance?.runtimeInstanceId !== runtimeInstanceId
80
+ || !Array.isArray(result.instance.scopes) || result.instance.scopes.some(scope => !requestedScopes.includes(scope))
81
+ || !Array.isArray(result.instance.capabilities) || result.instance.capabilities.some(id => !(requestedCapabilities ?? config.capabilities).includes(id))
82
+ || !Array.isArray(result.instance.tools) || result.instance.tools.some(name => !(requestedTools ?? config.tools).includes(name))) {
83
+ connectionFail('IRIS_CORE_SERVICE_RESPONSE_INVALID', 'Core 数据空间或协商身份不一致');
84
+ }
85
+ if (checkedPluginSelection(result.instance.pluginOperations).some(pin => !hasPluginSelection(pluginChoice ?? [], pin))) {
86
+ connectionFail('IRIS_CORE_SERVICE_RESPONSE_INVALID', 'Core 返回了未请求的插件业务依赖');
87
+ }
88
+ negotiated = result; metadataKey = null; metadataAt = 0; state = 'open'; return structuredClone(result);
89
+ } catch (error) { if (!['closing', 'closed'].includes(state)) state = 'disconnected'; throw annotateCoreConnectionError(error, stage); }
90
+ })();
91
+ void connectionPromise.then(() => { connectionPromise = null; }, () => { connectionPromise = null; });
92
+ return connectionPromise;
93
+ }
94
+ function snapshot() {
95
+ return { contractVersion: 0, state, ...(config ? { connectionId: config.connectionId, adapterId: config.adapterId,
96
+ harnessKind: config.harnessKind, label: config.label, runtimeInstanceId, scopes: negotiated?.instance.scopes || [],
97
+ capabilities: negotiated?.instance.capabilities || [], tools: negotiated?.instance.tools || [],
98
+ ...(negotiated?.instance.pluginOperations?.length ? { pluginOperations: structuredClone(negotiated.instance.pluginOperations) } : {}) } : {}) };
99
+ }
100
+ async function reportClientInfo(clientInfo, transport) {
101
+ if (!clientInfo || !negotiated?.connectionFeatures?.includes('client-info')) return;
102
+ const key = JSON.stringify({ clientInfo, transport });
103
+ if (key === metadataKey && Date.now() - metadataAt < 60000) return metadataPromise;
104
+ metadataKey = key; metadataAt = Date.now();
105
+ const attempt = request('/api/v0/connections/' + config.connectionId + '/client-info', { method: 'POST', value: { clientInfo, transport } });
106
+ metadataPromise = attempt;
107
+ void attempt.catch(() => { if (metadataPromise === attempt) { metadataKey = null; metadataAt = 0; } });
108
+ return metadataPromise;
109
+ }
110
+ function dispose() {
111
+ if (disposal) return disposal;
112
+ const wasOpen = state === 'open', starting = connectionPromise;
113
+ state = 'closing'; lifetime.abort();
114
+ disposal = (async () => {
115
+ let remoteDisconnect = wasOpen ? 'unconfirmed' : 'not_connected';
116
+ try {
117
+ if (wasOpen) {
118
+ await request('/api/v0/connections/' + config.connectionId + '/disconnect', { method: 'POST', value: {}, disconnect: true });
119
+ remoteDisconnect = 'confirmed';
120
+ }
121
+ } catch (error) {
122
+ if (!['IRIS_CORE_SERVICE_UNAVAILABLE', 'IRIS_CORE_SERVICE_TIMEOUT', 'IRIS_CORE_SERVICE_ABORTED', 'IRIS_CONNECTION_HANDSHAKE_REQUIRED',
123
+ 'IRIS_CONNECTION_AUTH_REQUIRED', 'IRIS_API_AUTH_REQUIRED', 'IRIS_API_UNAVAILABLE'].includes(error.code)) throw error;
124
+ } finally {
125
+ await Promise.allSettled([starting, metadataPromise].filter(Boolean));
126
+ state = 'closed'; negotiated = null; config = null;
127
+ }
128
+ return { state, remoteDisconnect };
129
+ })();
130
+ return disposal;
131
+ }
132
+ function pageQuery(value = {}) {
133
+ if (!value || typeof value !== 'object' || Object.keys(value).some(key => !['limit', 'cursor'].includes(key))
134
+ || value.limit !== undefined && (!Number.isInteger(value.limit) || value.limit < 1 || value.limit > 30)
135
+ || value.cursor !== undefined && typeof value.cursor !== 'string') input();
136
+ return new URLSearchParams(Object.fromEntries(Object.entries(value).map(([key, value]) => [key, String(value)]))).toString();
137
+ }
138
+ return Object.freeze({ connect, dispose, snapshot, reportClientInfo,
139
+ pluginCatalog: (page, options) => request('/api/v0/plugin-operations/catalog?' + new URLSearchParams(page), options),
140
+ callPlugin({ key, ...value }, options) { return request('/api/v0/plugin-operations', { ...options, method: 'POST', value, key }); },
141
+ models: options => request('/api/v0/models', options),
142
+ audioModels: options => request('/api/v0/audio-models', options),
143
+ connections: options => request('/api/v0/connections', options),
144
+ runs: (page, options) => request('/api/v0/runs?' + pageQuery(page), options),
145
+ run: (id, options) => request('/api/v0/runs/' + reference(id, RUN), options),
146
+ image: (id, options) => request('/api/v0/artifacts/' + reference(id, ARTIFACT) + '/content', { ...options, binary: true }),
147
+ audio: (id, options) => request('/api/v0/artifacts/' + reference(id, ARTIFACT) + '/audio', { ...options, binary: 'audio' }),
148
+ transcript: (id, options) => request('/api/v0/artifacts/' + reference(id, ARTIFACT) + '/text', { ...options, binary: 'text' }),
149
+ createImage({ key, bytes, mediaType }, options) {
150
+ if (!(bytes instanceof Uint8Array) || !bytes.length || bytes.length > MAX_IMPORT_IMAGE_BYTES || !['image/png', 'image/jpeg', 'image/webp'].includes(mediaType)) input();
151
+ return request('/api/v0/images', { ...options, method: 'POST', bytes, key, mediaType });
152
+ },
153
+ createRun({ key, value }, options) { return request('/api/v0/runs', { ...options, method: 'POST', value, key }); },
154
+ createGeneration({ key, value }, options) { return request('/api/v0/generations', { ...options, method: 'POST', value, key }); },
155
+ createAudio({ key, bytes, mediaType }, options) {
156
+ if (!(bytes instanceof Uint8Array) || !bytes.length || bytes.length > 20 * 1024 * 1024 || !['audio/wav', 'audio/mpeg', 'audio/ogg', 'audio/flac'].includes(mediaType)) input();
157
+ return request('/api/v0/audio', { ...options, method: 'POST', bytes, key, mediaType });
158
+ },
159
+ createSpeech({ key, value }, options) { return request('/api/v0/speeches', { ...options, method: 'POST', value, key }); },
160
+ createTranscription({ key, value }, options) { return request('/api/v0/transcriptions', { ...options, method: 'POST', value, key }); },
161
+ continueRun({ key, runId, expectedRevision }, options) {
162
+ if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 1) input();
163
+ return request('/api/v0/runs/' + reference(runId, RUN) + '/continue', { ...options, method: 'POST', value: { expectedRevision }, key });
164
+ },
165
+ operation: (id, options) => request('/api/v0/operations/' + reference(id, OP), options),
166
+ lookup: (key, options) => request('/api/v0/operations?key=' + reference(key, UUID), options),
167
+ operations: (runId, page, options) => request('/api/v0/runs/' + reference(runId, RUN) + '/operations?' + pageQuery(page), options),
168
+ stop: (id, options) => request('/api/v0/operations/' + reference(id, OP) + '/cancel', { ...options, method: 'POST', value: {} })
169
+ });
170
+ }
package/lib/core-tasks.js CHANGED
@@ -8,7 +8,8 @@ import { parseModelRef } from './models.js';
8
8
  import { providerErrorRecord } from './provider-contract.js';
9
9
  import { deriveLegacyStatus, semanticViolations } from './task-semantics.js';
10
10
  import { readDeliveryCache } from './core-delivery-cache.js';
11
- import { flowTaskBinding, readFlowRun, validFlowBinding } from './flow-store.js';
11
+ import { flowStep, flowStepId, flowTaskBinding, flowCapability, readFlowRun, validFlowBinding } from './flow-store.js';
12
+ import { checkedPluginDependency, assertPluginDependencyMatches, PluginContractError } from './plugin-contract.js';
12
13
 
13
14
  const STORE_DIR = 'task-store';
14
15
  const STORE_VERSION = 'v0';
@@ -111,8 +112,9 @@ function validateRecord(record, expectedId) {
111
112
  || (record.deliveryCache !== undefined && record.deliveryCache !== true)
112
113
  || (record.providerBinding !== undefined && !PROVIDER_BINDING.test(record.providerBinding))
113
114
  || (record.retriedFrom !== undefined && !TASK_ID.test(record.retriedFrom))
114
- || (record.flowBinding !== undefined && (record.capability !== 'image' || !validFlowBinding(record.flowBinding)))
115
- || (record.sourceArtifactId !== undefined && (record.capability !== 'image' || !ARTIFACT_ID.test(record.sourceArtifactId)))) {
115
+ || (record.flowBinding !== undefined && (!validFlowBinding(record.flowBinding)
116
+ || ({ edit: 'image', generate: 'image', speak: 'tts', transcribe: 'transcribe' })[record.flowBinding.stepId] !== record.capability))
117
+ || (record.sourceArtifactId !== undefined && (!['image', 'transcribe'].includes(record.capability) || !ARTIFACT_ID.test(record.sourceArtifactId)))) {
116
118
  fail('IRIS_TASK_INVALID', 'Task 记录结构无效');
117
119
  }
118
120
  if (record.attempts.some((attempt, index) => !attempt || !ATTEMPT_ID.test(attempt.id)
@@ -123,6 +125,15 @@ function validateRecord(record, expectedId) {
123
125
  || !validAttemptStageTimestamps(attempt.stageTimestamps))) {
124
126
  fail('IRIS_TASK_INVALID', 'Task Attempt 记录结构无效');
125
127
  }
128
+ try {
129
+ for (const item of [record, ...record.attempts]) if (item.pluginDependency !== undefined) {
130
+ if (checkedPluginDependency(item.pluginDependency).kind !== 'providers' || !item.providerBinding) fail('IRIS_TASK_INVALID', 'Task 插件依赖需要 Provider 与端点绑定');
131
+ }
132
+ assertPluginDependencyMatches(record.pluginDependency, record.attempts.at(-1)?.pluginDependency);
133
+ } catch (error) {
134
+ if (!(error instanceof PluginContractError)) throw error;
135
+ fail('IRIS_TASK_INVALID', 'Task 或 Attempt 的插件依赖无效或不一致');
136
+ }
126
137
  const violations = semanticViolations(record);
127
138
  if (violations.length) fail('IRIS_TASK_INVALID', 'Task 事实语义冲突:' + violations.join(', '));
128
139
  return record;
@@ -178,7 +189,7 @@ function newTaskRecord(input, id) {
178
189
  const retriedFrom = input.retriedFrom === undefined ? undefined : String(input.retriedFrom);
179
190
  if (retriedFrom !== undefined && !TASK_ID.test(retriedFrom)) fail('IRIS_TASK_INPUT_INVALID', 'retriedFrom 必须是合法 Task ID');
180
191
  const sourceArtifactId = input.sourceArtifactId;
181
- if (sourceArtifactId !== undefined && (capability !== 'image' || !ARTIFACT_ID.test(sourceArtifactId))) fail('IRIS_TASK_INPUT_INVALID', '改图 sourceArtifactId 必须是合法 Artifact ID');
192
+ if (sourceArtifactId !== undefined && (!['image', 'transcribe'].includes(capability) || !ARTIFACT_ID.test(sourceArtifactId))) fail('IRIS_TASK_INPUT_INVALID', '来源须为改图或转写的合法 Artifact ID');
182
193
  const now = new Date().toISOString();
183
194
  return {
184
195
  schemaVersion: 0,
@@ -210,8 +221,9 @@ export function createCoreTask(dataRoot, input = {}) {
210
221
  /** Flow 只能接回自己持久预留的 Task;原有 Task 没有该字段,读取语义不变。 */
211
222
  export function assertFlowTaskMatches(task, run) {
212
223
  const providerId = parseModelRef(run.input.model_ref).providerId;
213
- if (task.id !== run.edit.taskId || task.capability !== 'image' || task.sourceArtifactId !== run.source.artifactId
224
+ if (task.id !== flowStep(run).taskId || task.capability !== flowCapability(run) || task.sourceArtifactId !== run.source?.artifactId
214
225
  || !validFlowBinding(task.flowBinding) || task.flowBinding.runId !== run.id
226
+ || task.flowBinding.stepId !== flowStepId(run)
215
227
  || task.flowBinding.inputDigest !== run.inputDigest
216
228
  || task.attempts.some(attempt => attempt.providerId !== providerId
217
229
  || attempt.model !== run.input.model_ref || attempt.providerBinding !== run.providerBinding)
@@ -228,13 +240,13 @@ export function findFlowTask(dataRoot, run) {
228
240
  if (!stat.isDirectory() || stat.isSymbolicLink()) fail('IRIS_FLOW_TASK_CONFLICT', '流程 Task 目录不安全');
229
241
  } catch (error) { if (error.code !== 'ENOENT') throw error; }
230
242
  }
231
- try { fs.lstatSync(taskFile(dataRoot, run.edit.taskId)); }
243
+ try { fs.lstatSync(taskFile(dataRoot, flowStep(run).taskId)); }
232
244
  catch (error) {
233
245
  if (error.code !== 'ENOENT') throw error;
234
- if (run.edit.claimed) fail('IRIS_FLOW_TASK_CONFLICT', '流程已建立的 Task 丢失,禁止重建或重新生成');
246
+ if (flowStep(run).claimed) fail('IRIS_FLOW_TASK_CONFLICT', '流程已建立的 Task 丢失,禁止重建或重新生成');
235
247
  return null;
236
248
  }
237
- return assertFlowTaskMatches(readRecord(dataRoot, run.edit.taskId), run);
249
+ return assertFlowTaskMatches(readRecord(dataRoot, flowStep(run).taskId), run);
238
250
  }
239
251
 
240
252
  /** 仅从已提交的 Run 读取 ID;排他发布,新旧身份均不能覆盖。 */
@@ -242,7 +254,7 @@ export function claimFlowTask(dataRoot, runId) {
242
254
  const run = readFlowRun(dataRoot, runId);
243
255
  const previous = findFlowTask(dataRoot, run);
244
256
  if (previous) return previous;
245
- return writeRecord(dataRoot, { ...newTaskRecord({ capability: 'image', sourceArtifactId: run.source.artifactId }, run.edit.taskId),
257
+ return writeRecord(dataRoot, { ...newTaskRecord({ capability: flowCapability(run), sourceArtifactId: run.source?.artifactId }, flowStep(run).taskId),
246
258
  flowBinding: flowTaskBinding(run) }, true);
247
259
  }
248
260
 
@@ -302,9 +314,11 @@ export function beginCoreAttempt(dataRoot, taskId, fields = {}) {
302
314
  const providerId = String(fields.providerId || '').trim();
303
315
  const model = String(fields.model || '').trim();
304
316
  const parsed = parseModelRef(model);
317
+ const pluginDependency = fields.pluginDependency === undefined ? undefined : checkedPluginDependency(fields.pluginDependency);
305
318
  if (!providerId || !parsed || parsed.providerId !== providerId
306
319
  || (fields.providerBinding !== undefined && !PROVIDER_BINDING.test(fields.providerBinding))
307
- || (fields.selectionReason !== undefined && !ATTEMPT_SELECTION_REASONS.has(fields.selectionReason))) {
320
+ || (fields.selectionReason !== undefined && !ATTEMPT_SELECTION_REASONS.has(fields.selectionReason))
321
+ || (pluginDependency && (pluginDependency.kind !== 'providers' || !fields.providerBinding))) {
308
322
  fail('IRIS_ATTEMPT_INPUT_INVALID', 'Attempt 的 providerId 与复合 model 必须一致');
309
323
  }
310
324
  let created;
@@ -325,7 +339,8 @@ export function beginCoreAttempt(dataRoot, taskId, fields = {}) {
325
339
  startedAt: submitStartedAt,
326
340
  stageTimestamps: { queuedAt, submitStartedAt },
327
341
  ...(fields.selectionReason !== undefined ? { selectionReason: fields.selectionReason } : {}),
328
- ...(fields.providerBinding !== undefined ? { providerBinding: fields.providerBinding } : {})
342
+ ...(fields.providerBinding !== undefined ? { providerBinding: fields.providerBinding } : {}),
343
+ ...(pluginDependency ? { pluginDependency } : {})
329
344
  };
330
345
  task.attempts.push(created);
331
346
  task.activeAttemptId = created.id;
@@ -333,6 +348,8 @@ export function beginCoreAttempt(dataRoot, taskId, fields = {}) {
333
348
  task.modelRef = model;
334
349
  if (created.providerBinding) task.providerBinding = created.providerBinding;
335
350
  else delete task.providerBinding;
351
+ if (created.pluginDependency) task.pluginDependency = created.pluginDependency;
352
+ else delete task.pluginDependency;
336
353
  task.phase = 'submitting';
337
354
  task.acceptance = 'none';
338
355
  task.watchState = 'idle';
@@ -519,7 +536,7 @@ export function failCoreDelivery(dataRoot, taskId, error) {
519
536
  task.phase = 'terminal';
520
537
  task.watchState = 'idle';
521
538
  task.deliveryState = 'failed';
522
- task.lastError = safeError(error, { stage: 'download', category: 'local_io', acceptance: 'accepted' });
539
+ task.lastError = safeError(error, { stage: 'download', category: error?.category || 'local_io', acceptance: 'accepted' });
523
540
  task.finishedAt = new Date().toISOString();
524
541
  });
525
542
  }
@@ -44,6 +44,22 @@ function optionalRecord(file) {
44
44
  catch (error) { if (error?.code === 'ENOENT') return null; throw error; }
45
45
  }
46
46
 
47
+ /** Recognize only an unpublished preparation from a proven-dead owner. Keep it
48
+ * as evidence: initialization may ignore this metadata, but never removes it. */
49
+ export function isAbandonedWriterPreparation(root, name) {
50
+ const match = /^\.iris-runtime-writer-v0\.owner\.json\.prepare-([1-9][0-9]*)-[a-f0-9]{12}$/.exec(name);
51
+ if (!match) return false;
52
+ try {
53
+ const file = path.join(root, name), stat = fs.lstatSync(file);
54
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) return false;
55
+ const owner = checkedOwner(readRecord(file).value);
56
+ return String(owner.pid) === match[1] && writerOwnerStatus(owner.pid) === 'missing';
57
+ } catch (error) {
58
+ if (error.code && !['ENOENT', 'EACCES', 'EPERM'].includes(error.code)) throw error;
59
+ return false;
60
+ }
61
+ }
62
+
47
63
  /** 完整私有记录先在根中准备,再以 hard link 原子发布;已有目标绝不覆盖。 */
48
64
  export function publishLeaseRecord(root, target, value) {
49
65
  const prepared = privateSibling(path.join(root, WRITER_INTENT_FILE), 'prepare');
@@ -3,8 +3,10 @@ import crypto from 'node:crypto';
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { atomicCreatePrivate, atomicWritePrivate } from './private-storage.js';
6
- import { inspectFlowRoot, validateFlowCreation } from './flow-store.js';
7
- import { WRITER_LEASE_DIR, WRITER_INTENT_FILE } from './core-writer-lease.js';
6
+ import { validateFlowCreation } from './flow-store.js';
7
+ import { inspectLocalRequestRoot, initializeLocalRequestRoot, requestDirectory } from './local-request-root.js';
8
+ import { normalizeComfyParameters } from './comfyui-workflow.js';
9
+ import { AUDIO_TYPES, MAX_AUDIO_INPUT_BYTES, normalizeAudioFlowInput } from './audio-flow-input.js';
8
10
 
9
11
  const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/;
10
12
  const ID = /^req_[a-f0-9]{64}$/;
@@ -13,6 +15,8 @@ const SHA = /^[a-f0-9]{64}$/;
13
15
  const DIGEST = /^sha256:[a-f0-9]{64}$/;
14
16
  const TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
15
17
  const MAX_IMAGE = 20 * 1024 * 1024;
18
+ export const CREATION_KINDS = Object.freeze(['image-import', 'flow-create', 'generation-create', 'audio-import', 'speech-create', 'transcription-create']);
19
+ export const isImportCreation = kind => ['image-import', 'audio-import'].includes(kind);
16
20
  const iso = value => typeof value === 'string' && Number.isFinite(Date.parse(value)) && new Date(value).toISOString() === value;
17
21
  const hash = value => crypto.createHash('sha256').update(value).digest('hex');
18
22
  const copy = value => structuredClone(value);
@@ -22,16 +26,6 @@ function keys(value, allowed, required = allowed) {
22
26
  }
23
27
  export function creationFail(code, message) { throw Object.assign(new Error(message), { code }); }
24
28
  const invalid = () => creationFail('IRIS_CREATION_STORE_INVALID', '创建请求区未知、损坏或尚未完整初始化');
25
- function directory(root, segments, create = false) {
26
- let current = root;
27
- for (const segment of segments) {
28
- current = path.join(current, segment);
29
- if (create && !fs.existsSync(current)) fs.mkdirSync(current, { mode: 0o700 });
30
- const stat = fs.lstatSync(current);
31
- if (!stat.isDirectory() || stat.isSymbolicLink()) invalid();
32
- }
33
- return current;
34
- }
35
29
  function jsonFile(file) {
36
30
  const stat = fs.lstatSync(file);
37
31
  if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 128 * 1024) invalid();
@@ -39,54 +33,28 @@ function jsonFile(file) {
39
33
  }
40
34
 
41
35
  export function inspectCreationRoot(root, { allowAbsent = false } = {}) {
42
- let base;
43
- try { base = directory(root, ['creation-requests']); }
44
- catch (error) {
45
- if (error.code === 'ENOENT' && !fs.existsSync(path.join(root, 'creation-requests'))) {
46
- if (allowAbsent) return null;
47
- creationFail('IRIS_CREATION_ROOT_REQUIRED', '请先在空的专用 Flow 根显式执行 api init');
48
- }
49
- if (error.code === 'ENOENT') invalid();
50
- throw error;
51
- }
52
- try {
53
- if (fs.readdirSync(base).some(name => name !== 'v0')) invalid();
54
- const version = directory(root, ['creation-requests', 'v0']);
55
- if (fs.readdirSync(version).some(name => !['root.json', 'requests'].includes(name)
56
- && !/^root\.json\.tmp-[1-9][0-9]*-[a-f0-9]{12}$/.test(name))) invalid();
57
- const marker = jsonFile(path.join(version, 'root.json'));
58
- if (!keys(marker, ['schemaVersion', 'kind', 'rootId', 'createdAt']) || marker.schemaVersion !== 0
59
- || marker.kind !== 'iris-creation-requests' || !UUID.test(marker.rootId) || !iso(marker.createdAt)) invalid();
60
- directory(root, ['creation-requests', 'v0', 'requests']);
61
- return copy(marker);
62
- } catch (error) { if (error.code === 'ENOENT') invalid(); throw error; }
36
+ return inspectLocalRequestRoot(root, { allowAbsent });
63
37
  }
64
38
 
65
39
  export function initializeCreationRoot(root) {
66
- const existing = inspectCreationRoot(root, { allowAbsent: true });
67
- if (existing) { listCreationRequests(root); return existing; }
68
- inspectFlowRoot(root);
69
- if (fs.readdirSync(root).some(name => !['flow-store', WRITER_LEASE_DIR, WRITER_INTENT_FILE].includes(name))
70
- || fs.readdirSync(path.join(root, 'flow-store')).some(name => name !== 'v0')
71
- || fs.readdirSync(path.join(root, 'flow-store/v0')).some(name => !['root.json', 'runs'].includes(name))
72
- || fs.readdirSync(path.join(root, 'flow-store/v0/runs')).length) {
73
- creationFail('IRIS_CREATION_ROOT_NOT_EMPTY', '请求区仅在新的空 Flow 根初始化;不转换已有创作数据');
74
- }
75
- const marker = { schemaVersion: 0, kind: 'iris-creation-requests', rootId: crypto.randomUUID(), createdAt: new Date().toISOString() };
76
- directory(root, ['creation-requests', 'v0', 'requests'], true);
77
- atomicCreatePrivate(path.join(root, 'creation-requests/v0/root.json'), JSON.stringify(marker, null, 2) + '\n');
78
- return copy(marker);
40
+ const marker = initializeLocalRequestRoot(root, 0);
41
+ listCreationRequests(root);
42
+ return marker;
79
43
  }
80
44
 
81
45
  export function creationRequestId(marker, kind, key) {
82
46
  if (!UUID.test(key)) creationFail('IRIS_CREATION_KEY_INVALID', 'Idempotency-Key 须为单个小写 UUIDv4');
83
- if (!['image-import', 'flow-create'].includes(kind)) throw new TypeError('未知本地创建类型');
47
+ if (!CREATION_KINDS.includes(kind)) throw new TypeError('未知本地创建类型');
84
48
  return 'req_' + hash(JSON.stringify(['creation-requests/v0', marker.rootId, kind, key]));
85
49
  }
86
50
  export function creationInputDigest(kind, input) {
87
- return 'sha256:' + hash(JSON.stringify(kind === 'image-import'
88
- ? ['image-import/v0', input.mediaType, input.size, input.sha256]
89
- : ['flow-create/v0', 'image-edit/v0', input.source_artifact_id, input.model_ref, input.prompt, input.size ?? null, 1]));
51
+ if (['speech-create', 'transcription-create'].includes(kind)) return 'sha256:' + hash(JSON.stringify([kind + '/v0',
52
+ normalizeAudioFlowInput(kind === 'speech-create' ? 'speech/v0' : 'transcribe/v0', input)]));
53
+ return 'sha256:' + hash(JSON.stringify(isImportCreation(kind)
54
+ ? [kind + '/v0', input.mediaType, input.size, input.sha256]
55
+ : kind === 'generation-create' ? ['generation-create/v0', 'image-generate/v0', input.model_ref, input.prompt, input.size ?? null, 1,
56
+ ...(input.workflow_parameters !== undefined ? [['comfyui-parameters/v0', normalizeComfyParameters(input.workflow_parameters)]] : [])]
57
+ : ['flow-create/v0', 'image-edit/v0', input.source_artifact_id, input.model_ref, input.prompt, input.size ?? null, 1]));
90
58
  }
91
59
  function validate(record, id, marker) {
92
60
  if (!keys(record, ['schemaVersion', 'id', 'rootId', 'kind', 'inputDigest', 'createdAt', 'intent', 'state', 'completedAt', 'result'],
@@ -96,14 +64,19 @@ function validate(record, id, marker) {
96
64
  || !['prepared', 'succeeded'].includes(record.state)) invalid();
97
65
  const intent = record.intent;
98
66
  let expectedResult, expectedDigest;
99
- if (record.kind === 'image-import') {
67
+ if (isImportCreation(record.kind)) {
68
+ const audio = record.kind === 'audio-import';
100
69
  if (!keys(intent, ['artifactId', 'mediaType', 'size', 'sha256', 'metadata', 'createdAt'])
101
- || !ARTIFACT.test(intent.artifactId) || !TYPES.has(intent.mediaType) || !SHA.test(intent.sha256)
102
- || !Number.isSafeInteger(intent.size) || intent.size < 1 || intent.size > MAX_IMAGE
103
- || !keys(intent.metadata, ['width', 'height']) || !['width', 'height'].every(key => Number.isSafeInteger(intent.metadata[key]) && intent.metadata[key] > 0)) invalid();
70
+ || audio && marker.schemaVersion !== 3
71
+ || !ARTIFACT.test(intent.artifactId) || !(audio ? Object.hasOwn(AUDIO_TYPES, intent.mediaType) : TYPES.has(intent.mediaType)) || !SHA.test(intent.sha256)
72
+ || !Number.isSafeInteger(intent.size) || intent.size < 1 || intent.size > (audio ? MAX_AUDIO_INPUT_BYTES : MAX_IMAGE)
73
+ || (audio ? !keys(intent.metadata, []) : !keys(intent.metadata, ['width', 'height']) || !['width', 'height'].every(key => Number.isSafeInteger(intent.metadata[key]) && intent.metadata[key] > 0))) invalid();
104
74
  expectedDigest = creationInputDigest(record.kind, intent);
105
75
  expectedResult = { artifactId: intent.artifactId };
106
- } else if (record.kind === 'flow-create') {
76
+ } else if (['flow-create', 'generation-create', 'speech-create', 'transcription-create'].includes(record.kind)) {
77
+ if (record.kind === 'generation-create' ? ![2, 3].includes(marker.schemaVersion) || intent.recipe !== 'image-generate/v0'
78
+ : record.kind === 'flow-create' ? intent.recipe !== undefined
79
+ : marker.schemaVersion !== 3 || intent.recipe !== (record.kind === 'speech-create' ? 'speech/v0' : 'transcribe/v0')) invalid();
107
80
  try { validateFlowCreation(intent); } catch (_) { invalid(); }
108
81
  expectedDigest = creationInputDigest(record.kind, intent.input);
109
82
  expectedResult = { runId: intent.runId };
@@ -118,7 +91,7 @@ function validate(record, id, marker) {
118
91
  export function readCreationRequest(root, id, marker = inspectCreationRoot(root)) {
119
92
  if (!ID.test(id)) invalid();
120
93
  let record;
121
- try { record = jsonFile(path.join(root, 'creation-requests/v0/requests', id + '.json')); }
94
+ try { record = jsonFile(path.join(requestDirectory(root, marker, 'requests'), id + '.json')); }
122
95
  catch (error) { if (error.code === 'ENOENT') return null; throw error; }
123
96
  return copy(validate(record, id, marker));
124
97
  }
@@ -126,7 +99,7 @@ export function listCreationRequests(root) {
126
99
  const marker = inspectCreationRoot(root, { allowAbsent: true });
127
100
  if (!marker) return [];
128
101
  const records = [];
129
- for (const name of fs.readdirSync(path.join(root, 'creation-requests/v0/requests'))) {
102
+ for (const name of fs.readdirSync(requestDirectory(root, marker, 'requests'))) {
130
103
  if (/^req_[a-f0-9]{64}\.json\.tmp-[1-9][0-9]*-[a-f0-9]{12}$/.test(name)) continue;
131
104
  if (!/^req_[a-f0-9]{64}\.json$/.test(name)) invalid();
132
105
  const record = readCreationRequest(root, name.slice(0, -5), marker);
@@ -138,22 +111,23 @@ export function listCreationRequests(root) {
138
111
  export function publishCreationRequest(root, record) {
139
112
  const marker = inspectCreationRoot(root);
140
113
  validate(record, record.id, marker);
141
- atomicCreatePrivate(path.join(root, 'creation-requests/v0/requests', record.id + '.json'), JSON.stringify(record, null, 2) + '\n');
114
+ atomicCreatePrivate(path.join(requestDirectory(root, marker, 'requests'), record.id + '.json'), JSON.stringify(record, null, 2) + '\n');
142
115
  return copy(record);
143
116
  }
144
117
  export function completeCreationRequest(root, record) {
145
118
  const current = readCreationRequest(root, record.id);
146
119
  if (!current || JSON.stringify(current) !== JSON.stringify(record) || current.state !== 'prepared') invalid();
147
120
  const finished = { ...record, state: 'succeeded', completedAt: new Date().toISOString(),
148
- result: record.kind === 'image-import' ? { artifactId: record.intent.artifactId } : { runId: record.intent.runId } };
149
- validate(finished, record.id, inspectCreationRoot(root));
150
- atomicWritePrivate(path.join(root, 'creation-requests/v0/requests', record.id + '.json'), JSON.stringify(finished, null, 2) + '\n');
121
+ result: isImportCreation(record.kind) ? { artifactId: record.intent.artifactId } : { runId: record.intent.runId } };
122
+ const marker = inspectCreationRoot(root);
123
+ validate(finished, record.id, marker);
124
+ atomicWritePrivate(path.join(requestDirectory(root, marker, 'requests'), record.id + '.json'), JSON.stringify(finished, null, 2) + '\n');
151
125
  return copy(finished);
152
126
  }
153
127
  export function creationReferences(root) {
154
128
  return listCreationRequests(root).filter(record => record.state === 'prepared').map(record => ({
155
- requestId: record.id, taskIds: record.kind === 'flow-create' ? [record.intent.taskId] : [],
156
- artifactIds: record.kind === 'image-import' ? [record.intent.artifactId]
157
- : [record.intent.source.artifactId, record.intent.comparisonArtifactId]
129
+ requestId: record.id, taskIds: !isImportCreation(record.kind) ? [record.intent.taskId] : [],
130
+ artifactIds: isImportCreation(record.kind) ? [record.intent.artifactId]
131
+ : [record.intent.source?.artifactId, record.intent.comparisonArtifactId].filter(Boolean)
158
132
  }));
159
133
  }
@@ -133,12 +133,15 @@ function dshVisionPort(llm, defaults) {
133
133
  if (chunk?.type === 'finish') {
134
134
  if (chunk.reason?.kind === 'error') throw visionRouteError('DSH 视觉调用失败;请检查宿主模型状态');
135
135
  if (chunk.reason?.kind === 'aborted') throw visionRouteError('DSH 视觉调用已取消');
136
+ if (['length', 'max-tokens', 'tool-calls', 'content-blocked'].includes(chunk.reason?.kind)) {
137
+ throw visionRouteError('DSH 视觉结果未完整结束:' + chunk.reason.kind);
138
+ }
136
139
  }
137
140
  const text = typeof chunk?.delta === 'string'
138
141
  ? chunk.delta
139
142
  : (chunk?.type === 'text-delta' ? String(chunk.text || '') : '');
140
143
  if (text) chunks.push(text);
141
- if (chunks.reduce((sum, item) => sum + item.length, 0) > 6000) break;
144
+ if (chunks.reduce((sum, item) => sum + item.length, 0) > 6000) throw visionRouteError('DSH 视觉结果超过长度限制,未返回部分结果');
142
145
  }
143
146
  return chunks.join('').trim();
144
147
  }
@@ -0,0 +1,66 @@
1
+ import { createCoreServiceClient } from './core-service-client.js';
2
+ import { bridgeToolsForScopes, callCoreBridgeTool } from './core-bridge-tools.js';
3
+ import { requireHostPort } from './host-contract.js';
4
+
5
+ /** Optional shared-Flow connection. Native DSH ports stay in the host; no remote ctx or local execution fallback. */
6
+ export function createDshServiceBridge({ connectionFile, requestedScopes = ['read'], getHost, harnessVersion = 'unknown', fetch } = {}) {
7
+ if (typeof getHost !== 'function') throw new TypeError('DSH 服务桥需要真实 Host 工厂');
8
+ const client = createCoreServiceClient({ connectionFile, requestedScopes, harnessVersion, fetch });
9
+ const lifetime = new AbortController(), jobs = new Set();
10
+ const toolDisposers = [];
11
+ let state = 'new', disposal, remoteDisconnect = 'not_connected';
12
+ async function start() {
13
+ if (state !== 'new') throw new Error('DSH Core 服务桥只能启动一次');
14
+ state = 'connecting';
15
+ try {
16
+ await client.connect();
17
+ if (client.snapshot().adapterId !== 'iris.dsh-service') throw Object.assign(new Error('连接文件不是 DSH 适配授权'), { code: 'IRIS_CONNECTION_INPUT_INVALID' });
18
+ const selected = client.snapshot();
19
+ if (selected.capabilities.some(id => ['tts', 'transcribe'].includes(id))) throw Object.assign(new Error('DSH 原生共享桥尚未接入音频附件交付;请为此桥选择图片功能,音频使用通用 MCP 或 Core 工作台'), { code: 'IRIS_DSH_AUDIO_NOT_SUPPORTED' });
20
+ const tools = requireHostPort(getHost(), 'tools', '注册 Core 服务工具');
21
+ for (const tool of bridgeToolsForScopes(selected.scopes, selected.capabilities, selected.tools, selected.pluginOperations)) {
22
+ const remove = tools.register({ name: tool.name, description: tool.description, parameters: tool.parameters,
23
+ isConcurrencySafe: () => tool.scope === 'read' || tool.scope === 'stop',
24
+ output: { schema: { type: 'object' }, render: (_args, result) => result.blocks },
25
+ async execute(args, exec = {}) {
26
+ const signal = AbortSignal.any([lifetime.signal, ...(exec.signal ? [exec.signal] : [])]);
27
+ const job = (async () => {
28
+ signal.throwIfAborted();
29
+ const result = await callCoreBridgeTool(client, tool.name, args, { signal });
30
+ if (!result.image) return { value: result.value, blocks: [{ type: 'text', text: JSON.stringify(result.value) }] };
31
+ const attachments = requireHostPort(getHost(), 'attachments', '显示 Core 图片');
32
+ const attachment = await attachments.saveImage({ data: new Uint8Array(result.image.bytes), mediaType: result.image.mediaType,
33
+ name: 'iris-core-' + result.image.artifactId + '.' + (result.image.mediaType === 'image/jpeg' ? 'jpg' : result.image.mediaType.slice(6)) });
34
+ signal.throwIfAborted();
35
+ if (!attachment?.attachmentId) throw Object.assign(new Error('DSH 没有返回图片附件身份'), { code: 'IRIS_DSH_PROJECTION_FAILED' });
36
+ return { artifactId: result.image.artifactId, blocks: [{ type: 'image', attachment }] };
37
+ })();
38
+ jobs.add(job); try { return await job; } finally { jobs.delete(job); }
39
+ }
40
+ });
41
+ if (typeof remove === 'function') toolDisposers.push(remove);
42
+ }
43
+ state = 'open'; return snapshot();
44
+ } catch (error) {
45
+ try { await dispose(); }
46
+ catch (cleanup) { throw new AggregateError([error, cleanup], 'DSH 服务桥启动与释放均失败'); }
47
+ throw error;
48
+ }
49
+ }
50
+ function snapshot() { return { state, connection: client.snapshot(), operations: jobs.size, remoteDisconnect }; }
51
+ function dispose() {
52
+ if (disposal) return disposal;
53
+ state = 'closing'; lifetime.abort();
54
+ disposal = (async () => {
55
+ const errors = [];
56
+ for (const remove of toolDisposers.splice(0).reverse()) { try { await remove(); } catch (error) { errors.push(error); } }
57
+ await Promise.allSettled([...jobs]);
58
+ try { remoteDisconnect = (await client.dispose()).remoteDisconnect; } catch (error) { errors.push(error); }
59
+ state = 'closed';
60
+ if (errors.length) throw new AggregateError(errors, 'DSH 服务桥释放失败');
61
+ return snapshot();
62
+ })();
63
+ return disposal;
64
+ }
65
+ return Object.freeze({ start, snapshot, dispose });
66
+ }
@@ -0,0 +1,43 @@
1
+ // Runner ownership uses the existing Core lease protocol in its own private directory.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { readWriterLease, WRITER_LEASE_DIR, WRITER_INTENT_FILE } from './core-writer-lease.js';
5
+
6
+ const UUID = '[a-f0-9-]{36}';
7
+ const FILE = new RegExp('^(?:' + WRITER_INTENT_FILE.replaceAll('.', '\\.')
8
+ + '(?:\\.prepare-[1-9][0-9]*-[a-f0-9]{12})?|' + WRITER_LEASE_DIR.replaceAll('.', '\\.')
9
+ + '\\.recovery-' + UUID + '\\.json)$');
10
+ const RETIRED = new RegExp('^' + WRITER_LEASE_DIR.replaceAll('.', '\\.') + '\\.released-' + UUID + '$');
11
+ const invalid = () => { throw Object.assign(new Error('执行所有权目录未知或损坏;已保留现场'), { code: 'IRIS_EXECUTION_LEASE_INVALID' }); };
12
+
13
+ export function inspectRunnerArea(directory) {
14
+ function regular(file) {
15
+ const stat = fs.lstatSync(file);
16
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 64 * 1024) invalid();
17
+ }
18
+ function ownerDirectory(file, audit = false) {
19
+ const stat = fs.lstatSync(file);
20
+ if (!stat.isDirectory() || stat.isSymbolicLink()) invalid();
21
+ for (const name of fs.readdirSync(file)) {
22
+ if (name !== 'owner.json' && !/^\.owner\.stale-[a-f0-9-]{36}\.json$/.test(name)
23
+ && !(audit && new RegExp('^' + WRITER_LEASE_DIR.replaceAll('.', '\\.') + '\\.recovery-' + UUID + '\\.json$').test(name))) invalid();
24
+ regular(path.join(file, name));
25
+ }
26
+ }
27
+ for (const name of fs.readdirSync(directory)) {
28
+ const file = path.join(directory, name);
29
+ if (name === WRITER_LEASE_DIR || RETIRED.test(name)) ownerDirectory(file);
30
+ else if (FILE.test(name)) regular(file);
31
+ else if (name === '.iris-runtime-recovery-audit-v0') {
32
+ const stat = fs.lstatSync(file);
33
+ if (!stat.isDirectory() || stat.isSymbolicLink()) invalid();
34
+ for (const entry of fs.readdirSync(file)) {
35
+ if (/^lease_recovery_[a-f0-9]{24}\.json$/.test(entry)) regular(path.join(file, entry));
36
+ else if (/^lease_recovery_[a-f0-9]{24}\.lease$/.test(entry)) ownerDirectory(path.join(file, entry), true);
37
+ else invalid();
38
+ }
39
+ } else invalid();
40
+ }
41
+ try { return readWriterLease(directory); }
42
+ catch (_) { invalid(); }
43
+ }