@mokuyoaxis/dsh-iris 0.2.7 → 0.3.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +143 -1
- package/README.en.md +109 -311
- package/README.md +106 -309
- package/bin/dsh-iris.js +452 -92
- package/docs/AGENT_CORE_QUICKSTART.md +64 -0
- package/docs/AGENT_QUICKSTART.md +2 -0
- package/docs/ARCHITECTURE.md +13 -3
- package/docs/ARTIFACT_MANIFEST.md +4 -0
- package/docs/AUDIO_FLOWS.md +87 -0
- package/docs/BETA_SCOPE.md +42 -0
- package/docs/CONTACT_SHEET.md +62 -0
- package/docs/CORE_CONNECTIONS.md +101 -0
- package/docs/CORE_QUICKSTART.md +110 -0
- package/docs/CORE_RUNTIME_CONTRACT.md +7 -2
- package/docs/CORE_SERVICE.md +101 -0
- package/docs/CORE_SKILLS.md +35 -3
- package/docs/DSH_COMPATIBILITY.md +21 -1
- package/docs/DSH_CORE_MIGRATION.md +6 -0
- package/docs/DSH_RC2_ADAPTATION.md +1 -1
- package/docs/DSH_RUNTIME.md +6 -2
- package/docs/HEADLESS_CLI.md +30 -5
- package/docs/HOST_ADAPTER_CONTRACT.md +5 -3
- 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 +42 -15
- 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 +207 -0
- package/docs/MCP.md +212 -0
- package/docs/MEDIA_SCHEDULING.md +72 -0
- package/docs/MUSIC.md +2 -0
- package/docs/NETWORK_RESOURCES.md +106 -0
- package/docs/NETWORK_ROUTING.md +103 -0
- package/docs/NETWORK_WORKBENCH.md +65 -0
- package/docs/PANEL_EXTENSIONS.md +64 -0
- package/docs/PLUGINS.md +193 -0
- package/docs/PLUGIN_DEVELOPMENT.md +39 -0
- package/docs/PLUGIN_STARTUP.md +113 -0
- package/docs/PROVIDER_ADAPTER_CONTRACT.md +8 -2
- package/docs/PROVIDER_CREDENTIALS.md +67 -0
- package/docs/PROVIDER_EXTENSIONS.md +82 -0
- package/docs/PROVIDER_HEALTH.md +5 -1
- package/docs/PROVIDER_NETWORK.md +45 -0
- package/docs/PROVIDER_PLUGIN_CONTRACT.md +24 -0
- package/docs/PROVIDER_SETTINGS.md +91 -0
- package/docs/PROVIDER_SUBMISSION_CONTRACT.md +8 -0
- package/docs/ROADMAP.md +18 -8
- package/docs/SECURITY.md +1 -1
- package/docs/SYNC_DELIVERY_RECOVERY.md +5 -1
- package/docs/VIDEO_FLOWS.md +89 -0
- package/docs/VISION_CLI.md +1 -1
- package/docs/VISION_MODEL.md +2 -0
- package/docs/WEB_PROTOTYPE.md +21 -6
- package/docs/WEB_RECOVERY.md +46 -0
- package/docs/WORKBENCH_ARTIFACTS.md +6 -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/releases/0.3.0-beta.1.md +34 -0
- package/docs/screenshots.md +41 -1
- package/lib/actions.js +56 -4
- package/lib/adapters.js +75 -49
- package/lib/api.js +23 -13
- package/lib/application-runtime.js +73 -17
- package/lib/application-writer.js +193 -0
- package/lib/audio-flow-input.js +37 -0
- package/lib/client.d.ts +3 -3
- package/lib/client.js +402 -72
- package/lib/comfyui-adapter.js +95 -0
- package/lib/comfyui-client.js +60 -0
- package/lib/comfyui-inspection.js +73 -0
- package/lib/comfyui-workflow.js +94 -0
- package/lib/command-service.js +18 -5
- package/lib/config.js +49 -10
- package/lib/configured-image-runtime.js +69 -0
- package/lib/connection-skill.js +98 -0
- package/lib/connection-template.js +23 -0
- package/lib/core-artifact-store.js +84 -28
- package/lib/core-bridge-selection.js +31 -0
- package/lib/core-bridge-tools.js +85 -0
- package/lib/core-capability-catalog.js +192 -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-delivery-cache.js +8 -3
- package/lib/core-maintenance.js +9 -3
- package/lib/core-runtime.js +2 -1
- package/lib/core-service-client.js +174 -0
- package/lib/core-tasks.js +113 -21
- package/lib/core-writer-lease.js +16 -0
- package/lib/creation-request-store.js +41 -64
- package/lib/doctor.js +2 -2
- package/lib/dsh-core-adapter.js +61 -9
- package/lib/dsh-host-adapter.js +4 -1
- package/lib/dsh-provider-config.js +1 -1
- package/lib/dsh-service-bridge.js +67 -0
- package/lib/execution-ownership.js +43 -0
- package/lib/flow-store.js +156 -14
- 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 +97 -31
- package/lib/index.js +47 -22
- package/lib/local-api-projection.js +15 -6
- package/lib/local-api-token-directory.js +31 -0
- package/lib/local-api.js +861 -71
- package/lib/local-creation.js +96 -24
- package/lib/local-operations.js +36 -0
- package/lib/local-request-root.js +86 -0
- package/lib/local-web-assets.js +39 -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 +174 -0
- package/lib/mcp-server.js +69 -0
- package/lib/media-scheduler.js +194 -0
- package/lib/model-directory-service.js +37 -10
- package/lib/model-directory.js +3 -3
- package/lib/models.js +2 -2
- package/lib/music-transport.js +6 -3
- package/lib/network-config-service.js +123 -0
- package/lib/network-config.js +127 -0
- package/lib/network-doctor.js +94 -0
- package/lib/network-management.js +51 -0
- package/lib/network-policy.js +19 -0
- package/lib/network-route-evidence.js +38 -0
- package/lib/network-routing.js +283 -0
- package/lib/operation-runner.js +281 -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 +307 -0
- package/lib/plugins.js +59 -0
- package/lib/provider-adapter.js +7 -3
- package/lib/provider-adapters.js +92 -22
- package/lib/provider-catalog.js +64 -36
- package/lib/provider-config-format.js +51 -0
- package/lib/provider-config-service.js +143 -31
- package/lib/provider-context.d.ts +90 -0
- package/lib/provider-context.js +42 -0
- package/lib/provider-credentials.js +97 -0
- package/lib/provider-diagnostics.js +56 -0
- package/lib/provider-extensions.js +29 -0
- package/lib/provider-health.js +18 -6
- package/lib/provider-monitor.js +81 -0
- package/lib/provider-network.js +52 -0
- package/lib/provider-protocol.js +73 -22
- package/lib/provider-registry.js +128 -0
- package/lib/provider-response.js +38 -0
- package/lib/provider-settings.js +136 -0
- package/lib/provider-task-runner.js +132 -43
- package/lib/provider-transport.js +16 -0
- package/lib/render.js +11 -1
- package/lib/scheduling-policy.js +66 -0
- package/lib/skill-installation.js +23 -11
- package/lib/tasks.js +3 -2
- package/lib/video-flow-input.js +16 -0
- package/lib/video-input.js +12 -5
- package/lib/vision-model-routing.js +5 -0
- package/lib/web/app.js +703 -0
- package/lib/web/appearance.js +111 -0
- package/lib/web/comfyui.js +146 -0
- package/lib/web/connections.js +516 -0
- package/lib/web/creation.js +359 -0
- package/lib/web/flow-view.js +34 -0
- package/lib/web/gallery.js +133 -0
- package/lib/web/index.html +344 -0
- package/lib/web/media-client.js +218 -0
- package/lib/web/network.js +232 -0
- package/lib/web/plugin-panel.js +223 -0
- package/lib/web/plugin-status.js +140 -0
- package/lib/web/plugin-surface.css +17 -0
- package/lib/web/providers.js +513 -0
- package/lib/web/recovery.js +105 -0
- package/lib/web/startup.js +18 -0
- package/lib/web/styles.css +571 -0
- package/lib/web/transport.js +101 -0
- package/lib/web/workbench-login.js +165 -0
- package/lib/workbench-artifacts.js +3 -2
- package/lib/workbench-auth-cli.js +47 -0
- package/lib/workbench-auth.js +200 -0
- package/package.json +24 -6
- 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/templates/plugin-starter/LICENSE +21 -0
- package/templates/plugin-starter/README.md +74 -0
- package/templates/plugin-starter/assets/peach.png +0 -0
- package/templates/plugin-starter/assets/violet.png +0 -0
- package/templates/plugin-starter/index.mjs +27 -0
- package/templates/plugin-starter/package.json +18 -0
- package/templates/plugin-starter/scripts/core-cli.mjs +20 -0
- package/templates/plugin-starter/scripts/environment.mjs +65 -0
- package/templates/plugin-starter/scripts/mcp-probe.mjs +22 -0
- package/templates/plugin-starter/scripts/setup.mjs +42 -0
- package/templates/plugin-starter/scripts/smoke.mjs +76 -0
- package/templates/plugin-starter/web/board.css +27 -0
- package/templates/plugin-starter/web/board.mjs +160 -0
- package/templates/plugin-starter/web/png.mjs +23 -0
- package/templates/provider-starter/LICENSE +21 -0
- package/templates/provider-starter/README.md +50 -0
- package/templates/provider-starter/index.d.mts +5 -0
- package/templates/provider-starter/index.mjs +165 -0
- package/templates/provider-starter/package.json +13 -0
- package/templates/provider-starter/scripts/core-cli.mjs +22 -0
- package/templates/provider-starter/scripts/environment.mjs +122 -0
- package/templates/provider-starter/scripts/fixture.mjs +54 -0
- package/templates/provider-starter/scripts/mcp-probe.mjs +21 -0
- package/templates/provider-starter/scripts/protocol-smoke.mjs +48 -0
- package/templates/provider-starter/scripts/setup.mjs +44 -0
- package/templates/provider-starter/scripts/smoke.mjs +145 -0
- package/user_guide.md +5 -1
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
// Instance-owned routing. CONNECT preflight never sends a business request.
|
|
2
|
+
import net from 'node:net';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
5
|
+
import { readNetworkConfig } from './network-config-service.js';
|
|
6
|
+
import { networkNodeCredentialStatus, NETWORK_ROUTING_CONFIG_VERSION } from './network-config.js';
|
|
7
|
+
import { checkedNetworkRequirement, networkPolicyError, createNetworkAttemptBudget } from './network-policy.js';
|
|
8
|
+
import { createProviderNetworkClient, inheritedProviderFetch } from './provider-network.js';
|
|
9
|
+
import { ProviderContractError } from './provider-contract.js';
|
|
10
|
+
|
|
11
|
+
const hash = value => crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
12
|
+
const authority = url => `${url.hostname}:${url.port || (url.protocol === 'https:' ? 443 : 80)}`;
|
|
13
|
+
const nodeUri = node => `${node.protocol}://${net.isIP(node.host) === 6 ? '[' + node.host + ']' : node.host}:${node.port}`;
|
|
14
|
+
const PREFLIGHT_ERRORS = new Set([
|
|
15
|
+
'ECONNREFUSED', 'ECONNRESET', 'ECONNABORTED', 'EHOSTUNREACH', 'ENETUNREACH', 'ENETDOWN', 'EAI_AGAIN', 'ENOTFOUND',
|
|
16
|
+
'ETIMEDOUT', 'EPIPE', 'EADDRNOTAVAIL', 'EACCES', 'EPERM', 'IRIS_NETWORK_CONNECT_REJECTED',
|
|
17
|
+
'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT', 'UND_ERR_HEADERS_OVERFLOW', 'UND_ERR_SOCKET', 'UND_ERR_ABORTED', 'UND_ERR_PRX_TLS',
|
|
18
|
+
'DEPTH_ZERO_SELF_SIGNED_CERT', 'SELF_SIGNED_CERT_IN_CHAIN', 'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
|
|
19
|
+
'UNABLE_TO_GET_ISSUER_CERT', 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY', 'CERT_HAS_EXPIRED', 'CERT_NOT_YET_VALID', 'CERT_REVOKED',
|
|
20
|
+
'ERR_TLS_CERT_ALTNAME_INVALID', 'ERR_TLS_HANDSHAKE_TIMEOUT'
|
|
21
|
+
]);
|
|
22
|
+
const preflightUnavailable = error => PREFLIGHT_ERRORS.has(error.code) || /^(?:ERR_SSL_|HPE_)[A-Z0-9_]+$/.test(error.code || '')
|
|
23
|
+
|| ['AbortError', 'TimeoutError'].includes(error.name);
|
|
24
|
+
function failure(context, code, message, category = 'network') {
|
|
25
|
+
const operation = context.operation || 'submit';
|
|
26
|
+
const notAccepted = ['submit', 'prepareInput', 'discover', 'vision'].includes(operation);
|
|
27
|
+
return Object.assign(new ProviderContractError(message, { providerCode: code, category, stage: notAccepted ? 'prepare' : operation,
|
|
28
|
+
acceptance: notAccepted ? 'not_accepted' : 'accepted' }), { code });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function directPreflight(url, signal, timeoutMs) {
|
|
32
|
+
signal?.throwIfAborted();
|
|
33
|
+
const combined = signal ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs);
|
|
34
|
+
await new Promise((resolve, reject) => {
|
|
35
|
+
const socket = net.connect({ host: url.hostname.replace(/^\[|\]$/g, ''), port: Number(url.port || (url.protocol === 'https:' ? 443 : 80)) });
|
|
36
|
+
const aborted = () => socket.destroy(combined.reason);
|
|
37
|
+
combined.addEventListener('abort', aborted, { once: true });
|
|
38
|
+
const cleanup = () => combined.removeEventListener('abort', aborted);
|
|
39
|
+
socket.once('error', error => { cleanup(); reject(error); });
|
|
40
|
+
socket.once('connect', () => { cleanup(); socket.destroy(); resolve(); });
|
|
41
|
+
if (combined.aborted) aborted();
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function transportFetchInput(transport, input, options) {
|
|
46
|
+
// Node's built-in fetch and the installed Undici use different Web IDL brands.
|
|
47
|
+
// Convert only at the explicit transport boundary; inherited fetch stays native.
|
|
48
|
+
if (input instanceof Request) {
|
|
49
|
+
const request = new Request(input, options);
|
|
50
|
+
return { input: new transport.Request(request.url, request), options: { signal: request.signal } };
|
|
51
|
+
}
|
|
52
|
+
if (options.body instanceof FormData) {
|
|
53
|
+
const body = new transport.FormData();
|
|
54
|
+
for (const [name, value] of options.body) body.append(name, value);
|
|
55
|
+
return { input, options: { ...options, body } };
|
|
56
|
+
}
|
|
57
|
+
return { input, options };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function createProviderNetworkManager({ networkConfigPath, onRoute, tls, proxyTls, scheduler, preflightTimeoutMs = 3000,
|
|
61
|
+
loadTransport = () => {
|
|
62
|
+
// Initialize Node's own fetch classes/default dispatcher without a request.
|
|
63
|
+
// Undici must inherit an existing dispatcher rather than create its own default.
|
|
64
|
+
void globalThis.Request;
|
|
65
|
+
return import('undici');
|
|
66
|
+
} } = {}) {
|
|
67
|
+
if (onRoute !== undefined && typeof onRoute !== 'function') throw new TypeError('onRoute 必须是函数');
|
|
68
|
+
if (typeof loadTransport !== 'function' || !Number.isInteger(preflightTimeoutMs) || preflightTimeoutMs < 1 || preflightTimeoutMs > 30000) throw new TypeError('网络客户端装配参数无效');
|
|
69
|
+
const clients = new Map(), retiring = new Set();
|
|
70
|
+
let closing = false, disposal, transport, revision, cleanupFailed = false;
|
|
71
|
+
function retire(client) {
|
|
72
|
+
const pending = client.dispose();
|
|
73
|
+
retiring.add(pending);
|
|
74
|
+
void pending.then(() => retiring.delete(pending), () => { cleanupFailed = true; retiring.delete(pending); });
|
|
75
|
+
}
|
|
76
|
+
function remember(slot, key, client, routed = false) {
|
|
77
|
+
const previous = clients.get(slot);
|
|
78
|
+
clients.set(slot, { key, client, routed });
|
|
79
|
+
if (previous) retire(previous.client);
|
|
80
|
+
return client;
|
|
81
|
+
}
|
|
82
|
+
const library = () => transport ||= Promise.resolve().then(loadTransport).catch(() => {
|
|
83
|
+
throw failure({ operation: 'discover' }, 'IRIS_NETWORK_CLIENT_UNAVAILABLE', '代理客户端未安装或无法加载;请使用包含所需生产依赖的完整 Iris 安装,不会退回直连', 'invalid_request');
|
|
84
|
+
});
|
|
85
|
+
async function scheduledFetch(fetch, dispatcher, input, options, context) {
|
|
86
|
+
const managed = scheduler.dispatcher(dispatcher, context, options.signal);
|
|
87
|
+
try { return await fetch(input, { ...options, dispatcher: managed }); }
|
|
88
|
+
catch (error) {
|
|
89
|
+
const queuedFailure = [error, error.cause].some(value => typeof value?.code === 'string' && value.code.startsWith('IRIS_SCHEDULER_'));
|
|
90
|
+
if (managed.dispatches === 0 && (queuedFailure || options.signal?.aborted)) {
|
|
91
|
+
const original = typeof error.cause?.code === 'string' && error.cause.code.startsWith('IRIS_SCHEDULER_') ? error.cause : error;
|
|
92
|
+
throw failure(context, typeof original.code === 'string' && original.code.startsWith('IRIS_SCHEDULER_') ? original.code : 'IRIS_SCHEDULER_ABORTED',
|
|
93
|
+
'请求在本地调度期间停止,业务请求尚未派发', options.signal?.aborted ? 'aborted' : 'rate_limit');
|
|
94
|
+
}
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function resolveProviderNetwork({ providerId, modelRef, networkRequirement } = {}) {
|
|
100
|
+
if (closing) throw networkPolicyError('IRIS_PROVIDER_NETWORK_CLOSED', '网络管理实例已关闭');
|
|
101
|
+
if (networkRequirement === undefined) {
|
|
102
|
+
if (!scheduler) return undefined;
|
|
103
|
+
const key = JSON.stringify(['scheduled-inherit', providerId, modelRef]);
|
|
104
|
+
if (clients.has(key)) return clients.get(key).client;
|
|
105
|
+
const scope = new AsyncLocalStorage();
|
|
106
|
+
const base = createProviderNetworkClient({ async fetchPort(input, options = {}) {
|
|
107
|
+
const context = scope.getStore();
|
|
108
|
+
if (!context) throw networkPolicyError('IRIS_NETWORK_OPERATION_REQUIRED', '调度客户端必须在完整操作内调用');
|
|
109
|
+
const { getGlobalDispatcher } = await library();
|
|
110
|
+
return scheduledFetch(inheritedProviderFetch, getGlobalDispatcher(), input, options, context);
|
|
111
|
+
} });
|
|
112
|
+
const client = Object.freeze({ ...base, run(action, context = {}) {
|
|
113
|
+
return base.run(fetch => scope.run({ providerId, modelRef, ...context }, () => action(fetch)));
|
|
114
|
+
} });
|
|
115
|
+
return remember(key, key, client);
|
|
116
|
+
}
|
|
117
|
+
const requirement = Object.freeze(checkedNetworkRequirement(networkRequirement));
|
|
118
|
+
if (typeof providerId !== 'string' || !providerId.trim() || providerId.length > 256 || /[\x00-\x1f\x7f]/.test(providerId)) throw networkPolicyError('IRIS_NETWORK_CONTEXT_REQUIRED', '网络解析需要明确的供应商身份');
|
|
119
|
+
if (!networkConfigPath) throw networkPolicyError('IRIS_NETWORK_CONTEXT_REQUIRED', '此供应商配置需要明确的 network-config,不会使用默认出口');
|
|
120
|
+
const saved = readNetworkConfig(networkConfigPath), config = saved.config;
|
|
121
|
+
if (config.schemaVersion !== NETWORK_ROUTING_CONFIG_VERSION || config.configId !== requirement.configId) {
|
|
122
|
+
throw networkPolicyError('IRIS_NETWORK_CONFIG_MISMATCH', '网络配置版本或身份与供应商目录不符,不会借用其他配置');
|
|
123
|
+
}
|
|
124
|
+
if (revision !== saved.revision) {
|
|
125
|
+
revision = saved.revision;
|
|
126
|
+
for (const [slot, entry] of clients) if (entry.routed) {
|
|
127
|
+
clients.delete(slot); retire(entry.client);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const binding = config.bindings.find(value => value.catalogId === requirement.catalogId && value.providerId === providerId);
|
|
131
|
+
const pool = binding ? config.pools.find(value => value.id === binding.poolId) : undefined;
|
|
132
|
+
if (binding && (!pool?.enabled || !pool.candidates.length)) throw networkPolicyError('IRIS_NETWORK_POOL_UNAVAILABLE', '显式绑定的出口池为空或已停用,不会隐式继承或直连');
|
|
133
|
+
const secrets = pool ? pool.candidates.flatMap(candidate => {
|
|
134
|
+
const node = candidate.kind === 'node' && config.nodes.find(value => value.id === candidate.nodeId);
|
|
135
|
+
return node?.auth ? [process.env[node.auth.usernameEnv], process.env[node.auth.passwordEnv]] : [];
|
|
136
|
+
}) : [];
|
|
137
|
+
const slot = hash([requirement, providerId, ...(scheduler ? [modelRef] : [])]);
|
|
138
|
+
const key = hash([saved.revision, requirement, providerId, ...(scheduler ? [modelRef] : []), secrets]);
|
|
139
|
+
const cached = clients.get(slot);
|
|
140
|
+
if (cached?.key === key) return cached.client;
|
|
141
|
+
const scope = new AsyncLocalStorage(), dispatchers = new Map();
|
|
142
|
+
// Capture only this client's explicitly referenced values; later resolutions
|
|
143
|
+
// use a new client if credentials rotate, while admitted operations can drain.
|
|
144
|
+
const nodeIds = new Set(pool?.candidates.filter(candidate => candidate.kind === 'node').map(candidate => candidate.nodeId) || []);
|
|
145
|
+
const nodes = new Map(config.nodes.filter(node => nodeIds.has(node.id)).map(node => [node.id, { ...node, credentialStatus: networkNodeCredentialStatus(node),
|
|
146
|
+
...(node.auth ? { token: `Basic ${Buffer.from(`${process.env[node.auth.usernameEnv] || ''}:${process.env[node.auth.passwordEnv] || ''}`).toString('base64')}` } : {}) }]));
|
|
147
|
+
|
|
148
|
+
async function choose(url, options, context) {
|
|
149
|
+
if (!pool) return { route: 'inherit' };
|
|
150
|
+
const budget = context.networkBudget || createNetworkAttemptBudget();
|
|
151
|
+
for (const candidate of pool.candidates) {
|
|
152
|
+
if (options.signal?.aborted) throw failure(context, 'IRIS_NETWORK_ABORTED', '业务请求发送前已取消出口预检', 'aborted');
|
|
153
|
+
const probeKey = JSON.stringify([key, url.origin, candidate]);
|
|
154
|
+
if (budget.probes.has(probeKey)) {
|
|
155
|
+
const previous = await budget.probes.get(probeKey);
|
|
156
|
+
if (previous) return previous;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const node = candidate.kind === 'node' ? nodes.get(candidate.nodeId) : undefined;
|
|
160
|
+
if (candidate.kind === 'node' && (!node?.enabled || !['none', 'ready'].includes(node.credentialStatus))) {
|
|
161
|
+
budget.probes.set(probeKey, Promise.resolve(null)); continue;
|
|
162
|
+
}
|
|
163
|
+
if (!budget.remaining) continue;
|
|
164
|
+
budget.remaining--;
|
|
165
|
+
let complete;
|
|
166
|
+
const result = new Promise(resolve => { complete = resolve; });
|
|
167
|
+
budget.probes.set(probeKey, result);
|
|
168
|
+
try {
|
|
169
|
+
if (candidate.kind === 'direct') {
|
|
170
|
+
await directPreflight(url, options.signal, preflightTimeoutMs);
|
|
171
|
+
const selected = { route: 'direct', poolId: pool.id }; complete(selected); return selected;
|
|
172
|
+
}
|
|
173
|
+
const { Client } = await library();
|
|
174
|
+
const probe = new Client(nodeUri(node), { connect: proxyTls, connectTimeout: preflightTimeoutMs });
|
|
175
|
+
try {
|
|
176
|
+
const signal = options.signal ? AbortSignal.any([options.signal, AbortSignal.timeout(preflightTimeoutMs)]) : AbortSignal.timeout(preflightTimeoutMs);
|
|
177
|
+
const connected = await probe.connect({ path: authority(url), signal,
|
|
178
|
+
headers: { ...(node.token ? { 'proxy-authorization': node.token } : {}), host: authority(url) } });
|
|
179
|
+
connected.socket.destroy();
|
|
180
|
+
if (connected.statusCode !== 200) throw Object.assign(new Error('CONNECT rejected'), { code: 'IRIS_NETWORK_CONNECT_REJECTED' });
|
|
181
|
+
} finally { await probe.destroy(); }
|
|
182
|
+
const selected = { route: 'proxy', poolId: pool.id, nodeId: node.id }; complete(selected); return selected;
|
|
183
|
+
} catch (error) {
|
|
184
|
+
complete(null);
|
|
185
|
+
if (options.signal?.aborted) throw failure(context, 'IRIS_NETWORK_ABORTED', '业务请求发送前已取消出口预检', 'aborted');
|
|
186
|
+
if (error.code === 'IRIS_NETWORK_CLIENT_UNAVAILABLE') throw error;
|
|
187
|
+
if (!preflightUnavailable(error)) throw error;
|
|
188
|
+
// Only the independent connection preflight may select another route.
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
throw failure(context, 'IRIS_NETWORK_POOL_EXHAUSTED', '出口池没有通过连接预检的候选;未发送业务请求,也未隐式直连');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function dispatcherFor(selected) {
|
|
195
|
+
const id = selected.nodeId || 'direct';
|
|
196
|
+
if (!dispatchers.has(id)) {
|
|
197
|
+
const pending = library().then(({ Agent, ProxyAgent }) => {
|
|
198
|
+
const node = selected.nodeId && nodes.get(selected.nodeId);
|
|
199
|
+
return node ? new ProxyAgent({ uri: nodeUri(node), token: node.token, requestTls: tls, proxyTls,
|
|
200
|
+
connectTimeout: preflightTimeoutMs, proxyTunnel: true }) : new Agent({ connect: tls, connectTimeout: preflightTimeoutMs });
|
|
201
|
+
});
|
|
202
|
+
dispatchers.set(id, pending);
|
|
203
|
+
void pending.catch(() => { if (dispatchers.get(id) === pending) dispatchers.delete(id); });
|
|
204
|
+
}
|
|
205
|
+
return dispatchers.get(id);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const base = createProviderNetworkClient({
|
|
209
|
+
async fetchPort(input, options = {}) {
|
|
210
|
+
const state = scope.getStore();
|
|
211
|
+
if (!state) throw networkPolicyError('IRIS_NETWORK_OPERATION_REQUIRED', '显式网络客户端必须在完整操作内调用');
|
|
212
|
+
if (state.context.taskId && typeof state.context.recordNetworkRoute !== 'function') {
|
|
213
|
+
throw failure(state.context, 'IRIS_NETWORK_EVIDENCE_REQUIRED', 'Task 出站需要 Core 提供路线持久化端口,已停止派发', 'local_io');
|
|
214
|
+
}
|
|
215
|
+
if (input instanceof Request && options.signal === undefined) options = { ...options, signal: input.signal };
|
|
216
|
+
const url = new URL(input instanceof Request ? input.url : input);
|
|
217
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw failure(state.context, 'IRIS_NETWORK_TARGET_INVALID', '出站目标需要不含认证信息的 HTTP(S) 地址', 'invalid_request');
|
|
218
|
+
state.selection ||= choose(url, options, state.context);
|
|
219
|
+
const selected = await state.selection;
|
|
220
|
+
const dispatcher = selected.route === 'inherit' ? undefined : await dispatcherFor(selected);
|
|
221
|
+
const fact = { ...requirement, configGeneration: config.generation, configRevision: saved.revision, providerId,
|
|
222
|
+
operation: state.context.operation || 'discover', ...selected, ...(state.context.attemptId ? { attemptId: state.context.attemptId } : {}) };
|
|
223
|
+
if (state.context.recordNetworkRoute) {
|
|
224
|
+
try { await state.context.recordNetworkRoute(fact); }
|
|
225
|
+
catch (error) {
|
|
226
|
+
throw failure(state.context, /^IRIS_(?:TASK|NETWORK)_/.test(error.code || '') ? error.code : 'IRIS_NETWORK_ROUTE_WRITE_FAILED',
|
|
227
|
+
error.code === 'IRIS_NETWORK_ROUTE_LIMIT' ? '原 Task 的路线事实已达限额,已停止派发并保留历史'
|
|
228
|
+
: '路线事实未能持久化,已停止出站;请检查存储和原 Task 身份', 'local_io');
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (onRoute) {
|
|
232
|
+
try { await onRoute(fact, state.context); }
|
|
233
|
+
catch (error) {
|
|
234
|
+
if (!/^IRIS_(?:TASK|NETWORK)_/.test(error.code || '')) throw error;
|
|
235
|
+
throw failure(state.context, error.code, error.code === 'IRIS_NETWORK_ROUTE_LIMIT'
|
|
236
|
+
? '原 Task 的路线事实已达限额,已停止派发并保留历史'
|
|
237
|
+
: '路线事实未能持久化,已停止出站;请检查存储和原 Task 身份', 'local_io');
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
// Once business dispatch starts, errors remain conservatively ambiguous.
|
|
241
|
+
// Neither the next proxy nor a direct connection may replay this request.
|
|
242
|
+
try {
|
|
243
|
+
if (!dispatcher) {
|
|
244
|
+
if (!scheduler) return await inheritedProviderFetch(input, options);
|
|
245
|
+
const { getGlobalDispatcher } = await library();
|
|
246
|
+
return await scheduledFetch(inheritedProviderFetch, getGlobalDispatcher(), input, options, { providerId, modelRef, ...state.context });
|
|
247
|
+
}
|
|
248
|
+
const transport = await library(), request = transportFetchInput(transport, input, options);
|
|
249
|
+
if (scheduler) return await scheduledFetch(transport.fetch, dispatcher, request.input, request.options, { providerId, modelRef, ...state.context });
|
|
250
|
+
return await transport.fetch(request.input, { ...request.options, dispatcher });
|
|
251
|
+
} catch (error) {
|
|
252
|
+
if (typeof error.code === 'string' && error.code.startsWith('IRIS_SCHEDULER_')) throw error;
|
|
253
|
+
if (options.signal?.aborted) throw options.signal.reason;
|
|
254
|
+
throw Object.assign(new TypeError('出站请求失败;出口已选定,未自动重放请求'), { code: 'IRIS_NETWORK_TRANSPORT', cause: error });
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
async close() {
|
|
258
|
+
const results = await Promise.allSettled([...dispatchers.values()].map(async value => (await value).close()));
|
|
259
|
+
if (results.some(value => value.status === 'rejected')) throw new Error('部分网络连接未能正常释放');
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
const client = Object.freeze({ ...base, networkRequirement: requirement, providerId, ...(pool ? { poolId: pool.id } : {}),
|
|
263
|
+
run(action, context = {}) {
|
|
264
|
+
return base.run(fetch => scope.run({ context, selection: null }, () => action(fetch)));
|
|
265
|
+
} });
|
|
266
|
+
return remember(slot, key, client, true);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function dispose() {
|
|
270
|
+
if (!disposal) {
|
|
271
|
+
closing = true;
|
|
272
|
+
disposal = (async () => {
|
|
273
|
+
const results = await Promise.allSettled([
|
|
274
|
+
...[...clients.values()].map(entry => entry.client.dispose()), ...retiring
|
|
275
|
+
]);
|
|
276
|
+
clients.clear();
|
|
277
|
+
if (cleanupFailed || results.some(value => value.status === 'rejected')) throw new Error('网络管理实例释放失败');
|
|
278
|
+
})();
|
|
279
|
+
}
|
|
280
|
+
return disposal;
|
|
281
|
+
}
|
|
282
|
+
return Object.freeze({ resolveProviderNetwork, dispose, scheduler });
|
|
283
|
+
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createApplicationRuntime } from './application-runtime.js';
|
|
4
|
+
import { normalizeCoreOptions } from './core-contract.js';
|
|
5
|
+
import { createCoreRuntime } from './core-runtime.js';
|
|
6
|
+
import { createImageFlowService } from './image-flow.js';
|
|
7
|
+
import { inspectRunnerArea } from './execution-ownership.js';
|
|
8
|
+
import { requestDirectory } from './local-request-root.js';
|
|
9
|
+
import { inspectCoreWriterLease } from './core-runtime.js';
|
|
10
|
+
import { recoverCoreWriterLease } from './core-lease-recovery.js';
|
|
11
|
+
import { acceptOperation, inspectExecutionRoot, operationId, readOperation, readAllOperations,
|
|
12
|
+
transitionOperation, projectOperation, operationFail, MAX_ACTIVE_OPERATIONS } from './operation-store.js';
|
|
13
|
+
import { reservePluginOperation } from './plugin-operations.js';
|
|
14
|
+
import { invokeReservedPlugin } from './plugin-execution.js';
|
|
15
|
+
import { acceptPluginOperation, pluginInputDigest, pluginSourceArtifacts, reservePluginArtifact } from './plugin-operation-store.js';
|
|
16
|
+
import { readFlowRun } from './flow-store.js';
|
|
17
|
+
import { findFlowTask } from './core-tasks.js';
|
|
18
|
+
|
|
19
|
+
const activeState = record => ['queued', 'running'].includes(record.state);
|
|
20
|
+
const busy = () => operationFail('IRIS_CORE_DATA_ROOT_BUSY', '当前写者正在执行,请用原 Key 稍后明确重试');
|
|
21
|
+
|
|
22
|
+
export function executionRunnerDirectory(root) {
|
|
23
|
+
return requestDirectory(root, inspectExecutionRoot(root), 'runner');
|
|
24
|
+
}
|
|
25
|
+
export function inspectExecutionLease(root) {
|
|
26
|
+
return inspectCoreWriterLease(executionRunnerDirectory(root));
|
|
27
|
+
}
|
|
28
|
+
export function recoverExecutionLease(root, options) {
|
|
29
|
+
return recoverCoreWriterLease(executionRunnerDirectory(root), options);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** One execution owner, one finite continue per accepted Operation. Construction performs no I/O. */
|
|
33
|
+
export function createOperationRunner({ dataRoot, application: suppliedApplication, resolveImageCandidates, resolveAudioCandidates, resolveVideoCandidates, resolveTaskAdapter, onExecutionError,
|
|
34
|
+
pluginRegistry, allowedOperations = [] } = {}) {
|
|
35
|
+
const root = normalizeCoreOptions({ dataRoot }).dataRoot;
|
|
36
|
+
const application = suppliedApplication || createApplicationRuntime({ dataRoot: root, resolveImageCandidates, resolveAudioCandidates, resolveVideoCandidates, resolveTaskAdapter });
|
|
37
|
+
const ports = { resolveImageCandidates, resolveAudioCandidates, resolveVideoCandidates, resolveTaskAdapter };
|
|
38
|
+
if (onExecutionError !== undefined && typeof onExecutionError !== 'function') throw new TypeError('执行错误报告器须为函数');
|
|
39
|
+
const queue = [], active = new Map(), finalizing = new Map();
|
|
40
|
+
const scheduled = Boolean(application.scheduler);
|
|
41
|
+
const transaction = (action, options) => application.transaction ? application.transaction(action, options) : application.run('writer', action);
|
|
42
|
+
let state = 'new', physicalRoot, directory, ownership, lease, ownerInstanceId, draining, disposal, failure, wake;
|
|
43
|
+
|
|
44
|
+
function snapshot() {
|
|
45
|
+
return Object.freeze({ state, queued: queue.length, active: active.keys().next().value || null,
|
|
46
|
+
...(scheduled ? { activeOperations: [...active.keys()] } : {}),
|
|
47
|
+
executionEnabled: state === 'open', failed: Boolean(failure) });
|
|
48
|
+
}
|
|
49
|
+
function requireOwner() {
|
|
50
|
+
if (state !== 'open' && state !== 'closing') operationFail('IRIS_EXECUTION_UNAVAILABLE', '执行服务未就绪或已停止');
|
|
51
|
+
if (inspectRunnerArea(directory)?.fingerprint !== ownership.fingerprint) {
|
|
52
|
+
operationFail('IRIS_EXECUTION_OWNERSHIP_LOST', '执行所有权已改变,停止执行并保留现场');
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function writable() {
|
|
56
|
+
requireOwner();
|
|
57
|
+
if (state !== 'open') operationFail('IRIS_EXECUTION_UNAVAILABLE', '执行服务正在关闭');
|
|
58
|
+
}
|
|
59
|
+
function failRunner(error) {
|
|
60
|
+
failure ||= error; state = 'failed';
|
|
61
|
+
for (const job of active.values()) job.controller.abort();
|
|
62
|
+
}
|
|
63
|
+
function finishIfActive(id, terminal, failureCode) {
|
|
64
|
+
if (finalizing.has(id)) return finalizing.get(id);
|
|
65
|
+
const completion = transaction(runtime => runtime.run('execute', ({ dataRoot }) => {
|
|
66
|
+
requireOwner();
|
|
67
|
+
const record = readOperation(dataRoot, id);
|
|
68
|
+
if (!record || !activeState(record)) return record;
|
|
69
|
+
if (record.ownerInstanceId !== ownerInstanceId) operationFail('IRIS_EXECUTION_OWNERSHIP_LOST', 'Operation 不属于本次执行实例');
|
|
70
|
+
return transitionOperation(dataRoot, { id, expectedRevision: record.revision, state: terminal,
|
|
71
|
+
...(failureCode ? { failureCode } : {}) });
|
|
72
|
+
}), { finalize: true }).finally(() => { finalizing.delete(id); });
|
|
73
|
+
finalizing.set(id, completion);
|
|
74
|
+
return completion;
|
|
75
|
+
}
|
|
76
|
+
async function perform(job) {
|
|
77
|
+
let admitted = false;
|
|
78
|
+
try {
|
|
79
|
+
const saved = readOperation(physicalRoot, job.id);
|
|
80
|
+
job.runId = saved?.runId;
|
|
81
|
+
const execute = saved?.action === 'flow.continue' && application.runMedia ? application.runMedia : (action, options) => application.run('writer', action, options);
|
|
82
|
+
await execute(runtime => { admitted = true; return runtime.run('execute', async ({ dataRoot }) => {
|
|
83
|
+
requireOwner();
|
|
84
|
+
let record = readOperation(dataRoot, job.id);
|
|
85
|
+
if (!record || !activeState(record)) return;
|
|
86
|
+
if (record.ownerInstanceId !== ownerInstanceId) operationFail('IRIS_EXECUTION_OWNERSHIP_LOST', 'Operation 执行归属不一致');
|
|
87
|
+
if (job.stopReason) {
|
|
88
|
+
transitionOperation(dataRoot, { id: job.id, expectedRevision: record.revision, state: job.stopReason }); return;
|
|
89
|
+
}
|
|
90
|
+
if (record.action === 'plugin.execute') {
|
|
91
|
+
let reservation, result, executionError;
|
|
92
|
+
try {
|
|
93
|
+
job.authorize?.();
|
|
94
|
+
reservation = reservePluginOperation(pluginRegistry, record.operationId, record.input, allowedOperations, record.dependency);
|
|
95
|
+
record = transitionOperation(dataRoot, { id: job.id, expectedRevision: record.revision, state: 'running' });
|
|
96
|
+
result = await invokeReservedPlugin(runtime, reservation, { imports: { operationId: record.id, sources: record.sourceArtifactIds,
|
|
97
|
+
authorize: job.authorize, reserve: () => reservePluginArtifact(dataRoot, inspectExecutionRoot(dataRoot), job.id) } });
|
|
98
|
+
} catch (error) { executionError = error; }
|
|
99
|
+
finally { reservation?.release(); }
|
|
100
|
+
const current = readOperation(dataRoot, job.id);
|
|
101
|
+
const dependencyError = /IRIS_PLUGIN_(?:CLOSED|CONTRIBUTION_MISSING|DEPENDENCY_MISMATCH)/.test(executionError?.code || '');
|
|
102
|
+
const denied = /(?:DENIED|HANDSHAKE_REQUIRED|AUTH_REQUIRED|REVOKED)$/.test(executionError?.code || '');
|
|
103
|
+
transitionOperation(dataRoot, { id: job.id, expectedRevision: current.revision, state: job.stopReason || (executionError ? 'failed' : 'succeeded'),
|
|
104
|
+
...(executionError && !job.stopReason ? { failureCode: dependencyError ? 'dependency_unavailable' : denied ? 'permission_denied' : 'execution_failed' } : {}),
|
|
105
|
+
...(!executionError && !job.stopReason ? { value: result.value } : {}) });
|
|
106
|
+
if (executionError && !job.stopReason) onExecutionError?.(executionError, job.id);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
try { record = transitionOperation(dataRoot, { id: job.id, expectedRevision: record.revision, state: 'running' }); }
|
|
110
|
+
catch (error) {
|
|
111
|
+
if (error.code !== 'IRIS_OPERATION_RUN_CHANGED') throw error;
|
|
112
|
+
transitionOperation(dataRoot, { id: job.id, expectedRevision: record.revision, state: 'failed', failureCode: 'run_changed' }); return;
|
|
113
|
+
}
|
|
114
|
+
let executionError;
|
|
115
|
+
try { await createImageFlowService(runtime, ports).execute('flow.continue', { run_id: record.runId }); }
|
|
116
|
+
catch (error) { executionError = error; }
|
|
117
|
+
// The operation-scoped writer is still held, including after its signal aborts.
|
|
118
|
+
// Persist only local execution facts; Provider Task outcome remains authoritative.
|
|
119
|
+
transitionOperation(dataRoot, { id: job.id, expectedRevision: record.revision,
|
|
120
|
+
state: job.stopReason || (executionError ? 'failed' : 'succeeded'),
|
|
121
|
+
...(executionError && !job.stopReason ? { failureCode: 'execution_failed' } : {}) });
|
|
122
|
+
if (executionError && !job.stopReason) onExecutionError?.(executionError, job.id);
|
|
123
|
+
}); }, { signal: job.controller.signal, ...(saved?.runId ? { key: `run:${saved.runId}` } : {}) });
|
|
124
|
+
} catch (error) {
|
|
125
|
+
if (job.stopReason && error.name === 'AbortError' && !application.snapshot().cleanupFailed) {
|
|
126
|
+
await finishIfActive(job.id, job.stopReason);
|
|
127
|
+
} else if (scheduled && !admitted && !application.snapshot().cleanupFailed
|
|
128
|
+
&& ['IRIS_SCHEDULER_QUEUE_TIMEOUT', 'IRIS_SCHEDULER_QUEUE_FULL'].includes(error.code)) {
|
|
129
|
+
await finishIfActive(job.id, job.stopReason || 'failed', job.stopReason ? undefined : 'execution_failed');
|
|
130
|
+
if (!job.stopReason) onExecutionError?.(error, job.id);
|
|
131
|
+
} else throw error;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
function pump() {
|
|
135
|
+
if (draining) { wake?.(); return; }
|
|
136
|
+
if (state !== 'open') return;
|
|
137
|
+
let blocked = false;
|
|
138
|
+
draining = (async () => {
|
|
139
|
+
while ((state === 'open' && queue.length && !blocked) || active.size) {
|
|
140
|
+
while (state === 'open' && queue.length && !blocked && active.size < (scheduled ? MAX_ACTIVE_OPERATIONS : 1)) {
|
|
141
|
+
const job = queue.shift(); active.set(job.id, job);
|
|
142
|
+
job.done = perform(job).catch(error => {
|
|
143
|
+
if (error.code !== 'IRIS_CORE_DATA_ROOT_BUSY') throw error;
|
|
144
|
+
queue.unshift(job); blocked = true; // Explicit retry only; no lease stealing or polling timer.
|
|
145
|
+
}).finally(() => { active.delete(job.id); });
|
|
146
|
+
}
|
|
147
|
+
if (active.size) {
|
|
148
|
+
const added = new Promise(resolve => { wake = resolve; });
|
|
149
|
+
await Promise.race([...active.values()].map(job => job.done).concat(added));
|
|
150
|
+
wake = null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
})();
|
|
154
|
+
void draining.then(() => { draining = null; if (state === 'open' && queue.length && !blocked) queueMicrotask(pump); },
|
|
155
|
+
error => { draining = null; failRunner(error); });
|
|
156
|
+
}
|
|
157
|
+
async function start() {
|
|
158
|
+
if (state !== 'new') operationFail('IRIS_EXECUTION_LIFECYCLE', '执行服务只能显式启动一次');
|
|
159
|
+
state = 'starting';
|
|
160
|
+
try {
|
|
161
|
+
physicalRoot = fs.realpathSync(root); directory = executionRunnerDirectory(physicalRoot);
|
|
162
|
+
lease = createCoreRuntime({ dataRoot: directory, mode: 'writer' }); lease.start();
|
|
163
|
+
ownership = inspectRunnerArea(directory); ownerInstanceId = ownership.owner.instanceId;
|
|
164
|
+
state = 'open';
|
|
165
|
+
await transaction(runtime => runtime.run('execute', ({ dataRoot }) => {
|
|
166
|
+
requireOwner();
|
|
167
|
+
for (const record of readAllOperations(dataRoot).filter(activeState)) {
|
|
168
|
+
transitionOperation(dataRoot, { id: record.id, expectedRevision: record.revision, state: 'interrupted' });
|
|
169
|
+
}
|
|
170
|
+
}));
|
|
171
|
+
return snapshot();
|
|
172
|
+
} catch (error) {
|
|
173
|
+
state = 'failed'; failure = error;
|
|
174
|
+
if (lease) await lease.dispose();
|
|
175
|
+
throw error;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
async function submit(input, { authorize } = {}) {
|
|
179
|
+
writable();
|
|
180
|
+
if (input?.operationId !== undefined) {
|
|
181
|
+
if (!input || Object.keys(input).sort().join(',') !== 'dependency,input,key,operationId') operationFail('IRIS_OPERATION_INPUT_INVALID', '业务执行需要原 Key、准确贡献依赖及输入');
|
|
182
|
+
authorize?.();
|
|
183
|
+
const marker = inspectExecutionRoot(physicalRoot), id = operationId(marker, input.key), previous = readOperation(physicalRoot, id, marker);
|
|
184
|
+
const fingerprint = pluginInputDigest(input.operationId, input.input, input.dependency, previous?.sourceArtifactIds);
|
|
185
|
+
if (previous) {
|
|
186
|
+
if (previous.action !== 'plugin.execute' || previous.inputDigest !== fingerprint) operationFail('IRIS_OPERATION_KEY_CONFLICT', '该 Key 已绑定另一业务或输入');
|
|
187
|
+
if (previous.state === 'queued' && previous.ownerInstanceId === ownerInstanceId) queueMicrotask(pump);
|
|
188
|
+
return { contractVersion: 0, operation: projectOperation(previous), idempotentReplay: true };
|
|
189
|
+
}
|
|
190
|
+
if (!scheduled && (application.snapshot().activeWrites || application.snapshot().queuedWrites)) busy();
|
|
191
|
+
const reservation = reservePluginOperation(pluginRegistry, input.operationId, input.input, allowedOperations, input.dependency);
|
|
192
|
+
let record, replay = false;
|
|
193
|
+
try {
|
|
194
|
+
record = await transaction(runtime => runtime.run('execute', ({ dataRoot }) => {
|
|
195
|
+
writable(); authorize?.();
|
|
196
|
+
const current = readOperation(dataRoot, id, marker);
|
|
197
|
+
if (current) {
|
|
198
|
+
if (current.action !== 'plugin.execute' || current.inputDigest !== pluginInputDigest(input.operationId, input.input, input.dependency, current.sourceArtifactIds)) {
|
|
199
|
+
operationFail('IRIS_OPERATION_KEY_CONFLICT', '该 Key 已绑定另一业务或输入');
|
|
200
|
+
}
|
|
201
|
+
replay = true; return current;
|
|
202
|
+
}
|
|
203
|
+
if (readAllOperations(dataRoot).filter(activeState).length >= MAX_ACTIVE_OPERATIONS - Number(scheduled)) operationFail('IRIS_OPERATION_QUEUE_FULL', '未结束的 Operation 已达上限;调度模式保留一个位置供原任务恢复');
|
|
204
|
+
return acceptPluginOperation(dataRoot, marker, id, { ...input, input: reservation.input,
|
|
205
|
+
sourceArtifactIds: pluginSourceArtifacts(reservation.input, reservation.artifactInputs), ownerInstanceId });
|
|
206
|
+
}));
|
|
207
|
+
} finally { reservation.release(); }
|
|
208
|
+
if (!replay) queue.push({ id, controller: new AbortController(), stopReason: null, authorize });
|
|
209
|
+
if (!replay || record.state === 'queued' && record.ownerInstanceId === ownerInstanceId) queueMicrotask(pump);
|
|
210
|
+
return { contractVersion: 0, operation: projectOperation(record), idempotentReplay: replay };
|
|
211
|
+
}
|
|
212
|
+
if (!input || typeof input !== 'object' || Array.isArray(input) || Object.keys(input).length !== 3
|
|
213
|
+
|| !['key', 'runId', 'expectedRunRevision'].every(key => Object.hasOwn(input, key))) {
|
|
214
|
+
operationFail('IRIS_OPERATION_INPUT_INVALID', '需要明确 Key、Run 和预期 revision');
|
|
215
|
+
}
|
|
216
|
+
const marker = inspectExecutionRoot(physicalRoot), id = operationId(marker, input.key);
|
|
217
|
+
const previous = readOperation(physicalRoot, id, marker);
|
|
218
|
+
if (previous) {
|
|
219
|
+
if (previous.runId !== input.runId || previous.expectedRunRevision !== input.expectedRunRevision) {
|
|
220
|
+
operationFail('IRIS_OPERATION_KEY_CONFLICT', '该 Key 已绑定其他操作输入');
|
|
221
|
+
}
|
|
222
|
+
if (previous.state === 'queued' && previous.ownerInstanceId === ownerInstanceId) queueMicrotask(pump);
|
|
223
|
+
return { contractVersion: 0, operation: projectOperation(previous), idempotentReplay: true };
|
|
224
|
+
}
|
|
225
|
+
if (!scheduled && (application.snapshot().activeWrites || application.snapshot().queuedWrites)) busy();
|
|
226
|
+
const accepted = await transaction(runtime => runtime.run('execute', ({ dataRoot }) => {
|
|
227
|
+
writable();
|
|
228
|
+
if (scheduled && !readOperation(dataRoot, id) && readAllOperations(dataRoot).filter(activeState).length >= MAX_ACTIVE_OPERATIONS - 1
|
|
229
|
+
&& !findFlowTask(dataRoot, readFlowRun(dataRoot, input.runId))?.attempts.length) {
|
|
230
|
+
operationFail('IRIS_OPERATION_QUEUE_FULL', '新提交队列已满;保留一个位置用于原任务查询和交付');
|
|
231
|
+
}
|
|
232
|
+
return acceptOperation(dataRoot, { ...input, ownerInstanceId });
|
|
233
|
+
}));
|
|
234
|
+
if (!accepted.idempotentReplay) {
|
|
235
|
+
queue.push({ id: accepted.operation.id, controller: new AbortController(), stopReason: null });
|
|
236
|
+
queueMicrotask(pump);
|
|
237
|
+
}
|
|
238
|
+
return { contractVersion: 0, operation: projectOperation(accepted.operation), idempotentReplay: accepted.idempotentReplay };
|
|
239
|
+
}
|
|
240
|
+
async function stop(id) {
|
|
241
|
+
writable();
|
|
242
|
+
const record = readOperation(physicalRoot, id);
|
|
243
|
+
if (!record) operationFail('IRIS_OPERATION_NOT_FOUND', 'Operation 不存在');
|
|
244
|
+
if (activeState(record)) {
|
|
245
|
+
if (record.ownerInstanceId !== ownerInstanceId) operationFail('IRIS_EXECUTION_OWNERSHIP_LOST', '不能停止另一执行实例的操作');
|
|
246
|
+
const job = active.get(id) || queue.find(job => job.id === id);
|
|
247
|
+
if (job) { job.stopReason = 'canceled'; job.controller.abort(); }
|
|
248
|
+
if (active.has(id)) await job.done;
|
|
249
|
+
else {
|
|
250
|
+
await finishIfActive(id, 'canceled');
|
|
251
|
+
const index = queue.indexOf(job);
|
|
252
|
+
if (index >= 0) queue.splice(index, 1);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const saved = readOperation(physicalRoot, id);
|
|
256
|
+
if (activeState(saved)) operationFail('IRIS_EXECUTION_STOP_UNSAVED', '停止尚未持久保存,请查询原 Operation');
|
|
257
|
+
return { contractVersion: 0, operation: projectOperation(saved),
|
|
258
|
+
localStopped: saved.state === 'canceled', remoteCancellation: 'not_requested' };
|
|
259
|
+
}
|
|
260
|
+
async function idle() { if (draining) await draining; if (failure) throw failure; return snapshot(); }
|
|
261
|
+
function dispose() {
|
|
262
|
+
if (disposal) return disposal;
|
|
263
|
+
if (state === 'new') { state = 'closed'; return disposal = suppliedApplication ? Promise.resolve(snapshot()) : application.dispose().then(snapshot); }
|
|
264
|
+
if (state === 'closed') return Promise.resolve(snapshot());
|
|
265
|
+
if (state !== 'failed') state = 'closing';
|
|
266
|
+
for (const job of active.values()) { job.stopReason ||= 'interrupted'; job.controller.abort(); }
|
|
267
|
+
disposal = (async () => {
|
|
268
|
+
if (draining) await draining;
|
|
269
|
+
if (failure) throw failure;
|
|
270
|
+
for (const job of queue.splice(0)) await finishIfActive(job.id, 'interrupted');
|
|
271
|
+
if (!suppliedApplication) await application.dispose();
|
|
272
|
+
await lease?.dispose(); state = 'closed'; return snapshot();
|
|
273
|
+
})();
|
|
274
|
+
return disposal;
|
|
275
|
+
}
|
|
276
|
+
return Object.freeze({ start, submit, stop, idle, dispose, snapshot,
|
|
277
|
+
scheduling(id) {
|
|
278
|
+
const job = active.get(id), phase = job?.runId && application.schedulingPhase(`run:${job.runId}`);
|
|
279
|
+
return scheduled && phase ? { phase } : undefined;
|
|
280
|
+
} });
|
|
281
|
+
}
|