@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.
- package/CHANGELOG.md +81 -1
- package/README.en.md +103 -312
- package/README.md +100 -310
- package/bin/dsh-iris.js +333 -65
- package/docs/AGENT_CORE_QUICKSTART.md +64 -0
- package/docs/AGENT_QUICKSTART.md +2 -0
- package/docs/ARCHITECTURE.md +13 -3
- package/docs/AUDIO_FLOWS.md +71 -0
- package/docs/CONTACT_SHEET.md +62 -0
- package/docs/CORE_CONNECTIONS.md +99 -0
- package/docs/CORE_QUICKSTART.md +108 -0
- package/docs/CORE_RUNTIME_CONTRACT.md +7 -2
- package/docs/CORE_SERVICE.md +97 -0
- package/docs/CORE_SKILLS.md +35 -3
- package/docs/DSH_COMPATIBILITY.md +10 -0
- package/docs/DSH_RC2_ADAPTATION.md +1 -1
- package/docs/DSH_RUNTIME.md +6 -2
- package/docs/HEADLESS_CLI.md +2 -2
- package/docs/IMAGE_FLOW.md +4 -0
- package/docs/IMAGE_GENERATION.md +127 -0
- package/docs/LOCAL_API.md +4 -2
- package/docs/LOCAL_API_CONTRACT.md +39 -14
- package/docs/LOCAL_CREATION.md +7 -1
- package/docs/LOCAL_OPERATIONS.md +46 -0
- package/docs/LOCAL_SELECTION.md +57 -0
- package/docs/LOCAL_WEB.md +201 -0
- package/docs/MCP.md +191 -0
- package/docs/MUSIC.md +2 -0
- package/docs/PANEL_EXTENSIONS.md +64 -0
- package/docs/PLUGINS.md +189 -0
- package/docs/PLUGIN_STARTUP.md +100 -0
- package/docs/PROVIDER_ADAPTER_CONTRACT.md +1 -1
- package/docs/PROVIDER_EXTENSIONS.md +78 -0
- package/docs/PROVIDER_HEALTH.md +3 -1
- package/docs/PROVIDER_SETTINGS.md +85 -0
- package/docs/PROVIDER_SUBMISSION_CONTRACT.md +6 -0
- package/docs/ROADMAP.md +17 -7
- package/docs/VISION_CLI.md +1 -1
- package/docs/WEB_PROTOTYPE.md +21 -6
- package/docs/WEB_RECOVERY.md +46 -0
- package/docs/WORKBENCH_LOGIN.md +63 -0
- package/docs/assets/screenshots/core-connections-preview.webp +0 -0
- package/docs/assets/screenshots/core-model-pools-preview.webp +0 -0
- package/docs/assets/screenshots/core-overview-preview.webp +0 -0
- package/docs/assets/screenshots/core-spectrum-preview.webp +0 -0
- package/docs/assets/screenshots/core-studio-dark-preview.webp +0 -0
- package/docs/assets/screenshots/core-studio-mobile-preview.webp +0 -0
- package/docs/assets/screenshots/core-studio-preview.webp +0 -0
- package/docs/releases/0.3.0-alpha.1.md +51 -0
- package/docs/screenshots.md +41 -1
- package/lib/actions.js +53 -2
- package/lib/adapters.js +20 -11
- package/lib/application-runtime.js +34 -7
- package/lib/audio-flow-input.js +37 -0
- package/lib/client.js +346 -53
- package/lib/comfyui-adapter.js +94 -0
- package/lib/comfyui-client.js +51 -0
- package/lib/comfyui-inspection.js +73 -0
- package/lib/comfyui-workflow.js +94 -0
- package/lib/config.js +36 -1
- package/lib/configured-image-runtime.js +50 -0
- package/lib/connection-skill.js +89 -0
- package/lib/connection-template.js +23 -0
- package/lib/core-artifact-store.js +16 -2
- package/lib/core-bridge-selection.js +31 -0
- package/lib/core-bridge-tools.js +83 -0
- package/lib/core-capability-catalog.js +182 -0
- package/lib/core-connection-diagnostics.js +49 -0
- package/lib/core-connection-store.js +277 -0
- package/lib/core-connections.js +225 -0
- package/lib/core-maintenance.js +9 -3
- package/lib/core-runtime.js +2 -1
- package/lib/core-service-client.js +170 -0
- package/lib/core-tasks.js +29 -12
- package/lib/core-writer-lease.js +16 -0
- package/lib/creation-request-store.js +38 -64
- package/lib/dsh-host-adapter.js +4 -1
- package/lib/dsh-service-bridge.js +66 -0
- package/lib/execution-ownership.js +43 -0
- package/lib/flow-store.js +105 -10
- package/lib/generation-input.js +10 -1
- package/lib/headless-vision.js +7 -7
- package/lib/image-edit-input.js +1 -1
- package/lib/image-flow.js +73 -29
- package/lib/index.js +37 -17
- package/lib/local-api-projection.js +10 -5
- package/lib/local-api-token-directory.js +31 -0
- package/lib/local-api.js +769 -67
- package/lib/local-creation.js +89 -24
- package/lib/local-operations.js +36 -0
- package/lib/local-request-root.js +86 -0
- package/lib/local-web-assets.js +37 -0
- package/lib/mcp-bridge.js +58 -0
- package/lib/mcp-check.js +26 -0
- package/lib/mcp-config.js +42 -0
- package/lib/mcp-delivery.js +46 -0
- package/lib/mcp-http.js +101 -0
- package/lib/mcp-server.js +47 -0
- package/lib/model-directory-service.js +16 -6
- package/lib/model-directory.js +3 -3
- package/lib/models.js +2 -2
- package/lib/music-transport.js +2 -1
- package/lib/operation-runner.js +236 -0
- package/lib/operation-store.js +216 -0
- package/lib/panel-extension.js +36 -0
- package/lib/plugin-contract.js +194 -0
- package/lib/plugin-execution.js +51 -0
- package/lib/plugin-launch.js +86 -0
- package/lib/plugin-operation-store.js +101 -0
- package/lib/plugin-operations.js +28 -0
- package/lib/plugin-registry.js +109 -0
- package/lib/plugin-selection.js +15 -0
- package/lib/plugin-source-image.js +5 -0
- package/lib/plugin-web-assets.js +54 -0
- package/lib/plugins.d.ts +288 -0
- package/lib/plugins.js +58 -0
- package/lib/provider-adapter.js +5 -1
- package/lib/provider-adapters.js +56 -18
- package/lib/provider-catalog.js +40 -29
- package/lib/provider-config-service.js +105 -23
- package/lib/provider-diagnostics.js +56 -0
- package/lib/provider-extensions.js +29 -0
- package/lib/provider-health.js +8 -1
- package/lib/provider-monitor.js +79 -0
- package/lib/provider-protocol.js +70 -20
- package/lib/provider-registry.js +118 -0
- package/lib/provider-response.js +28 -0
- package/lib/provider-settings.js +134 -0
- package/lib/provider-task-runner.js +22 -9
- package/lib/skill-installation.js +23 -11
- package/lib/vision-model-routing.js +3 -0
- package/lib/web/app.js +642 -0
- package/lib/web/appearance.js +111 -0
- package/lib/web/comfyui.js +146 -0
- package/lib/web/connections.js +377 -0
- package/lib/web/creation.js +355 -0
- package/lib/web/flow-view.js +33 -0
- package/lib/web/gallery.js +132 -0
- package/lib/web/index.html +291 -0
- package/lib/web/media-client.js +204 -0
- package/lib/web/plugin-panel.js +221 -0
- package/lib/web/plugin-surface.css +17 -0
- package/lib/web/providers.js +492 -0
- package/lib/web/recovery.js +105 -0
- package/lib/web/startup.js +18 -0
- package/lib/web/styles.css +518 -0
- package/lib/web/transport.js +96 -0
- package/lib/web/workbench-login.js +165 -0
- package/lib/workbench-auth-cli.js +47 -0
- package/lib/workbench-auth.js +200 -0
- package/package.json +20 -5
- package/plugins/contact-sheet/index.d.mts +4 -0
- package/plugins/contact-sheet/index.mjs +33 -0
- package/plugins/contact-sheet/web/board.css +14 -0
- package/plugins/contact-sheet/web/board.mjs +93 -0
- package/skills/iris-core/SKILL.md +8 -4
- package/skills/iris-core/references/cli.md +2 -2
- package/skills/iris-core/references/comfyui.md +69 -0
- package/skills/iris-core/references/tasks.md +2 -0
- 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 && (
|
|
115
|
-
|
|
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 && (
|
|
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.
|
|
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.
|
|
243
|
+
try { fs.lstatSync(taskFile(dataRoot, flowStep(run).taskId)); }
|
|
232
244
|
catch (error) {
|
|
233
245
|
if (error.code !== 'ENOENT') throw error;
|
|
234
|
-
if (run.
|
|
246
|
+
if (flowStep(run).claimed) fail('IRIS_FLOW_TASK_CONFLICT', '流程已建立的 Task 丢失,禁止重建或重新生成');
|
|
235
247
|
return null;
|
|
236
248
|
}
|
|
237
|
-
return assertFlowTaskMatches(readRecord(dataRoot, 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:
|
|
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
|
}
|
package/lib/core-writer-lease.js
CHANGED
|
@@ -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 {
|
|
7
|
-
import {
|
|
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
|
-
|
|
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
|
|
67
|
-
|
|
68
|
-
|
|
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 (!
|
|
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
|
|
88
|
-
?
|
|
89
|
-
|
|
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
|
|
67
|
+
if (isImportCreation(record.kind)) {
|
|
68
|
+
const audio = record.kind === 'audio-import';
|
|
100
69
|
if (!keys(intent, ['artifactId', 'mediaType', 'size', 'sha256', 'metadata', 'createdAt'])
|
|
101
|
-
||
|
|
102
|
-
|| !
|
|
103
|
-
|| !
|
|
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 (
|
|
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, '
|
|
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(
|
|
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, '
|
|
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
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|
156
|
-
artifactIds: record.kind
|
|
157
|
-
: [record.intent.source
|
|
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
|
}
|
package/lib/dsh-host-adapter.js
CHANGED
|
@@ -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)
|
|
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
|
+
}
|