@mokuyoaxis/dsh-iris 0.2.5 → 0.2.7
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/.dsh/skills/iris-use/SKILL.md +2 -2
- package/.dsh/skills/iris-use/references/media-contract.md +2 -2
- package/CHANGELOG.md +49 -1
- package/README.en.md +39 -22
- package/README.md +40 -21
- package/bin/dsh-iris.js +166 -35
- package/docs/AGENT_QUICKSTART.md +7 -7
- package/docs/ARCHITECTURE.md +9 -3
- package/docs/CLI_MANAGEMENT.md +5 -1
- package/docs/CORE_RUNTIME_CONTRACT.md +36 -5
- package/docs/CORE_SKILLS.md +62 -0
- package/docs/DSH_COMPATIBILITY.md +8 -4
- package/docs/DSH_CORE_MIGRATION.md +4 -2
- package/docs/DSH_PROVIDER_CONNECTIONS.md +34 -0
- package/docs/DSH_RUNTIME.md +36 -0
- package/docs/FUNCTION_MODEL_ASSIGNMENTS.md +2 -0
- package/docs/FUNCTION_MODEL_POOLS.md +84 -0
- package/docs/HEADLESS_CLI.md +16 -8
- package/docs/HOST_ADAPTER_CONTRACT.md +7 -2
- package/docs/HOST_DOCTOR.md +14 -2
- package/docs/IMAGE_FLOW.md +71 -0
- package/docs/LOCAL_API.md +60 -0
- package/docs/LOCAL_API_CONTRACT.md +115 -0
- package/docs/LOCAL_CREATION.md +50 -0
- package/docs/MUSIC.md +2 -2
- package/docs/PROVIDER_HEALTH.md +26 -2
- package/docs/ROADMAP.md +18 -4
- package/docs/SECURITY.md +20 -2
- package/docs/SYNC_DELIVERY_RECOVERY.md +2 -0
- package/docs/VISION_CLI.md +2 -2
- package/docs/WEB_PROTOTYPE.md +47 -0
- package/docs/releases/0.2.6.md +50 -0
- package/docs/releases/0.2.7.md +62 -0
- package/lib/actions.js +208 -90
- package/lib/adapters.js +36 -9
- package/lib/api.js +40 -39
- package/lib/application-runtime.js +186 -0
- package/lib/client.js +805 -182
- package/lib/composite-vision-routing.js +6 -6
- package/lib/config.js +140 -29
- package/lib/core-artifact-store.js +5 -0
- package/lib/core-maintenance.js +25 -2
- package/lib/core-runtime.js +19 -0
- package/lib/core-tasks.js +62 -8
- package/lib/creation-request-store.js +159 -0
- package/lib/doctor.js +20 -2
- package/lib/dsh-core-adapter.js +669 -564
- package/lib/dsh-host-adapter.js +14 -1
- package/lib/dsh-provider-config.js +35 -0
- package/lib/dsh-provider-source.js +102 -0
- package/lib/flow-store.js +195 -0
- package/lib/headless-vision.js +5 -5
- package/lib/host-contract.js +1 -0
- package/lib/http-vision-model-adapter.js +5 -2
- package/lib/image-flow.js +188 -0
- package/lib/index.js +103 -78
- package/lib/local-api-projection.js +27 -0
- package/lib/local-api.js +381 -0
- package/lib/local-creation.js +85 -0
- package/lib/model-directory-service.js +53 -0
- package/lib/model-directory.js +103 -0
- package/lib/models.js +71 -6
- package/lib/ocr-model-routing.js +2 -2
- package/lib/private-storage.js +12 -0
- package/lib/provider-adapters.js +42 -9
- package/lib/provider-catalog.js +14 -9
- package/lib/provider-config-scope.js +16 -0
- package/lib/provider-config-service.js +82 -13
- package/lib/provider-health.js +33 -0
- package/lib/provider-protocol.js +30 -0
- package/lib/provider-task-runner.js +22 -2
- package/lib/render-address.js +35 -0
- package/lib/render.js +52 -13
- package/lib/skill-installation.js +199 -0
- package/lib/vision-model-routing.js +25 -12
- package/lib/workbench-artifacts.js +1 -2
- package/lib/workbench-management.js +8 -9
- package/package.json +4 -3
- package/skills/iris-core/SKILL.md +40 -0
- package/skills/iris-core/agents/openai.yaml +4 -0
- package/skills/iris-core/references/cli.md +95 -0
- package/skills/iris-core/references/tasks.md +58 -0
- package/user_guide.md +32 -20
|
@@ -8,10 +8,10 @@ import { VISION_BUDGET } from './vision-core.js';
|
|
|
8
8
|
import { normalizeModelCall } from './model-port-contract.js';
|
|
9
9
|
import { waitForModelWork } from './model-call-runtime.js';
|
|
10
10
|
|
|
11
|
-
export async function runLocateRequest(host, { providers = [], model, target, image, prepareImage, signal, budget = VISION_BUDGET, onRateLimit }) {
|
|
11
|
+
export async function runLocateRequest(host, { providers = [], model, target, image, prepareImage, signal, budget = VISION_BUDGET, onRateLimit, onModelAvailability }) {
|
|
12
12
|
target = normalizeLocateTarget(target);
|
|
13
13
|
const inputs = new Map();
|
|
14
|
-
const result = await runVisionOperation(host, { providers, model, signal, budget, onRateLimit, prepareImages: true,
|
|
14
|
+
const result = await runVisionOperation(host, { providers, model, signal, budget, onRateLimit, onModelAvailability, prepareImages: true,
|
|
15
15
|
orientImages: true, prepareImageRequest: createLocateImageRequest(target, inputs) }, async (ports, operation) => {
|
|
16
16
|
const resolved = await waitForModelWork(() => prepareImage ? prepareImage(operation.signal) : image, operation.signal);
|
|
17
17
|
const source = normalizeModelCall(ports[0].describe(), { prompt: target, image: resolved }, { budget }).request.image;
|
|
@@ -23,8 +23,8 @@ export async function runLocateRequest(host, { providers = [], model, target, im
|
|
|
23
23
|
return location;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
export async function runSummaryRequest(host, { providers = [], model, prepareMedia, frames, question, transcript, signal, budget = VISION_BUDGET, onRateLimit }) {
|
|
27
|
-
return runVisionOperation(host, { providers, model, signal, budget, onRateLimit }, async (ports, operation) => {
|
|
26
|
+
export async function runSummaryRequest(host, { providers = [], model, prepareMedia, frames, question, transcript, signal, budget = VISION_BUDGET, onRateLimit, onModelAvailability }) {
|
|
27
|
+
return runVisionOperation(host, { providers, model, signal, budget, onRateLimit, onModelAvailability }, async (ports, operation) => {
|
|
28
28
|
const media = await waitForModelWork(() => prepareMedia ? prepareMedia(operation.signal) : { frames, transcript }, operation.signal);
|
|
29
29
|
const completion = await summarizeMedia({ ports, frames: media.frames, question, transcript: media.transcript,
|
|
30
30
|
operation, budget });
|
|
@@ -33,9 +33,9 @@ export async function runSummaryRequest(host, { providers = [], model, prepareMe
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
/** 增强描述沿用共享调用;失败不改变已经完成的媒体交付。 */
|
|
36
|
-
export async function describeGeneratedImage(host, { providers = [], originalPrompt, image, prepareImage, signal, onRateLimit }) {
|
|
36
|
+
export async function describeGeneratedImage(host, { providers = [], originalPrompt, image, prepareImage, signal, onRateLimit, onModelAvailability }) {
|
|
37
37
|
try {
|
|
38
|
-
const result = await runVisionRequest(host, { providers, image, prepareImage, signal, onRateLimit,
|
|
38
|
+
const result = await runVisionRequest(host, { providers, image, prepareImage, signal, onRateLimit, onModelAvailability,
|
|
39
39
|
question: `用不超过两句话描述这张图片的主题与构图。生成该图的提示词是:「${String(originalPrompt).slice(0, 200)}」` });
|
|
40
40
|
return result.answer.slice(0, 300);
|
|
41
41
|
} catch (_) { return ''; }
|
package/lib/config.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
import { normalizeVisionInputLimits } from './vision-image-input.js';
|
|
3
|
+
import { scopedProvider } from './provider-config-scope.js';
|
|
4
|
+
import { currentModelDirectory, modelDirectoryBinding, modelAvailabilityBinding, modelUnavailable, modelUnavailableReason, validateModelDirectory } from './model-directory.js';
|
|
5
|
+
import { refreshModelDirectory } from './model-directory-service.js';
|
|
3
6
|
/**
|
|
4
7
|
* Iris 配置存储:$DSH_HOME/iris/v1/providers.json
|
|
5
8
|
* Iris 独立持有配置;旧工作台仅作为用户显式指定的一次性导入来源。
|
|
@@ -19,7 +22,7 @@ import {
|
|
|
19
22
|
healthObservationState,
|
|
20
23
|
isDefinitiveHealthFailure,
|
|
21
24
|
newestHealthEvidence,
|
|
22
|
-
modelRateLimit, recordModelRateLimit, clearModelRateLimit, clearModelCapabilityHealth
|
|
25
|
+
modelRateLimit, recordModelRateLimit, clearModelRateLimit, clearModelCapabilityHealth, recordModelAvailability, resetHealthOnCriticalChange
|
|
23
26
|
} from './provider-health.js';
|
|
24
27
|
|
|
25
28
|
function isPlainObject(value) {
|
|
@@ -30,12 +33,21 @@ function validateStoredConfig(value) {
|
|
|
30
33
|
if (!isPlainObject(value)) throw new Error('配置根节点必须是对象');
|
|
31
34
|
if (!Array.isArray(value.providers)) throw new Error('providers 必须是数组');
|
|
32
35
|
if (!isPlainObject(value.assignments)) throw new Error('assignments 必须是对象');
|
|
36
|
+
for (const [capability, assignment] of Object.entries(value.assignments)) models.functionPolicy(assignment, capability);
|
|
33
37
|
for (const provider of value.providers) {
|
|
34
38
|
if (!isPlainObject(provider)) throw new Error('provider 条目必须是对象');
|
|
39
|
+
validateModelDirectory(provider.modelDirectory);
|
|
35
40
|
if (provider.id !== undefined && typeof provider.id !== 'string') throw new Error('provider.id 必须是字符串');
|
|
36
41
|
if (provider.baseUrl !== undefined && typeof provider.baseUrl !== 'string') throw new Error('provider.baseUrl 必须是字符串');
|
|
37
42
|
if (provider.mediaBaseUrl !== undefined && typeof provider.mediaBaseUrl !== 'string') throw new Error('provider.mediaBaseUrl 必须是字符串');
|
|
38
43
|
if (provider.apiKey !== undefined && typeof provider.apiKey !== 'string') throw new Error('provider.apiKey 必须是字符串');
|
|
44
|
+
if (provider.dshProvider !== undefined) {
|
|
45
|
+
const ref = provider.dshProvider;
|
|
46
|
+
if (!isPlainObject(ref) || typeof ref.provider !== 'string' || !ref.provider.trim()
|
|
47
|
+
|| typeof ref.settingsNs !== 'string' || !ref.settingsNs.trim()
|
|
48
|
+
|| Object.keys(ref).some(key => !['provider', 'settingsNs'].includes(key))) throw new Error('DSH 连接引用无效');
|
|
49
|
+
if (provider.apiKey !== undefined || provider.baseUrl !== undefined) throw new Error('DSH 引用不能同时保存密钥或普通 Base URL');
|
|
50
|
+
}
|
|
39
51
|
if (provider.models !== undefined && !Array.isArray(provider.models)) throw new Error('provider.models 必须是数组');
|
|
40
52
|
for (const model of provider.models || []) {
|
|
41
53
|
if (typeof model === 'string') continue;
|
|
@@ -58,6 +70,7 @@ function authModeOf(provider) {
|
|
|
58
70
|
/** 该供应商是否缺少必需凭据('none' 认证永不缺)。 */
|
|
59
71
|
function missingCredentials(provider) {
|
|
60
72
|
if (!provider || authModeOf(provider) === 'none') return false;
|
|
73
|
+
if (provider.dshProvider) return false;
|
|
61
74
|
return typeof provider.apiKey !== 'string' || !provider.apiKey.trim();
|
|
62
75
|
}
|
|
63
76
|
|
|
@@ -172,7 +185,37 @@ export function importFromWorkbench(workbenchConfigPath) {
|
|
|
172
185
|
}
|
|
173
186
|
|
|
174
187
|
export function providers() {
|
|
175
|
-
return
|
|
188
|
+
return runtimeProviders().filter(isConfiguredProvider);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Runtime copies may contain Host-resolved credentials; never persist these copies. */
|
|
192
|
+
export function runtimeProviders() {
|
|
193
|
+
return load().providers.map(scopedProvider);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** 目录更新只保存可选元数据,不保存宿主解析出的连接副本。 */
|
|
197
|
+
export function saveModelDirectory(providerId, directory) {
|
|
198
|
+
const provider = load().providers.find(item => item.id === providerId);
|
|
199
|
+
const current = runtimeProviders().find(item => item.id === providerId);
|
|
200
|
+
if (!provider || !current || modelDirectoryBinding(current) !== directory.binding) return false;
|
|
201
|
+
provider.modelDirectory = directory;
|
|
202
|
+
// 同一次操作的已解析副本也使用最新快照。
|
|
203
|
+
if (current !== provider) current.modelDirectory = directory;
|
|
204
|
+
persist();
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export async function refreshProviderDirectories({ providerId, force = false, signal } = {}) {
|
|
209
|
+
const selected = providers().filter(provider => !providerId || provider.id === providerId);
|
|
210
|
+
return Promise.all(selected.map(provider => refreshModelDirectory(provider, { force, signal,
|
|
211
|
+
save: directory => saveModelDirectory(provider.id, directory) })));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export async function refreshAutomaticDirectories({ capability, signal } = {}) {
|
|
215
|
+
const capabilities = (capability ? [capability] : Object.values(cap.CAPABILITIES)).filter(c => models.functionPolicy(assignments()[c], c).mode === 'auto');
|
|
216
|
+
const selected = providers().filter(provider => models.providerModels(provider).some(model => model.capabilities.some(c => capabilities.includes(c))));
|
|
217
|
+
return Promise.all(selected.map(provider => refreshModelDirectory(provider, { signal,
|
|
218
|
+
save: directory => saveModelDirectory(provider.id, directory) })));
|
|
176
219
|
}
|
|
177
220
|
|
|
178
221
|
export function providerById(id) {
|
|
@@ -183,7 +226,7 @@ export function providerById(id) {
|
|
|
183
226
|
const FIELD_OF = models.CAP_FIELD;
|
|
184
227
|
|
|
185
228
|
/**
|
|
186
|
-
*
|
|
229
|
+
* 按当前功能策略取第一个可用模型。
|
|
187
230
|
* 返回该模型所属 provider 的副本,并把该能力的模型字段设为选中模型。
|
|
188
231
|
* 向后兼容:调用方继续读 provider.apiKey / provider.imageModel 等。
|
|
189
232
|
* 无可用模型 → null(不再兜底到任意 provider)。
|
|
@@ -194,12 +237,12 @@ export function pickFor(capability) {
|
|
|
194
237
|
|
|
195
238
|
/**
|
|
196
239
|
* 同一能力的有序 provider 列表(供 failover 依次尝试):
|
|
197
|
-
*
|
|
240
|
+
* 自动池按登记顺序选择;手选名单不补池,停用没有候选。
|
|
198
241
|
* 一个 provider 若池里有多个该能力模型,会返回多条(各自带不同模型)。
|
|
199
242
|
*/
|
|
200
|
-
export function pickAllFor(capability, { includeRateLimited = false } = {}) {
|
|
201
|
-
const pool = models.
|
|
202
|
-
const chosen = models.orderedModels(pool, capability, assignments()[capability], { includeRateLimited });
|
|
243
|
+
export function pickAllFor(capability, { includeRateLimited = false, includeUnavailable = false } = {}) {
|
|
244
|
+
const pool = models.functionModelPool(providers(), capability, assignments()[capability], { includeHostVision: true });
|
|
245
|
+
const chosen = models.orderedModels(pool, capability, assignments()[capability], { includeRateLimited, includeUnavailable });
|
|
203
246
|
const out = [];
|
|
204
247
|
for (const m of chosen) {
|
|
205
248
|
if (m.ref === models.HOST_VISION_REF) {
|
|
@@ -226,8 +269,11 @@ export function assignmentOrder(capability) {
|
|
|
226
269
|
const a = assignments()[capability];
|
|
227
270
|
if (!a) return [];
|
|
228
271
|
const pool = models.modelPool(allProviders(), { includeHostVision: true });
|
|
272
|
+
const policy = models.functionPolicy(a, capability);
|
|
273
|
+
if (policy.mode === 'auto') return models.orderedModels(pool, capability, a, { includeRateLimited: true, includeUnavailable: true }).map(model => model.ref);
|
|
274
|
+
if (policy.mode === 'disabled') return [];
|
|
229
275
|
const refs = [];
|
|
230
|
-
for (const raw of
|
|
276
|
+
for (const raw of policy.models) {
|
|
231
277
|
const m = resolveAssignment(pool, raw, capability);
|
|
232
278
|
if (m && !refs.includes(m.ref)) refs.push(m.ref);
|
|
233
279
|
else if (!m && typeof raw === 'string' && models.parseModelRef(raw) && !refs.includes(raw)) refs.push(raw);
|
|
@@ -284,6 +330,47 @@ export function clearAssignment(capability) {
|
|
|
284
330
|
return true;
|
|
285
331
|
}
|
|
286
332
|
|
|
333
|
+
/** Explicit policy changes preserve the previous manual list for a later switch. */
|
|
334
|
+
export function setFunctionPolicy(capability, mode, { hostVision } = {}) {
|
|
335
|
+
if (!cap.isKnownCapability(capability)) throw new TypeError('未知功能');
|
|
336
|
+
const c = load();
|
|
337
|
+
const previous = models.functionPolicy(c.assignments[capability], capability);
|
|
338
|
+
const next = { mode, models: models.savedAssignmentRefs(models.modelPool(c.providers, { includeHostVision: true }), capability, previous.models) };
|
|
339
|
+
if (capability === cap.CAPABILITIES.VISION) next.hostVision = hostVision === undefined ? previous.hostVision : hostVision;
|
|
340
|
+
models.functionPolicy(next, capability);
|
|
341
|
+
c.assignments[capability] = next;
|
|
342
|
+
persist();
|
|
343
|
+
return next;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** One user confirmation updates capabilities and optional automatic use atomically. */
|
|
347
|
+
export function confirmModelUsage(id, modelId, capabilities, { enableAutomatic = false, musicProtocol } = {}) {
|
|
348
|
+
const c = load();
|
|
349
|
+
const provider = c.providers.find(item => item.id === id);
|
|
350
|
+
if (!provider) throw new TypeError('供应商不存在');
|
|
351
|
+
if (typeof modelId !== 'string' || !modelId.trim() || !Array.isArray(capabilities)
|
|
352
|
+
|| !capabilities.every(cap.isKnownCapability) || typeof enableAutomatic !== 'boolean') throw new TypeError('请填写模型名称并确认已知功能');
|
|
353
|
+
if (musicProtocol !== undefined) normalizeMusicProtocol(musicProtocol);
|
|
354
|
+
const entries = (Array.isArray(provider.models) ? provider.models : models.providerModels(provider)).map(model => typeof model === 'string' ? { id: model } : { ...model });
|
|
355
|
+
let entry = entries.find(model => model.id === modelId.trim());
|
|
356
|
+
if (!entry) { entry = { id: modelId.trim() }; entries.push(entry); }
|
|
357
|
+
entry.capabilities = [...new Set(capabilities)];
|
|
358
|
+
entry.source = 'manual';
|
|
359
|
+
provider.models = entries;
|
|
360
|
+
if (musicProtocol !== undefined) {
|
|
361
|
+
if (entry.musicProtocol !== musicProtocol) clearModelCapabilityHealth(provider, entry.id, cap.CAPABILITIES.MUSIC);
|
|
362
|
+
entry.musicProtocol = musicProtocol;
|
|
363
|
+
}
|
|
364
|
+
if (enableAutomatic) for (const capability of entry.capabilities) {
|
|
365
|
+
const previous = models.functionPolicy(c.assignments[capability], capability);
|
|
366
|
+
c.assignments[capability] = { mode: 'auto', models: models.savedAssignmentRefs(models.modelPool(c.providers, { includeHostVision: true }), capability, previous.models),
|
|
367
|
+
...(capability === cap.CAPABILITIES.VISION ? { hostVision: previous.hostVision } : {}) };
|
|
368
|
+
}
|
|
369
|
+
pruneHealthToEffectiveModels(provider);
|
|
370
|
+
persist();
|
|
371
|
+
return entry;
|
|
372
|
+
}
|
|
373
|
+
|
|
287
374
|
/** 某 provider 的模型池覆盖的能力集合(合并各模型的能力标签) */
|
|
288
375
|
export function capabilitiesOf(p) {
|
|
289
376
|
const caps = new Set();
|
|
@@ -293,11 +380,6 @@ export function capabilitiesOf(p) {
|
|
|
293
380
|
return [...caps];
|
|
294
381
|
}
|
|
295
382
|
|
|
296
|
-
const HEALTH_SENSITIVE_FIELDS = new Set([
|
|
297
|
-
'auth', 'type', 'baseUrl', 'mediaBaseUrl', 'apiKey', 'mediaProtocol',
|
|
298
|
-
'imageModel', 'videoModel', 'ttsModel', 'transcribeModel', 'visionModel'
|
|
299
|
-
]);
|
|
300
|
-
|
|
301
383
|
function healthContainer(provider) {
|
|
302
384
|
const current = provider.health;
|
|
303
385
|
if (current && current.version === 1 && Number.isSafeInteger(current.revision)
|
|
@@ -318,7 +400,7 @@ function safeHealthNote(provider, value) {
|
|
|
318
400
|
const raw = String(value ?? '').trim();
|
|
319
401
|
if (!raw) return '';
|
|
320
402
|
let note = redactProviderMessage(raw, 200);
|
|
321
|
-
const key = String(provider && provider.apiKey || '');
|
|
403
|
+
const key = String(provider && scopedProvider(provider).apiKey || '');
|
|
322
404
|
if (key) note = note.split(key).join('[REDACTED]');
|
|
323
405
|
return note;
|
|
324
406
|
}
|
|
@@ -330,7 +412,9 @@ function observationEvidence(provider, result, fallbackSource) {
|
|
|
330
412
|
return {
|
|
331
413
|
at: new Date(Number.isFinite(atMs) ? atMs : Date.now()).toISOString(),
|
|
332
414
|
source: ['probe', 'task'].includes(result && result.source) ? result.source : fallbackSource,
|
|
415
|
+
...(result?.stage === 'poll' ? { stage: 'poll' } : {}),
|
|
333
416
|
category: String(result && result.category || (result && result.ok ? 'success' : 'unknown')).slice(0, 40),
|
|
417
|
+
...(typeof result?.providerCode === 'string' ? { providerCode: safeHealthNote(provider, result.providerCode) } : {}),
|
|
334
418
|
...(Number.isInteger(status) && status >= 100 && status <= 599 ? { httpStatus: status } : {}),
|
|
335
419
|
...(note ? { note } : {})
|
|
336
420
|
};
|
|
@@ -340,6 +424,8 @@ function applyProviderHealth(provider, modelId, capability, result, fallbackSour
|
|
|
340
424
|
const model = String(modelId || '').trim();
|
|
341
425
|
if (!provider || !model || !cap.isKnownCapability(capability)) return false;
|
|
342
426
|
const health = healthContainer(provider);
|
|
427
|
+
const scoped = scopedProvider(provider);
|
|
428
|
+
if (scoped !== provider) scoped.health = health;
|
|
343
429
|
let observation = health.observations.find((item) => item && item.modelId === model
|
|
344
430
|
&& item.capability === capability);
|
|
345
431
|
if (!observation) {
|
|
@@ -347,6 +433,12 @@ function applyProviderHealth(provider, modelId, capability, result, fallbackSour
|
|
|
347
433
|
health.observations.push(observation);
|
|
348
434
|
}
|
|
349
435
|
const evidence = observationEvidence(provider, result || {}, fallbackSource);
|
|
436
|
+
const effective = { ...scopedProvider(provider), health };
|
|
437
|
+
if (recordModelAvailability(effective, model, capability, { ...result, ...evidence,
|
|
438
|
+
binding: result?.binding || modelAvailabilityBinding(effective, model, capability) }) && observation.availability?.state === 'blocked') {
|
|
439
|
+
const directory = currentModelDirectory(effective);
|
|
440
|
+
if (directory) directory.invalidatedAt = evidence.at;
|
|
441
|
+
}
|
|
350
442
|
recordModelRateLimit(provider, model, { ...evidence, retryAfterMs: result?.retryAfterMs, until: result?.until, reason: result?.reason });
|
|
351
443
|
if (result && result.ok === true) {
|
|
352
444
|
observation.lastSuccess = evidence;
|
|
@@ -382,16 +474,6 @@ function pruneModelHealth(provider, modelId) {
|
|
|
382
474
|
provider.health.observations = provider.health.observations.filter((item) => item.modelId !== modelId);
|
|
383
475
|
}
|
|
384
476
|
|
|
385
|
-
function resetHealthOnCriticalChange(previous, next, patch) {
|
|
386
|
-
const changed = [...HEALTH_SENSITIVE_FIELDS].some((key) =>
|
|
387
|
-
Object.prototype.hasOwnProperty.call(patch, key) && previous[key] !== next[key]);
|
|
388
|
-
if (!changed) return;
|
|
389
|
-
const current = previous.health;
|
|
390
|
-
const revision = Number.isSafeInteger(current && current.revision) ? current.revision + 1 : 1;
|
|
391
|
-
next.health = { version: 1, revision, observations: [],
|
|
392
|
-
...(current?.rateLimits?.length ? { rateLimits: current.rateLimits } : {}) };
|
|
393
|
-
}
|
|
394
|
-
|
|
395
477
|
/** 记录显式实测或真实任务观察;限流资格另存,保留原成功事实。 */
|
|
396
478
|
export function recordProviderHealth(id, modelId, capability, result) {
|
|
397
479
|
const c = load();
|
|
@@ -403,6 +485,14 @@ export function recordProviderHealth(id, modelId, capability, result) {
|
|
|
403
485
|
return true;
|
|
404
486
|
}
|
|
405
487
|
|
|
488
|
+
/** Adapter提交边界的可用性事件;poll不会调用这一入口。 */
|
|
489
|
+
export function recordAvailability({ providerId, modelId, capability, ...result }) {
|
|
490
|
+
const known = { image: cap.CAPABILITIES.IMAGE, video: cap.CAPABILITIES.VIDEO, music: cap.CAPABILITIES.MUSIC }[capability] || capability;
|
|
491
|
+
const current = runtimeProviders().find(provider => provider.id === providerId);
|
|
492
|
+
if (!current || result.binding && modelAvailabilityBinding(current, modelId, known) !== result.binding) return false;
|
|
493
|
+
return recordProviderHealth(providerId, modelId, known, result);
|
|
494
|
+
}
|
|
495
|
+
|
|
406
496
|
/** DSH 调用边界记录冷却或明确耗尽;覆盖同账号该模型的所有能力。 */
|
|
407
497
|
export function recordRateLimit({ providerId, modelId, capability, httpStatus = 429, category = 'rate_limit', at, until, retryAfterMs, reason }) {
|
|
408
498
|
const known = { image: 'image-gen', video: 'video-gen' }[capability] || capability;
|
|
@@ -421,6 +511,10 @@ export function modelHealth(id, modelId, capability, { now = Date.now() } = {})
|
|
|
421
511
|
source: blocked.source, category: blocked.category, httpStatus: blocked.httpStatus,
|
|
422
512
|
...(blocked.until ? { retryAt: blocked.until } : { reason: blocked.reason }),
|
|
423
513
|
note: blocked.until ? '429 限流冷却,暂时跳过' : '额度或预算耗尽后已停用,手动实测成功后恢复' };
|
|
514
|
+
const unavailable = modelUnavailable(scopedProvider(provider), modelId, capability, { allowUnresolvedConnections: true });
|
|
515
|
+
if (unavailable) return { status: 'failed', modelUnavailable: true, reason: 'model_not_found',
|
|
516
|
+
observedAt: unavailable.at, source: unavailable.source, httpStatus: 404, category: 'invalid_request',
|
|
517
|
+
note: modelUnavailableReason() };
|
|
424
518
|
const observation = healthObservation(provider, modelId, capability);
|
|
425
519
|
const status = healthObservationState(observation, { now });
|
|
426
520
|
const evidence = status === 'verified'
|
|
@@ -441,12 +535,18 @@ export function modelHealth(id, modelId, capability, { now = Date.now() } = {})
|
|
|
441
535
|
/** 当前有序 failover 路径的健康快照;只含脱敏标量,不触发网络。 */
|
|
442
536
|
export function providerHealthSnapshot({ now = Date.now() } = {}) {
|
|
443
537
|
const capabilities = {};
|
|
538
|
+
// A stored Host reference is configured without local endpoint/key fields.
|
|
539
|
+
// This snapshot reports configuration and past evidence, not live readiness.
|
|
540
|
+
const healthProviders = runtimeProviders().filter(provider => provider.enabled !== false
|
|
541
|
+
&& (provider.dshProvider || isConfiguredProvider(provider)));
|
|
444
542
|
for (const capability of Object.values(cap.CAPABILITIES)) {
|
|
445
|
-
const
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
543
|
+
const pool = models.functionModelPool(healthProviders, capability, assignments()[capability], {
|
|
544
|
+
includeHostVision: true, allowUnresolvedConnections: true
|
|
545
|
+
});
|
|
546
|
+
const candidates = models.orderedModels(pool, capability, assignments()[capability], { includeRateLimited: true, includeUnavailable: true }).map((model) => {
|
|
547
|
+
const health = model.ref === models.HOST_VISION_REF ? { status: 'configured', source: 'configuration' }
|
|
548
|
+
: modelHealth(model.providerId, model.id, capability, { now });
|
|
549
|
+
return { providerId: model.providerId, modelId: model.id, ...health };
|
|
450
550
|
});
|
|
451
551
|
const status = aggregateHealthStates(candidates.map((item) => item.status));
|
|
452
552
|
const newest = [...candidates]
|
|
@@ -480,6 +580,17 @@ export function upsert(provider) {
|
|
|
480
580
|
const { id, ...input } = provider;
|
|
481
581
|
// PATCH 语义:缺失字段和 undefined 都不覆盖旧值;null/空串仍是显式更新。
|
|
482
582
|
const rest = Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined));
|
|
583
|
+
const existing = id && c.providers.find(item => item.id === id);
|
|
584
|
+
const reference = rest.dshProvider || existing?.dshProvider;
|
|
585
|
+
if (reference) {
|
|
586
|
+
if (!isPlainObject(reference) || typeof reference.provider !== 'string' || !reference.provider.trim()
|
|
587
|
+
|| typeof reference.settingsNs !== 'string' || !reference.settingsNs.trim()
|
|
588
|
+
|| Object.keys(reference).some(key => !['provider', 'settingsNs'].includes(key))) throw new Error('DSH 连接引用无效');
|
|
589
|
+
if (['apiKey', 'baseUrl'].some(key => rest[key] !== undefined || existing?.[key] !== undefined)) {
|
|
590
|
+
throw new Error('DSH 引用不保存密钥或普通 Base URL;请新建引用供应商');
|
|
591
|
+
}
|
|
592
|
+
rest.dshProvider = { provider: reference.provider.trim(), settingsNs: reference.settingsNs.trim() };
|
|
593
|
+
}
|
|
483
594
|
if (rest.visionInput !== undefined) rest.visionInput = normalizeVisionInputLimits(rest.visionInput) || null;
|
|
484
595
|
if (Array.isArray(rest.models)) rest.models = rest.models.map(model => typeof model === 'string' ? model : ({ ...model,
|
|
485
596
|
...(model?.imageProtocol !== undefined ? { imageProtocol: normalizeImageProtocol(model.imageProtocol) } : {}),
|
|
@@ -422,6 +422,11 @@ export function inspectCoreArtifact(dataRoot, id) {
|
|
|
422
422
|
return publicArtifact(readArtifact(dataRoot, id).manifest);
|
|
423
423
|
}
|
|
424
424
|
|
|
425
|
+
/** 先检查清单/普通文件大小,用于媒体边界限额;读取或提供字节时仍必须校验内容哈希。 */
|
|
426
|
+
export function inspectCoreArtifactMetadata(dataRoot, id) {
|
|
427
|
+
return publicArtifact(readArtifact(dataRoot, id, { verifyContent: false }).manifest);
|
|
428
|
+
}
|
|
429
|
+
|
|
425
430
|
/** 供宿主投影层读取已经校验过内容哈希的字节;不暴露 Core 内部路径。 */
|
|
426
431
|
export function readCoreArtifactBytes(dataRoot, id) {
|
|
427
432
|
const hit = readArtifact(dataRoot, id);
|
package/lib/core-maintenance.js
CHANGED
|
@@ -2,9 +2,11 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import crypto from 'node:crypto';
|
|
4
4
|
import { inspectCoreArtifact, refreshCoreArtifactInventory } from './core-artifacts.js';
|
|
5
|
-
import { inspectCoreTask, listCoreTasks } from './core-tasks.js';
|
|
5
|
+
import { assertFlowTaskMatches, inspectCoreTask, listCoreTasks } from './core-tasks.js';
|
|
6
6
|
import { atomicWritePrivate } from './private-storage.js';
|
|
7
7
|
import { readDeliveryCache } from './core-delivery-cache.js';
|
|
8
|
+
import { flowReferences } from './flow-store.js';
|
|
9
|
+
import { creationReferences } from './creation-request-store.js';
|
|
8
10
|
|
|
9
11
|
const TASK = /^task_[a-f0-9]{24}$/;
|
|
10
12
|
const ARTIFACT = /^artifact_[a-f0-9]{24}$/;
|
|
@@ -51,7 +53,18 @@ function inventory(root) {
|
|
|
51
53
|
}
|
|
52
54
|
const artifacts = names(root, 'artifact-store/v0/records').filter(name => ARTIFACT.test(name.slice(0, -5)) && name.endsWith('.json'))
|
|
53
55
|
.map(name => inspectCoreArtifact(root, name.slice(0, -5)));
|
|
54
|
-
|
|
56
|
+
const flows = flowReferences(root);
|
|
57
|
+
for (const reference of flows) {
|
|
58
|
+
if (reference.run.edit.claimed && !tasks.some(task => task.id === reference.run.edit.taskId)) {
|
|
59
|
+
fail('IRIS_FLOW_TASK_CONFLICT', '流程已建立的 Task 丢失,停止管理');
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
for (const task of tasks.filter(task => task.flowBinding)) {
|
|
63
|
+
const reference = flows.find(item => item.run.id === task.flowBinding.runId);
|
|
64
|
+
if (!reference) fail('IRIS_FLOW_TASK_CONFLICT', '流程 Task 的 Run 记录丢失,停止管理');
|
|
65
|
+
assertFlowTaskMatches(task, reference.run);
|
|
66
|
+
}
|
|
67
|
+
return { tasks, artifacts, flows, creations: creationReferences(root) };
|
|
55
68
|
}
|
|
56
69
|
|
|
57
70
|
function ids(value, pattern, label) {
|
|
@@ -65,6 +78,14 @@ export function planCoreDeletion(root, input) {
|
|
|
65
78
|
if (!taskIds.length && !artifactIds.length) fail('IRIS_COMMAND_INPUT_INVALID', '请明确指定 Task 或 Artifact ID');
|
|
66
79
|
const existing = inventory(root), selectedTasks = new Set(taskIds), selectedArtifacts = new Set(artifactIds);
|
|
67
80
|
const blockers = [], files = [];
|
|
81
|
+
for (const reference of existing.creations) {
|
|
82
|
+
for (const id of reference.taskIds) if (selectedTasks.has(id)) blockers.push({ id, referencedBy: reference.requestId, reason: 'creation_task_reference' });
|
|
83
|
+
for (const id of reference.artifactIds) if (selectedArtifacts.has(id)) blockers.push({ id, referencedBy: reference.requestId, reason: 'creation_artifact_reference' });
|
|
84
|
+
}
|
|
85
|
+
for (const reference of existing.flows) {
|
|
86
|
+
for (const id of reference.taskIds) if (selectedTasks.has(id)) blockers.push({ id, referencedBy: reference.run.id, reason: 'flow_task_reference' });
|
|
87
|
+
for (const id of reference.artifactIds) if (selectedArtifacts.has(id)) blockers.push({ id, referencedBy: reference.run.id, reason: 'flow_artifact_reference' });
|
|
88
|
+
}
|
|
68
89
|
for (const id of taskIds) {
|
|
69
90
|
const task = inspectCoreTask(root, id);
|
|
70
91
|
if (task.phase !== 'terminal' || task.acceptance === 'unknown' || task.outcome === 'unknown') blockers.push({ id, reason: 'task_not_settled' });
|
|
@@ -115,6 +136,8 @@ export function planCoreCleanup(root, { older_than_ms = 3600000 } = {}) {
|
|
|
115
136
|
if (!Number.isSafeInteger(older_than_ms) || older_than_ms < 60000) fail('IRIS_COMMAND_INPUT_INVALID', 'older_than_ms 至少为 60000');
|
|
116
137
|
const existing = inventory(root);
|
|
117
138
|
const referenced = new Set(existing.artifacts.map(artifact => artifact.id));
|
|
139
|
+
for (const reference of existing.creations) for (const id of reference.artifactIds) referenced.add(id);
|
|
140
|
+
for (const reference of existing.flows) for (const id of reference.artifactIds) referenced.add(id);
|
|
118
141
|
for (const artifact of existing.artifacts) for (const edge of artifact.relations) referenced.add(edge.artifactId);
|
|
119
142
|
for (const task of existing.tasks) for (const id of task.artifactIds) referenced.add(id);
|
|
120
143
|
for (const task of existing.tasks) if (task.deliveryCache && task.deliveryState !== 'ready') {
|
package/lib/core-runtime.js
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
transitionCoreLifecycle
|
|
14
14
|
} from './core-contract.js';
|
|
15
15
|
import { ensurePrivateDir } from './private-storage.js';
|
|
16
|
+
import { listCreationRequests } from './creation-request-store.js';
|
|
16
17
|
import { WRITER_LEASE_DIR, WRITER_INTENT_FILE, publishLeaseRecord, readWriterLease,
|
|
17
18
|
removeWriterIntent, retireLeaseDirectory, writerOwnerStatus } from './core-writer-lease.js';
|
|
18
19
|
|
|
@@ -162,6 +163,24 @@ export function createCoreRuntime(input) {
|
|
|
162
163
|
}
|
|
163
164
|
const candidateRoot = physicalDataRoot(options);
|
|
164
165
|
const candidateLease = options.mode === 'writer' ? acquireWriterLease(candidateRoot, instanceId) : null;
|
|
166
|
+
if (candidateLease) {
|
|
167
|
+
try { listCreationRequests(candidateRoot); }
|
|
168
|
+
catch (error) {
|
|
169
|
+
try { releaseWriterLease(candidateLease); }
|
|
170
|
+
catch (cleanupError) {
|
|
171
|
+
// Retained ownership must also make dispose fail, so the application stops its writer queue.
|
|
172
|
+
root = candidateRoot; lease = candidateLease;
|
|
173
|
+
lifecycle = transitionCoreLifecycle(lifecycle, 'dispose');
|
|
174
|
+
abortController.abort();
|
|
175
|
+
const failure = Object.assign(new AggregateError([error, cleanupError], '请求区检查失败且租约未能安全释放'),
|
|
176
|
+
{ code: 'IRIS_CORE_LEASE_INVALID' });
|
|
177
|
+
disposePromise = Promise.reject(failure);
|
|
178
|
+
void disposePromise.catch(() => {});
|
|
179
|
+
throw failure;
|
|
180
|
+
}
|
|
181
|
+
throw error;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
165
184
|
root = candidateRoot;
|
|
166
185
|
lease = candidateLease;
|
|
167
186
|
lifecycle = transitionCoreLifecycle(lifecycle, 'start');
|
package/lib/core-tasks.js
CHANGED
|
@@ -3,11 +3,12 @@
|
|
|
3
3
|
import crypto from 'node:crypto';
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
-
import { atomicWritePrivate, ensurePrivateDir } from './private-storage.js';
|
|
6
|
+
import { atomicCreatePrivate, atomicWritePrivate, ensurePrivateDir } from './private-storage.js';
|
|
7
7
|
import { parseModelRef } from './models.js';
|
|
8
8
|
import { providerErrorRecord } from './provider-contract.js';
|
|
9
9
|
import { deriveLegacyStatus, semanticViolations } from './task-semantics.js';
|
|
10
10
|
import { readDeliveryCache } from './core-delivery-cache.js';
|
|
11
|
+
import { flowTaskBinding, readFlowRun, validFlowBinding } from './flow-store.js';
|
|
11
12
|
|
|
12
13
|
const STORE_DIR = 'task-store';
|
|
13
14
|
const STORE_VERSION = 'v0';
|
|
@@ -110,6 +111,7 @@ function validateRecord(record, expectedId) {
|
|
|
110
111
|
|| (record.deliveryCache !== undefined && record.deliveryCache !== true)
|
|
111
112
|
|| (record.providerBinding !== undefined && !PROVIDER_BINDING.test(record.providerBinding))
|
|
112
113
|
|| (record.retriedFrom !== undefined && !TASK_ID.test(record.retriedFrom))
|
|
114
|
+
|| (record.flowBinding !== undefined && (record.capability !== 'image' || !validFlowBinding(record.flowBinding)))
|
|
113
115
|
|| (record.sourceArtifactId !== undefined && (record.capability !== 'image' || !ARTIFACT_ID.test(record.sourceArtifactId)))) {
|
|
114
116
|
fail('IRIS_TASK_INVALID', 'Task 记录结构无效');
|
|
115
117
|
}
|
|
@@ -143,11 +145,12 @@ function readRecord(dataRoot, id) {
|
|
|
143
145
|
return validateRecord(record, id);
|
|
144
146
|
}
|
|
145
147
|
|
|
146
|
-
function writeRecord(dataRoot, record) {
|
|
148
|
+
function writeRecord(dataRoot, record, exclusive = false) {
|
|
147
149
|
validateRecord(record, record.id);
|
|
148
150
|
try {
|
|
149
151
|
ensurePrivateDir(taskDirectory(dataRoot));
|
|
150
|
-
|
|
152
|
+
const publish = exclusive ? atomicCreatePrivate : atomicWritePrivate;
|
|
153
|
+
publish(taskFile(dataRoot, record.id), JSON.stringify(record, null, 2) + '\n');
|
|
151
154
|
} catch (error) {
|
|
152
155
|
if (error instanceof CoreTaskError) throw error;
|
|
153
156
|
fail('IRIS_TASK_WRITE_FAILED', 'Task 无法安全写入');
|
|
@@ -169,7 +172,7 @@ function safeError(error, overrides) {
|
|
|
169
172
|
return providerErrorRecord(error, overrides);
|
|
170
173
|
}
|
|
171
174
|
|
|
172
|
-
|
|
175
|
+
function newTaskRecord(input, id) {
|
|
173
176
|
const capability = String(input.capability || '').trim();
|
|
174
177
|
if (!CAPABILITIES.has(capability)) fail('IRIS_TASK_INPUT_INVALID', 'Task capability 无效');
|
|
175
178
|
const retriedFrom = input.retriedFrom === undefined ? undefined : String(input.retriedFrom);
|
|
@@ -177,10 +180,10 @@ export function createCoreTask(dataRoot, input = {}) {
|
|
|
177
180
|
const sourceArtifactId = input.sourceArtifactId;
|
|
178
181
|
if (sourceArtifactId !== undefined && (capability !== 'image' || !ARTIFACT_ID.test(sourceArtifactId))) fail('IRIS_TASK_INPUT_INVALID', '改图 sourceArtifactId 必须是合法 Artifact ID');
|
|
179
182
|
const now = new Date().toISOString();
|
|
180
|
-
return
|
|
183
|
+
return {
|
|
181
184
|
schemaVersion: 0,
|
|
182
185
|
semanticsVersion: 2,
|
|
183
|
-
id
|
|
186
|
+
id,
|
|
184
187
|
capability,
|
|
185
188
|
status: 'running',
|
|
186
189
|
phase: 'queued',
|
|
@@ -197,7 +200,50 @@ export function createCoreTask(dataRoot, input = {}) {
|
|
|
197
200
|
// D4 retry as new task:显式单向关系(新任务指向旧任务),绝不复制 Prompt。
|
|
198
201
|
...(retriedFrom !== undefined ? { retriedFrom } : {}),
|
|
199
202
|
...(sourceArtifactId !== undefined ? { sourceArtifactId } : {})
|
|
200
|
-
}
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function createCoreTask(dataRoot, input = {}) {
|
|
207
|
+
return writeRecord(dataRoot, newTaskRecord(input, 'task_' + crypto.randomBytes(12).toString('hex')));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Flow 只能接回自己持久预留的 Task;原有 Task 没有该字段,读取语义不变。 */
|
|
211
|
+
export function assertFlowTaskMatches(task, run) {
|
|
212
|
+
const providerId = parseModelRef(run.input.model_ref).providerId;
|
|
213
|
+
if (task.id !== run.edit.taskId || task.capability !== 'image' || task.sourceArtifactId !== run.source.artifactId
|
|
214
|
+
|| !validFlowBinding(task.flowBinding) || task.flowBinding.runId !== run.id
|
|
215
|
+
|| task.flowBinding.inputDigest !== run.inputDigest
|
|
216
|
+
|| task.attempts.some(attempt => attempt.providerId !== providerId
|
|
217
|
+
|| attempt.model !== run.input.model_ref || attempt.providerBinding !== run.providerBinding)
|
|
218
|
+
|| (task.attempts.length && (task.providerId !== providerId || task.modelRef !== run.input.model_ref || task.providerBinding !== run.providerBinding))) {
|
|
219
|
+
fail('IRIS_FLOW_TASK_CONFLICT', '流程与原 Task 的身份、输入或模型绑定冲突');
|
|
220
|
+
}
|
|
221
|
+
return task;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function findFlowTask(dataRoot, run) {
|
|
225
|
+
for (const relative of [STORE_DIR, STORE_DIR + '/' + STORE_VERSION, STORE_DIR + '/' + STORE_VERSION + '/tasks']) {
|
|
226
|
+
try {
|
|
227
|
+
const stat = fs.lstatSync(path.join(dataRoot, relative));
|
|
228
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) fail('IRIS_FLOW_TASK_CONFLICT', '流程 Task 目录不安全');
|
|
229
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
230
|
+
}
|
|
231
|
+
try { fs.lstatSync(taskFile(dataRoot, run.edit.taskId)); }
|
|
232
|
+
catch (error) {
|
|
233
|
+
if (error.code !== 'ENOENT') throw error;
|
|
234
|
+
if (run.edit.claimed) fail('IRIS_FLOW_TASK_CONFLICT', '流程已建立的 Task 丢失,禁止重建或重新生成');
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
return assertFlowTaskMatches(readRecord(dataRoot, run.edit.taskId), run);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** 仅从已提交的 Run 读取 ID;排他发布,新旧身份均不能覆盖。 */
|
|
241
|
+
export function claimFlowTask(dataRoot, runId) {
|
|
242
|
+
const run = readFlowRun(dataRoot, runId);
|
|
243
|
+
const previous = findFlowTask(dataRoot, run);
|
|
244
|
+
if (previous) return previous;
|
|
245
|
+
return writeRecord(dataRoot, { ...newTaskRecord({ capability: 'image', sourceArtifactId: run.source.artifactId }, run.edit.taskId),
|
|
246
|
+
flowBinding: flowTaskBinding(run) }, true);
|
|
201
247
|
}
|
|
202
248
|
|
|
203
249
|
export function inspectCoreTask(dataRoot, id) {
|
|
@@ -366,7 +412,7 @@ export function activateCoreWatch(dataRoot, taskId) {
|
|
|
366
412
|
});
|
|
367
413
|
}
|
|
368
414
|
|
|
369
|
-
export function recordCorePollResult(dataRoot, taskId, result) {
|
|
415
|
+
export function recordCorePollResult(dataRoot, taskId, result, { deliveryCache = false } = {}) {
|
|
370
416
|
return mutate(dataRoot, taskId, (task) => {
|
|
371
417
|
if (task.acceptance !== 'accepted' || !task.remoteTaskId) {
|
|
372
418
|
fail('IRIS_TASK_NOT_OBSERVABLE', 'Task 缺少可观察的远端受理事实');
|
|
@@ -387,6 +433,7 @@ export function recordCorePollResult(dataRoot, taskId, result) {
|
|
|
387
433
|
task.phase = 'running';
|
|
388
434
|
task.outcome = 'succeeded';
|
|
389
435
|
task.deliveryState = 'pending';
|
|
436
|
+
if (deliveryCache) task.deliveryCache = true;
|
|
390
437
|
delete task.lastError;
|
|
391
438
|
return;
|
|
392
439
|
}
|
|
@@ -524,6 +571,13 @@ export function recordCoreCancelResult(dataRoot, taskId, result) {
|
|
|
524
571
|
*/
|
|
525
572
|
export function recoverCoreTask(dataRoot, taskId) {
|
|
526
573
|
let current = readRecord(dataRoot, taskId);
|
|
574
|
+
// Flow 的异步完成结果同样先缓存,再记录 poll;中断时只恢复原结果,不重新查询/生成。
|
|
575
|
+
if (current.flowBinding && current.acceptance === 'accepted' && current.remoteTaskId
|
|
576
|
+
&& current.outcome === 'none' && current.cancelState === 'none'
|
|
577
|
+
&& readDeliveryCache(dataRoot, current, { allowAbsent: true })) {
|
|
578
|
+
recordCorePollResult(dataRoot, taskId, { kind: 'succeeded' }, { deliveryCache: true });
|
|
579
|
+
current = readRecord(dataRoot, taskId);
|
|
580
|
+
}
|
|
527
581
|
if (current.phase === 'submitting' && current.acceptance === 'none') {
|
|
528
582
|
const cache = readDeliveryCache(dataRoot, current, { allowAbsent: true });
|
|
529
583
|
if (cache) {
|