@mokuyoaxis/dsh-iris 0.2.6 → 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 (177) hide show
  1. package/CHANGELOG.md +103 -1
  2. package/README.en.md +104 -304
  3. package/README.md +101 -302
  4. package/bin/dsh-iris.js +446 -64
  5. package/docs/AGENT_CORE_QUICKSTART.md +64 -0
  6. package/docs/AGENT_QUICKSTART.md +7 -5
  7. package/docs/ARCHITECTURE.md +20 -4
  8. package/docs/AUDIO_FLOWS.md +71 -0
  9. package/docs/CLI_MANAGEMENT.md +1 -1
  10. package/docs/CONTACT_SHEET.md +62 -0
  11. package/docs/CORE_CONNECTIONS.md +99 -0
  12. package/docs/CORE_QUICKSTART.md +108 -0
  13. package/docs/CORE_RUNTIME_CONTRACT.md +41 -5
  14. package/docs/CORE_SERVICE.md +97 -0
  15. package/docs/CORE_SKILLS.md +94 -0
  16. package/docs/DSH_COMPATIBILITY.md +14 -4
  17. package/docs/DSH_CORE_MIGRATION.md +4 -2
  18. package/docs/DSH_PROVIDER_CONNECTIONS.md +1 -1
  19. package/docs/DSH_RC2_ADAPTATION.md +1 -1
  20. package/docs/DSH_RUNTIME.md +40 -0
  21. package/docs/FUNCTION_MODEL_POOLS.md +1 -1
  22. package/docs/HEADLESS_CLI.md +12 -4
  23. package/docs/HOST_DOCTOR.md +13 -1
  24. package/docs/IMAGE_FLOW.md +75 -0
  25. package/docs/IMAGE_GENERATION.md +127 -0
  26. package/docs/LOCAL_API.md +62 -0
  27. package/docs/LOCAL_API_CONTRACT.md +140 -0
  28. package/docs/LOCAL_CREATION.md +56 -0
  29. package/docs/LOCAL_OPERATIONS.md +46 -0
  30. package/docs/LOCAL_SELECTION.md +57 -0
  31. package/docs/LOCAL_WEB.md +201 -0
  32. package/docs/MCP.md +191 -0
  33. package/docs/MUSIC.md +2 -0
  34. package/docs/PANEL_EXTENSIONS.md +64 -0
  35. package/docs/PLUGINS.md +189 -0
  36. package/docs/PLUGIN_STARTUP.md +100 -0
  37. package/docs/PROVIDER_ADAPTER_CONTRACT.md +1 -1
  38. package/docs/PROVIDER_EXTENSIONS.md +78 -0
  39. package/docs/PROVIDER_HEALTH.md +4 -2
  40. package/docs/PROVIDER_SETTINGS.md +85 -0
  41. package/docs/PROVIDER_SUBMISSION_CONTRACT.md +6 -0
  42. package/docs/ROADMAP.md +28 -6
  43. package/docs/SECURITY.md +9 -1
  44. package/docs/SYNC_DELIVERY_RECOVERY.md +2 -0
  45. package/docs/VISION_CLI.md +2 -2
  46. package/docs/WEB_PROTOTYPE.md +62 -0
  47. package/docs/WEB_RECOVERY.md +46 -0
  48. package/docs/WORKBENCH_LOGIN.md +63 -0
  49. package/docs/assets/screenshots/core-connections-preview.webp +0 -0
  50. package/docs/assets/screenshots/core-model-pools-preview.webp +0 -0
  51. package/docs/assets/screenshots/core-overview-preview.webp +0 -0
  52. package/docs/assets/screenshots/core-spectrum-preview.webp +0 -0
  53. package/docs/assets/screenshots/core-studio-dark-preview.webp +0 -0
  54. package/docs/assets/screenshots/core-studio-mobile-preview.webp +0 -0
  55. package/docs/assets/screenshots/core-studio-preview.webp +0 -0
  56. package/docs/releases/0.2.7.md +62 -0
  57. package/docs/releases/0.3.0-alpha.1.md +51 -0
  58. package/docs/screenshots.md +41 -1
  59. package/lib/actions.js +132 -70
  60. package/lib/adapters.js +20 -11
  61. package/lib/api.js +38 -38
  62. package/lib/application-runtime.js +213 -0
  63. package/lib/audio-flow-input.js +37 -0
  64. package/lib/client.js +434 -55
  65. package/lib/comfyui-adapter.js +94 -0
  66. package/lib/comfyui-client.js +51 -0
  67. package/lib/comfyui-inspection.js +73 -0
  68. package/lib/comfyui-workflow.js +94 -0
  69. package/lib/config.js +36 -1
  70. package/lib/configured-image-runtime.js +50 -0
  71. package/lib/connection-skill.js +89 -0
  72. package/lib/connection-template.js +23 -0
  73. package/lib/core-artifact-store.js +21 -2
  74. package/lib/core-bridge-selection.js +31 -0
  75. package/lib/core-bridge-tools.js +83 -0
  76. package/lib/core-capability-catalog.js +182 -0
  77. package/lib/core-connection-diagnostics.js +49 -0
  78. package/lib/core-connection-store.js +277 -0
  79. package/lib/core-connections.js +225 -0
  80. package/lib/core-maintenance.js +31 -2
  81. package/lib/core-runtime.js +20 -0
  82. package/lib/core-service-client.js +170 -0
  83. package/lib/core-tasks.js +84 -13
  84. package/lib/core-writer-lease.js +16 -0
  85. package/lib/creation-request-store.js +133 -0
  86. package/lib/doctor.js +12 -1
  87. package/lib/dsh-core-adapter.js +666 -572
  88. package/lib/dsh-host-adapter.js +8 -2
  89. package/lib/dsh-service-bridge.js +66 -0
  90. package/lib/execution-ownership.js +43 -0
  91. package/lib/flow-store.js +290 -0
  92. package/lib/generation-input.js +10 -1
  93. package/lib/headless-vision.js +7 -7
  94. package/lib/image-edit-input.js +1 -1
  95. package/lib/image-flow.js +232 -0
  96. package/lib/index.js +94 -68
  97. package/lib/local-api-projection.js +32 -0
  98. package/lib/local-api-token-directory.js +31 -0
  99. package/lib/local-api.js +1083 -0
  100. package/lib/local-creation.js +150 -0
  101. package/lib/local-operations.js +36 -0
  102. package/lib/local-request-root.js +86 -0
  103. package/lib/local-web-assets.js +37 -0
  104. package/lib/mcp-bridge.js +58 -0
  105. package/lib/mcp-check.js +26 -0
  106. package/lib/mcp-config.js +42 -0
  107. package/lib/mcp-delivery.js +46 -0
  108. package/lib/mcp-http.js +101 -0
  109. package/lib/mcp-server.js +47 -0
  110. package/lib/model-directory-service.js +16 -6
  111. package/lib/model-directory.js +3 -3
  112. package/lib/models.js +2 -2
  113. package/lib/music-transport.js +2 -1
  114. package/lib/operation-runner.js +236 -0
  115. package/lib/operation-store.js +216 -0
  116. package/lib/panel-extension.js +36 -0
  117. package/lib/plugin-contract.js +194 -0
  118. package/lib/plugin-execution.js +51 -0
  119. package/lib/plugin-launch.js +86 -0
  120. package/lib/plugin-operation-store.js +101 -0
  121. package/lib/plugin-operations.js +28 -0
  122. package/lib/plugin-registry.js +109 -0
  123. package/lib/plugin-selection.js +15 -0
  124. package/lib/plugin-source-image.js +5 -0
  125. package/lib/plugin-web-assets.js +54 -0
  126. package/lib/plugins.d.ts +288 -0
  127. package/lib/plugins.js +58 -0
  128. package/lib/private-storage.js +12 -0
  129. package/lib/provider-adapter.js +5 -1
  130. package/lib/provider-adapters.js +56 -18
  131. package/lib/provider-catalog.js +40 -29
  132. package/lib/provider-config-service.js +105 -23
  133. package/lib/provider-diagnostics.js +56 -0
  134. package/lib/provider-extensions.js +29 -0
  135. package/lib/provider-health.js +8 -1
  136. package/lib/provider-monitor.js +79 -0
  137. package/lib/provider-protocol.js +70 -20
  138. package/lib/provider-registry.js +118 -0
  139. package/lib/provider-response.js +28 -0
  140. package/lib/provider-settings.js +134 -0
  141. package/lib/provider-task-runner.js +40 -7
  142. package/lib/render-address.js +35 -0
  143. package/lib/render.js +52 -13
  144. package/lib/skill-installation.js +211 -0
  145. package/lib/vision-model-routing.js +3 -0
  146. package/lib/web/app.js +642 -0
  147. package/lib/web/appearance.js +111 -0
  148. package/lib/web/comfyui.js +146 -0
  149. package/lib/web/connections.js +377 -0
  150. package/lib/web/creation.js +355 -0
  151. package/lib/web/flow-view.js +33 -0
  152. package/lib/web/gallery.js +132 -0
  153. package/lib/web/index.html +291 -0
  154. package/lib/web/media-client.js +204 -0
  155. package/lib/web/plugin-panel.js +221 -0
  156. package/lib/web/plugin-surface.css +17 -0
  157. package/lib/web/providers.js +492 -0
  158. package/lib/web/recovery.js +105 -0
  159. package/lib/web/startup.js +18 -0
  160. package/lib/web/styles.css +518 -0
  161. package/lib/web/transport.js +96 -0
  162. package/lib/web/workbench-login.js +165 -0
  163. package/lib/workbench-artifacts.js +1 -2
  164. package/lib/workbench-auth-cli.js +47 -0
  165. package/lib/workbench-auth.js +200 -0
  166. package/lib/workbench-management.js +8 -9
  167. package/package.json +22 -6
  168. package/plugins/contact-sheet/index.d.mts +4 -0
  169. package/plugins/contact-sheet/index.mjs +33 -0
  170. package/plugins/contact-sheet/web/board.css +14 -0
  171. package/plugins/contact-sheet/web/board.mjs +93 -0
  172. package/skills/iris-core/SKILL.md +44 -0
  173. package/skills/iris-core/agents/openai.yaml +4 -0
  174. package/skills/iris-core/references/cli.md +95 -0
  175. package/skills/iris-core/references/comfyui.md +69 -0
  176. package/skills/iris-core/references/tasks.md +60 -0
  177. package/user_guide.md +19 -9
@@ -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
@@ -3,11 +3,13 @@
3
3
  import crypto from 'node:crypto';
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
- import { atomicWritePrivate, ensurePrivateDir } from './private-storage.js';
6
+ import { atomicCreatePrivate, atomicWritePrivate, ensurePrivateDir } from './private-storage.js';
7
7
  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 { flowStep, flowStepId, flowTaskBinding, flowCapability, readFlowRun, validFlowBinding } from './flow-store.js';
12
+ import { checkedPluginDependency, assertPluginDependencyMatches, PluginContractError } from './plugin-contract.js';
11
13
 
12
14
  const STORE_DIR = 'task-store';
13
15
  const STORE_VERSION = 'v0';
@@ -110,7 +112,9 @@ function validateRecord(record, expectedId) {
110
112
  || (record.deliveryCache !== undefined && record.deliveryCache !== true)
111
113
  || (record.providerBinding !== undefined && !PROVIDER_BINDING.test(record.providerBinding))
112
114
  || (record.retriedFrom !== undefined && !TASK_ID.test(record.retriedFrom))
113
- || (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)))) {
114
118
  fail('IRIS_TASK_INVALID', 'Task 记录结构无效');
115
119
  }
116
120
  if (record.attempts.some((attempt, index) => !attempt || !ATTEMPT_ID.test(attempt.id)
@@ -121,6 +125,15 @@ function validateRecord(record, expectedId) {
121
125
  || !validAttemptStageTimestamps(attempt.stageTimestamps))) {
122
126
  fail('IRIS_TASK_INVALID', 'Task Attempt 记录结构无效');
123
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
+ }
124
137
  const violations = semanticViolations(record);
125
138
  if (violations.length) fail('IRIS_TASK_INVALID', 'Task 事实语义冲突:' + violations.join(', '));
126
139
  return record;
@@ -143,11 +156,12 @@ function readRecord(dataRoot, id) {
143
156
  return validateRecord(record, id);
144
157
  }
145
158
 
146
- function writeRecord(dataRoot, record) {
159
+ function writeRecord(dataRoot, record, exclusive = false) {
147
160
  validateRecord(record, record.id);
148
161
  try {
149
162
  ensurePrivateDir(taskDirectory(dataRoot));
150
- atomicWritePrivate(taskFile(dataRoot, record.id), JSON.stringify(record, null, 2) + '\n');
163
+ const publish = exclusive ? atomicCreatePrivate : atomicWritePrivate;
164
+ publish(taskFile(dataRoot, record.id), JSON.stringify(record, null, 2) + '\n');
151
165
  } catch (error) {
152
166
  if (error instanceof CoreTaskError) throw error;
153
167
  fail('IRIS_TASK_WRITE_FAILED', 'Task 无法安全写入');
@@ -169,18 +183,18 @@ function safeError(error, overrides) {
169
183
  return providerErrorRecord(error, overrides);
170
184
  }
171
185
 
172
- export function createCoreTask(dataRoot, input = {}) {
186
+ function newTaskRecord(input, id) {
173
187
  const capability = String(input.capability || '').trim();
174
188
  if (!CAPABILITIES.has(capability)) fail('IRIS_TASK_INPUT_INVALID', 'Task capability 无效');
175
189
  const retriedFrom = input.retriedFrom === undefined ? undefined : String(input.retriedFrom);
176
190
  if (retriedFrom !== undefined && !TASK_ID.test(retriedFrom)) fail('IRIS_TASK_INPUT_INVALID', 'retriedFrom 必须是合法 Task ID');
177
191
  const sourceArtifactId = input.sourceArtifactId;
178
- 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');
179
193
  const now = new Date().toISOString();
180
- return writeRecord(dataRoot, {
194
+ return {
181
195
  schemaVersion: 0,
182
196
  semanticsVersion: 2,
183
- id: 'task_' + crypto.randomBytes(12).toString('hex'),
197
+ id,
184
198
  capability,
185
199
  status: 'running',
186
200
  phase: 'queued',
@@ -197,7 +211,51 @@ export function createCoreTask(dataRoot, input = {}) {
197
211
  // D4 retry as new task:显式单向关系(新任务指向旧任务),绝不复制 Prompt。
198
212
  ...(retriedFrom !== undefined ? { retriedFrom } : {}),
199
213
  ...(sourceArtifactId !== undefined ? { sourceArtifactId } : {})
200
- });
214
+ };
215
+ }
216
+
217
+ export function createCoreTask(dataRoot, input = {}) {
218
+ return writeRecord(dataRoot, newTaskRecord(input, 'task_' + crypto.randomBytes(12).toString('hex')));
219
+ }
220
+
221
+ /** Flow 只能接回自己持久预留的 Task;原有 Task 没有该字段,读取语义不变。 */
222
+ export function assertFlowTaskMatches(task, run) {
223
+ const providerId = parseModelRef(run.input.model_ref).providerId;
224
+ if (task.id !== flowStep(run).taskId || task.capability !== flowCapability(run) || task.sourceArtifactId !== run.source?.artifactId
225
+ || !validFlowBinding(task.flowBinding) || task.flowBinding.runId !== run.id
226
+ || task.flowBinding.stepId !== flowStepId(run)
227
+ || task.flowBinding.inputDigest !== run.inputDigest
228
+ || task.attempts.some(attempt => attempt.providerId !== providerId
229
+ || attempt.model !== run.input.model_ref || attempt.providerBinding !== run.providerBinding)
230
+ || (task.attempts.length && (task.providerId !== providerId || task.modelRef !== run.input.model_ref || task.providerBinding !== run.providerBinding))) {
231
+ fail('IRIS_FLOW_TASK_CONFLICT', '流程与原 Task 的身份、输入或模型绑定冲突');
232
+ }
233
+ return task;
234
+ }
235
+
236
+ export function findFlowTask(dataRoot, run) {
237
+ for (const relative of [STORE_DIR, STORE_DIR + '/' + STORE_VERSION, STORE_DIR + '/' + STORE_VERSION + '/tasks']) {
238
+ try {
239
+ const stat = fs.lstatSync(path.join(dataRoot, relative));
240
+ if (!stat.isDirectory() || stat.isSymbolicLink()) fail('IRIS_FLOW_TASK_CONFLICT', '流程 Task 目录不安全');
241
+ } catch (error) { if (error.code !== 'ENOENT') throw error; }
242
+ }
243
+ try { fs.lstatSync(taskFile(dataRoot, flowStep(run).taskId)); }
244
+ catch (error) {
245
+ if (error.code !== 'ENOENT') throw error;
246
+ if (flowStep(run).claimed) fail('IRIS_FLOW_TASK_CONFLICT', '流程已建立的 Task 丢失,禁止重建或重新生成');
247
+ return null;
248
+ }
249
+ return assertFlowTaskMatches(readRecord(dataRoot, flowStep(run).taskId), run);
250
+ }
251
+
252
+ /** 仅从已提交的 Run 读取 ID;排他发布,新旧身份均不能覆盖。 */
253
+ export function claimFlowTask(dataRoot, runId) {
254
+ const run = readFlowRun(dataRoot, runId);
255
+ const previous = findFlowTask(dataRoot, run);
256
+ if (previous) return previous;
257
+ return writeRecord(dataRoot, { ...newTaskRecord({ capability: flowCapability(run), sourceArtifactId: run.source?.artifactId }, flowStep(run).taskId),
258
+ flowBinding: flowTaskBinding(run) }, true);
201
259
  }
202
260
 
203
261
  export function inspectCoreTask(dataRoot, id) {
@@ -256,9 +314,11 @@ export function beginCoreAttempt(dataRoot, taskId, fields = {}) {
256
314
  const providerId = String(fields.providerId || '').trim();
257
315
  const model = String(fields.model || '').trim();
258
316
  const parsed = parseModelRef(model);
317
+ const pluginDependency = fields.pluginDependency === undefined ? undefined : checkedPluginDependency(fields.pluginDependency);
259
318
  if (!providerId || !parsed || parsed.providerId !== providerId
260
319
  || (fields.providerBinding !== undefined && !PROVIDER_BINDING.test(fields.providerBinding))
261
- || (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))) {
262
322
  fail('IRIS_ATTEMPT_INPUT_INVALID', 'Attempt 的 providerId 与复合 model 必须一致');
263
323
  }
264
324
  let created;
@@ -279,7 +339,8 @@ export function beginCoreAttempt(dataRoot, taskId, fields = {}) {
279
339
  startedAt: submitStartedAt,
280
340
  stageTimestamps: { queuedAt, submitStartedAt },
281
341
  ...(fields.selectionReason !== undefined ? { selectionReason: fields.selectionReason } : {}),
282
- ...(fields.providerBinding !== undefined ? { providerBinding: fields.providerBinding } : {})
342
+ ...(fields.providerBinding !== undefined ? { providerBinding: fields.providerBinding } : {}),
343
+ ...(pluginDependency ? { pluginDependency } : {})
283
344
  };
284
345
  task.attempts.push(created);
285
346
  task.activeAttemptId = created.id;
@@ -287,6 +348,8 @@ export function beginCoreAttempt(dataRoot, taskId, fields = {}) {
287
348
  task.modelRef = model;
288
349
  if (created.providerBinding) task.providerBinding = created.providerBinding;
289
350
  else delete task.providerBinding;
351
+ if (created.pluginDependency) task.pluginDependency = created.pluginDependency;
352
+ else delete task.pluginDependency;
290
353
  task.phase = 'submitting';
291
354
  task.acceptance = 'none';
292
355
  task.watchState = 'idle';
@@ -366,7 +429,7 @@ export function activateCoreWatch(dataRoot, taskId) {
366
429
  });
367
430
  }
368
431
 
369
- export function recordCorePollResult(dataRoot, taskId, result) {
432
+ export function recordCorePollResult(dataRoot, taskId, result, { deliveryCache = false } = {}) {
370
433
  return mutate(dataRoot, taskId, (task) => {
371
434
  if (task.acceptance !== 'accepted' || !task.remoteTaskId) {
372
435
  fail('IRIS_TASK_NOT_OBSERVABLE', 'Task 缺少可观察的远端受理事实');
@@ -387,6 +450,7 @@ export function recordCorePollResult(dataRoot, taskId, result) {
387
450
  task.phase = 'running';
388
451
  task.outcome = 'succeeded';
389
452
  task.deliveryState = 'pending';
453
+ if (deliveryCache) task.deliveryCache = true;
390
454
  delete task.lastError;
391
455
  return;
392
456
  }
@@ -472,7 +536,7 @@ export function failCoreDelivery(dataRoot, taskId, error) {
472
536
  task.phase = 'terminal';
473
537
  task.watchState = 'idle';
474
538
  task.deliveryState = 'failed';
475
- 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' });
476
540
  task.finishedAt = new Date().toISOString();
477
541
  });
478
542
  }
@@ -524,6 +588,13 @@ export function recordCoreCancelResult(dataRoot, taskId, result) {
524
588
  */
525
589
  export function recoverCoreTask(dataRoot, taskId) {
526
590
  let current = readRecord(dataRoot, taskId);
591
+ // Flow 的异步完成结果同样先缓存,再记录 poll;中断时只恢复原结果,不重新查询/生成。
592
+ if (current.flowBinding && current.acceptance === 'accepted' && current.remoteTaskId
593
+ && current.outcome === 'none' && current.cancelState === 'none'
594
+ && readDeliveryCache(dataRoot, current, { allowAbsent: true })) {
595
+ recordCorePollResult(dataRoot, taskId, { kind: 'succeeded' }, { deliveryCache: true });
596
+ current = readRecord(dataRoot, taskId);
597
+ }
527
598
  if (current.phase === 'submitting' && current.acceptance === 'none') {
528
599
  const cache = readDeliveryCache(dataRoot, current, { allowAbsent: true });
529
600
  if (cache) {
@@ -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');
@@ -0,0 +1,133 @@
1
+ // Private local creation intents. No Host, HTTP, Provider or Runtime dependencies.
2
+ import crypto from 'node:crypto';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { atomicCreatePrivate, atomicWritePrivate } from './private-storage.js';
6
+ import { validateFlowCreation } from './flow-store.js';
7
+ import { inspectLocalRequestRoot, initializeLocalRequestRoot, requestDirectory } from './local-request-root.js';
8
+ import { normalizeComfyParameters } from './comfyui-workflow.js';
9
+ import { AUDIO_TYPES, MAX_AUDIO_INPUT_BYTES, normalizeAudioFlowInput } from './audio-flow-input.js';
10
+
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}$/;
12
+ const ID = /^req_[a-f0-9]{64}$/;
13
+ const ARTIFACT = /^artifact_[a-f0-9]{24}$/;
14
+ const SHA = /^[a-f0-9]{64}$/;
15
+ const DIGEST = /^sha256:[a-f0-9]{64}$/;
16
+ const TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
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);
20
+ const iso = value => typeof value === 'string' && Number.isFinite(Date.parse(value)) && new Date(value).toISOString() === value;
21
+ const hash = value => crypto.createHash('sha256').update(value).digest('hex');
22
+ const copy = value => structuredClone(value);
23
+ function keys(value, allowed, required = allowed) {
24
+ return value && typeof value === 'object' && !Array.isArray(value)
25
+ && Object.keys(value).every(key => allowed.includes(key)) && required.every(key => Object.hasOwn(value, key));
26
+ }
27
+ export function creationFail(code, message) { throw Object.assign(new Error(message), { code }); }
28
+ const invalid = () => creationFail('IRIS_CREATION_STORE_INVALID', '创建请求区未知、损坏或尚未完整初始化');
29
+ function jsonFile(file) {
30
+ const stat = fs.lstatSync(file);
31
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 128 * 1024) invalid();
32
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch (_) { invalid(); }
33
+ }
34
+
35
+ export function inspectCreationRoot(root, { allowAbsent = false } = {}) {
36
+ return inspectLocalRequestRoot(root, { allowAbsent });
37
+ }
38
+
39
+ export function initializeCreationRoot(root) {
40
+ const marker = initializeLocalRequestRoot(root, 0);
41
+ listCreationRequests(root);
42
+ return marker;
43
+ }
44
+
45
+ export function creationRequestId(marker, kind, key) {
46
+ if (!UUID.test(key)) creationFail('IRIS_CREATION_KEY_INVALID', 'Idempotency-Key 须为单个小写 UUIDv4');
47
+ if (!CREATION_KINDS.includes(kind)) throw new TypeError('未知本地创建类型');
48
+ return 'req_' + hash(JSON.stringify(['creation-requests/v0', marker.rootId, kind, key]));
49
+ }
50
+ export function creationInputDigest(kind, input) {
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]));
58
+ }
59
+ function validate(record, id, marker) {
60
+ if (!keys(record, ['schemaVersion', 'id', 'rootId', 'kind', 'inputDigest', 'createdAt', 'intent', 'state', 'completedAt', 'result'],
61
+ ['schemaVersion', 'id', 'rootId', 'kind', 'inputDigest', 'createdAt', 'intent', 'state'])
62
+ || record.schemaVersion !== 0 || !ID.test(id) || record.id !== id || record.rootId !== marker.rootId
63
+ || !DIGEST.test(record.inputDigest) || !iso(record.createdAt) || record.intent?.createdAt !== record.createdAt
64
+ || !['prepared', 'succeeded'].includes(record.state)) invalid();
65
+ const intent = record.intent;
66
+ let expectedResult, expectedDigest;
67
+ if (isImportCreation(record.kind)) {
68
+ const audio = record.kind === 'audio-import';
69
+ if (!keys(intent, ['artifactId', 'mediaType', 'size', 'sha256', 'metadata', 'createdAt'])
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();
74
+ expectedDigest = creationInputDigest(record.kind, intent);
75
+ expectedResult = { artifactId: intent.artifactId };
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();
80
+ try { validateFlowCreation(intent); } catch (_) { invalid(); }
81
+ expectedDigest = creationInputDigest(record.kind, intent.input);
82
+ expectedResult = { runId: intent.runId };
83
+ } else invalid();
84
+ if (record.inputDigest !== expectedDigest) invalid();
85
+ if (record.state === 'prepared') {
86
+ if (Object.hasOwn(record, 'completedAt') || Object.hasOwn(record, 'result')) invalid();
87
+ } else if (!iso(record.completedAt) || !keys(record.result, Object.keys(expectedResult))
88
+ || JSON.stringify(record.result) !== JSON.stringify(expectedResult)) invalid();
89
+ return record;
90
+ }
91
+ export function readCreationRequest(root, id, marker = inspectCreationRoot(root)) {
92
+ if (!ID.test(id)) invalid();
93
+ let record;
94
+ try { record = jsonFile(path.join(requestDirectory(root, marker, 'requests'), id + '.json')); }
95
+ catch (error) { if (error.code === 'ENOENT') return null; throw error; }
96
+ return copy(validate(record, id, marker));
97
+ }
98
+ export function listCreationRequests(root) {
99
+ const marker = inspectCreationRoot(root, { allowAbsent: true });
100
+ if (!marker) return [];
101
+ const records = [];
102
+ for (const name of fs.readdirSync(requestDirectory(root, marker, 'requests'))) {
103
+ if (/^req_[a-f0-9]{64}\.json\.tmp-[1-9][0-9]*-[a-f0-9]{12}$/.test(name)) continue;
104
+ if (!/^req_[a-f0-9]{64}\.json$/.test(name)) invalid();
105
+ const record = readCreationRequest(root, name.slice(0, -5), marker);
106
+ if (!record) invalid();
107
+ records.push(record);
108
+ }
109
+ return records;
110
+ }
111
+ export function publishCreationRequest(root, record) {
112
+ const marker = inspectCreationRoot(root);
113
+ validate(record, record.id, marker);
114
+ atomicCreatePrivate(path.join(requestDirectory(root, marker, 'requests'), record.id + '.json'), JSON.stringify(record, null, 2) + '\n');
115
+ return copy(record);
116
+ }
117
+ export function completeCreationRequest(root, record) {
118
+ const current = readCreationRequest(root, record.id);
119
+ if (!current || JSON.stringify(current) !== JSON.stringify(record) || current.state !== 'prepared') invalid();
120
+ const finished = { ...record, state: 'succeeded', completedAt: new Date().toISOString(),
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');
125
+ return copy(finished);
126
+ }
127
+ export function creationReferences(root) {
128
+ return listCreationRequests(root).filter(record => record.state === 'prepared').map(record => ({
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)
132
+ }));
133
+ }
package/lib/doctor.js CHANGED
@@ -421,7 +421,18 @@ export function hostDoctor(host, evidence = {}, options = {}) {
421
421
  + (missingSkills.length ? ':' + missingSkills.join(', ') : ''),
422
422
  '工具仍可直接调用;检查 DSH Skill registry 和插件启动日志。'));
423
423
 
424
- capabilityCheck(checks, snapshot, 'browser', 'browser', 'HTML Browser');
424
+ if (capabilityCheck(checks, snapshot, 'browser', 'browser', 'HTML Browser')) {
425
+ checks[checks.length - 1] = result('browser', 'ok', 'HTML 浏览器接口已提供;实际截图需单独自检',
426
+ '点击“截图自检”验证当前浏览器能否启动、打开页面并生成图片。');
427
+ const address = host.ports.browser.address;
428
+ if (address) {
429
+ checks.push(result('render-address', address.status === 'resolved' ? 'ok' : 'warn',
430
+ address.status === 'resolved'
431
+ ? '截图地址:' + address.baseUrl + (address.source === 'environment' ? '(DSH_WEB_BASE)' : '(宿主监听)')
432
+ : address.message,
433
+ address.status === 'resolved' ? '' : '显式配置 DSH_WEB_BASE;不需要额外的端口转发。'));
434
+ }
435
+ }
425
436
  capabilityCheck(checks, snapshot, 'attachments', 'attachments', '附件读写');
426
437
  capabilityCheck(checks, snapshot, 'sessions', 'sessions', '会话查询');
427
438
  capabilityCheck(checks, snapshot, 'text-model', 'textModel', '宿主文本模型');