@mokuyoaxis/dsh-iris 0.1.3 → 0.2.0
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-compose-media/SKILL.md +23 -49
- package/.dsh/skills/iris-compose-media/references/task-v2-recovery.md +30 -0
- package/.dsh/skills/iris-compose-media/references/workflows.md +38 -0
- package/.dsh/skills/iris-verify-ui/SKILL.md +3 -25
- package/.dsh/skills/iris-verify-ui/agents/openai.yaml +4 -0
- package/.dsh/skills/iris-verify-ui/references/verdict-and-degradation.md +31 -0
- package/CHANGELOG.md +77 -2
- package/README.en.md +98 -19
- package/README.md +98 -19
- package/bin/dsh-iris.js +392 -13
- package/docs/AGENT_QUICKSTART.md +35 -0
- package/docs/ARCHITECTURE.md +82 -52
- package/docs/ARTIFACT_LIBRARY.md +24 -0
- package/docs/ARTIFACT_MANIFEST.md +60 -0
- package/docs/CORE_RUNTIME_CONTRACT.md +83 -0
- package/docs/DSH_CORE_MIGRATION.md +106 -0
- package/docs/DSH_RC2_ADAPTATION.md +36 -0
- package/docs/HEADLESS_CLI.md +217 -0
- package/docs/HOST_ADAPTER_CONTRACT.md +105 -0
- package/docs/HOST_DOCTOR.md +49 -0
- package/docs/MODEL_PORT_CONTRACT.md +300 -0
- package/docs/PROVIDER_ADAPTER_CONTRACT.md +138 -0
- package/docs/PROVIDER_HEALTH.md +39 -0
- package/docs/PROVIDER_RUNTIME_HARNESS.md +33 -0
- package/docs/PROVIDER_SUBMISSION_CONTRACT.md +2 -2
- package/docs/ROADMAP.md +42 -76
- package/docs/SECURITY.md +10 -5
- package/docs/TASK_SEMANTICS.md +3 -2
- package/docs/assets/diagrams/iris-architecture.png +0 -0
- package/docs/assets/diagrams/iris-architecture.svg +5 -5
- package/docs/assets/logo/README.md +55 -0
- package/docs/assets/logo/iris-bubble-concept.svg +67 -0
- package/docs/assets/logo/iris-bubble.svg +14 -0
- package/docs/assets/logo/iris-interim-flower.png +0 -0
- package/docs/assets/logo/iris-logo-mono-inverse.svg +78 -0
- package/docs/assets/logo/iris-logo-mono.svg +78 -0
- package/docs/assets/logo/iris-logo.svg +78 -0
- package/docs/assets/logo/iris-mark-mono-inverse.svg +42 -0
- package/docs/assets/logo/iris-mark-mono.svg +42 -0
- package/docs/assets/logo/iris-mark.svg +42 -0
- package/docs/releases/0.1.4.md +59 -0
- package/docs/releases/0.2.0.md +48 -0
- package/lib/actions.js +673 -525
- package/lib/adapters.js +58 -21
- package/lib/api.js +182 -16
- package/lib/artifacts.js +239 -0
- package/lib/bundled-skills.js +5 -5
- package/lib/client.js +1116 -86
- package/lib/command-service.js +420 -0
- package/lib/config.js +245 -49
- package/lib/core-artifact-store.js +598 -0
- package/lib/core-artifacts.js +4 -0
- package/lib/core-attention.js +103 -0
- package/lib/core-contract.js +105 -0
- package/lib/core-doctor.js +41 -0
- package/lib/core-lease-recovery.js +170 -0
- package/lib/core-runtime.js +232 -0
- package/lib/core-tasks.js +560 -0
- package/lib/core-user-projection.js +192 -0
- package/lib/doctor.js +260 -30
- package/lib/dsh-core-adapter.js +599 -0
- package/lib/dsh-host-adapter.js +240 -0
- package/lib/generation-input.js +95 -0
- package/lib/guard.js +3 -3
- package/lib/host-contract.js +131 -0
- package/lib/host-runtime.js +156 -0
- package/lib/index.js +255 -166
- package/lib/locate.js +1 -1
- package/lib/media.js +16 -6
- package/lib/model-invoker.js +218 -0
- package/lib/model-port-contract.js +333 -0
- package/lib/models.js +97 -11
- package/lib/prompt-optimizer.js +10 -20
- package/lib/provider-adapter.js +397 -0
- package/lib/provider-adapters.js +342 -0
- package/lib/provider-catalog.js +171 -0
- package/lib/provider-contract.js +72 -3
- package/lib/provider-health.js +59 -0
- package/lib/provider-protocol.js +51 -2
- package/lib/provider-task-runner.js +328 -0
- package/lib/render.js +43 -12
- package/lib/tasks.js +45 -9
- package/lib/vision.js +51 -56
- package/package.json +20 -14
- package/user_guide.md +67 -12
- package/docs/assets/diagrams/iris-architecture.drawio +0 -1
- package/docs/assets/diagrams/iris-task-lifecycle.drawio +0 -1
- package/docs/assets/diagrams/iris-workflow-compose.drawio +0 -1
- package/docs/releases/0.1.1.md +0 -51
- package/docs/releases/0.1.2.md +0 -26
- package/docs/releases/0.1.3.md +0 -30
package/lib/provider-protocol.js
CHANGED
|
@@ -2,18 +2,54 @@
|
|
|
2
2
|
/** 供应商媒体协议判断与 DashScope 凭据边界。 */
|
|
3
3
|
|
|
4
4
|
const DASH_SCOPE_HOST = /^dashscope(?:-[a-z0-9-]+)?\.aliyuncs\.com$/i;
|
|
5
|
+
const REGIONAL_HOST = /^cn-hongkong\.dashscope\.aliyuncs\.com$/i;
|
|
6
|
+
const WORKSPACE_HOST = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.(?:cn-beijing|ap-southeast-1|eu-central-1|ap-northeast-1|us-east-1)\.maas\.aliyuncs\.com$/i;
|
|
7
|
+
|
|
8
|
+
/** 可选媒体端点;空值沿用普通/视觉端点。 */
|
|
9
|
+
export function providerMediaBaseUrl(provider) {
|
|
10
|
+
return String(provider?.mediaBaseUrl || '').trim() || String(provider?.baseUrl || '').trim();
|
|
11
|
+
}
|
|
5
12
|
|
|
6
13
|
export function isDashScopeBaseUrl(value) {
|
|
7
14
|
try {
|
|
8
15
|
const url = new URL(String(value || '').trim());
|
|
9
|
-
return url.protocol === 'https:' && !url.username && !url.password
|
|
16
|
+
return url.protocol === 'https:' && !url.username && !url.password
|
|
17
|
+
&& (DASH_SCOPE_HOST.test(url.hostname) || REGIONAL_HOST.test(url.hostname) || WORKSPACE_HOST.test(url.hostname));
|
|
10
18
|
} catch (_) {
|
|
11
19
|
return false;
|
|
12
20
|
}
|
|
13
21
|
}
|
|
14
22
|
|
|
15
23
|
export function inferMediaProtocol(baseUrl) {
|
|
16
|
-
return
|
|
24
|
+
return detectMediaProtocol(baseUrl) || 'openai-images';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 未识别的端点不等于已验证的兼容协议。 */
|
|
28
|
+
export function detectMediaProtocol(baseUrl) {
|
|
29
|
+
if (isDashScopeBaseUrl(baseUrl)) return 'dashscope';
|
|
30
|
+
try {
|
|
31
|
+
const url = new URL(String(baseUrl || '').trim());
|
|
32
|
+
if (url.protocol === 'https:' && !url.username && !url.password
|
|
33
|
+
&& url.hostname === 'api.openai.com') return 'openai-images';
|
|
34
|
+
} catch (_) { /* 未知端点保留兼容回退,由调用方展示推断状态 */ }
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function selectMediaProtocol(provider) {
|
|
39
|
+
const explicit = typeof provider?.mediaProtocol === 'string' ? provider.mediaProtocol.trim() : '';
|
|
40
|
+
if (explicit && explicit !== 'auto'
|
|
41
|
+
&& (provider?.protocolInferred !== true || explicit !== 'openai-images')) {
|
|
42
|
+
return { mediaProtocol: explicit, protocolInferred: false };
|
|
43
|
+
}
|
|
44
|
+
const detected = detectMediaProtocol(providerMediaBaseUrl(provider));
|
|
45
|
+
return { mediaProtocol: detected || 'openai-images', protocolInferred: !detected };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function unsupportedProtocolError(protocol, kind = 'media') {
|
|
49
|
+
const safeName = /^[a-z][a-z0-9-]{0,63}$/.test(String(protocol)) ? protocol : '(invalid)';
|
|
50
|
+
const error = new TypeError('iris: 不支持的 ' + (kind === 'vision' ? 'Vision' : 'Provider') + ' 协议:' + safeName);
|
|
51
|
+
error.code = kind === 'vision' ? 'IRIS_VISION_PROTOCOL_UNSUPPORTED' : 'IRIS_PROVIDER_PROTOCOL_UNSUPPORTED';
|
|
52
|
+
return error;
|
|
17
53
|
}
|
|
18
54
|
|
|
19
55
|
/** 返回官方 DashScope API 根;不满足边界时在 fetch 前拒绝。 */
|
|
@@ -23,3 +59,16 @@ export function dashscopeApiBase(baseUrl) {
|
|
|
23
59
|
}
|
|
24
60
|
return new URL('/api/v1', String(baseUrl).trim()).toString().replace(/\/$/, '');
|
|
25
61
|
}
|
|
62
|
+
|
|
63
|
+
/** 无认证模式不发送保留的旧凭据;未声明模式保持 bearer 兼容。 */
|
|
64
|
+
export function providerApiKey(provider) {
|
|
65
|
+
return provider?.auth === 'none' ? '' : String(provider?.apiKey || '');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 配置管理与 headless 候选链共用可用性门,未声明 enabled 时默认启用。 */
|
|
69
|
+
export function isConfiguredProvider(provider) {
|
|
70
|
+
return Boolean(provider && provider.enabled !== false
|
|
71
|
+
&& typeof provider.id === 'string' && provider.id.trim()
|
|
72
|
+
&& providerMediaBaseUrl(provider)
|
|
73
|
+
&& (provider.auth === 'none' || (typeof provider.apiKey === 'string' && provider.apiKey.trim())));
|
|
74
|
+
}
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { createCoreArtifact } from './core-artifacts.js';
|
|
7
|
+
import {
|
|
8
|
+
activateCoreWatch,
|
|
9
|
+
beginCoreAttempt,
|
|
10
|
+
beginCoreDelivery,
|
|
11
|
+
completeCoreDelivery,
|
|
12
|
+
createCoreTask,
|
|
13
|
+
failCoreDelivery,
|
|
14
|
+
finalizeCoreNoAcceptance,
|
|
15
|
+
inspectCoreTask,
|
|
16
|
+
prepareCoreRedelivery,
|
|
17
|
+
recordCoreAttemptResult,
|
|
18
|
+
recordCoreCancelResult,
|
|
19
|
+
recordCoreDownloadCompleted,
|
|
20
|
+
recordCorePollResult,
|
|
21
|
+
recordCoreWatchError,
|
|
22
|
+
recoverCoreTask,
|
|
23
|
+
requestCoreCancel
|
|
24
|
+
} from './core-tasks.js';
|
|
25
|
+
import { ensurePrivateDir } from './private-storage.js';
|
|
26
|
+
import { parseModelRef } from './models.js';
|
|
27
|
+
import {
|
|
28
|
+
hasProviderOperation,
|
|
29
|
+
invokeProviderOperation
|
|
30
|
+
} from './provider-adapter.js';
|
|
31
|
+
import { providerErrorRecord, submitWithAcceptanceBoundary } from './provider-contract.js';
|
|
32
|
+
|
|
33
|
+
export const PROVIDER_TASK_RUNNER_VERSION = 0;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 按 capability 冻结的交付 Profile(E 阶段视频迁移):媒体类型白名单、Artifact
|
|
37
|
+
* kind 与默认 metadata。新增 capability 必须显式登记;未登记的 capability 直接
|
|
38
|
+
* 失败,不会按图片语义猜。
|
|
39
|
+
*/
|
|
40
|
+
export const DELIVERY_PROFILES = Object.freeze({
|
|
41
|
+
image: Object.freeze({ kind: 'generated-image', mediaTypes: Object.freeze(['image/png']), defaultMediaType: 'image/png' }),
|
|
42
|
+
video: Object.freeze({ kind: 'generated-video', mediaTypes: Object.freeze(['video/mp4']), defaultMediaType: 'video/mp4' }),
|
|
43
|
+
// TTS 同步完成型:submit 内 completed → 同一次调用直接交付(无 remote 长轮询)。
|
|
44
|
+
tts: Object.freeze({ kind: 'generated-audio', mediaTypes: Object.freeze(['audio/mpeg', 'audio/wav']), defaultMediaType: 'audio/mpeg' }),
|
|
45
|
+
// 转写上传型异步:受理 + remoteTaskId → 长轮询 → 文本物化为 UTF-8 Artifact。
|
|
46
|
+
transcribe: Object.freeze({ kind: 'transcript', mediaTypes: Object.freeze(['text/plain']), defaultMediaType: 'text/plain' })
|
|
47
|
+
});
|
|
48
|
+
const runtimeTaskGates = new WeakMap();
|
|
49
|
+
|
|
50
|
+
export class ProviderTaskRunnerError extends Error {
|
|
51
|
+
constructor(code, message) {
|
|
52
|
+
super(message);
|
|
53
|
+
this.name = 'ProviderTaskRunnerError';
|
|
54
|
+
this.code = code;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function checkRuntime(runtime) {
|
|
59
|
+
if (!runtime || typeof runtime.run !== 'function') {
|
|
60
|
+
throw new TypeError('Provider Task Runner 需要已创建的 Core Runtime');
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function checkAdapterForTask(task, adapter) {
|
|
65
|
+
if (!adapter || adapter.id !== task.providerId) {
|
|
66
|
+
throw new ProviderTaskRunnerError('IRIS_PROVIDER_TASK_IDENTITY_MISMATCH', 'Provider Adapter 与 Task 已持久化身份不一致');
|
|
67
|
+
}
|
|
68
|
+
return adapter;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function normalizedCandidates(capability, candidates) {
|
|
72
|
+
if (!Array.isArray(candidates) || !candidates.length) throw new TypeError('至少需要一个 Provider 候选');
|
|
73
|
+
return candidates.map((candidate, index) => {
|
|
74
|
+
const adapter = candidate?.adapter;
|
|
75
|
+
const model = String(candidate?.model || '').trim();
|
|
76
|
+
const parsed = parseModelRef(model);
|
|
77
|
+
const selectionReason = candidate?.selectionReason;
|
|
78
|
+
if (!adapter || !adapter.capabilities?.includes(capability) || !parsed
|
|
79
|
+
|| parsed.providerId !== adapter.id || !parsed.modelId
|
|
80
|
+
|| (candidate.providerBinding !== undefined && !/^sha256:[a-f0-9]{64}$/.test(candidate.providerBinding))
|
|
81
|
+
|| (selectionReason !== undefined && !['explicit', 'assignment', 'pool'].includes(selectionReason))) {
|
|
82
|
+
throw new TypeError('Provider 候选 ' + (index + 1) + ' 不支持 Task capability 或模型复合身份无效');
|
|
83
|
+
}
|
|
84
|
+
return { adapter, model, providerModel: parsed.modelId, providerBinding: candidate.providerBinding, selectionReason };
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function stagingFile(dataRoot) {
|
|
89
|
+
const directory = path.join(dataRoot, 'provider-staging', 'v0');
|
|
90
|
+
ensurePrivateDir(directory);
|
|
91
|
+
return path.join(directory, 'download-' + process.pid + '-' + crypto.randomBytes(8).toString('hex') + '.part');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function deliverArtifacts(dataRoot, taskId, adapter, remoteArtifacts, signal) {
|
|
95
|
+
beginCoreDelivery(dataRoot, taskId);
|
|
96
|
+
const artifactIds = [];
|
|
97
|
+
const stagedFiles = [];
|
|
98
|
+
try {
|
|
99
|
+
if (!Array.isArray(remoteArtifacts) || remoteArtifacts.length === 0) {
|
|
100
|
+
throw Object.assign(new Error('Provider Task Runner 没有收到可物化产物'), {
|
|
101
|
+
stage: 'download', category: 'protocol', acceptance: 'accepted'
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
const task = inspectCoreTask(dataRoot, taskId);
|
|
105
|
+
const profile = DELIVERY_PROFILES[task.capability];
|
|
106
|
+
if (!profile) {
|
|
107
|
+
throw Object.assign(new Error('Provider Task Runner 没有该 capability 的交付 Profile:' + task.capability), {
|
|
108
|
+
stage: 'download', category: 'protocol', acceptance: 'accepted'
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
for (const remote of remoteArtifacts) {
|
|
112
|
+
const mediaType = String(remote.mediaType || profile.defaultMediaType);
|
|
113
|
+
if (!profile.mediaTypes.includes(mediaType)) {
|
|
114
|
+
throw Object.assign(new Error('Provider Task Runner 交付媒体类型不在 Profile 白名单:' + mediaType), {
|
|
115
|
+
stage: 'download', category: 'protocol', acceptance: 'accepted'
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
const target = stagingFile(dataRoot);
|
|
119
|
+
stagedFiles.push({ target, mediaType });
|
|
120
|
+
const downloaded = await invokeProviderOperation(adapter, 'download', {
|
|
121
|
+
capability: task.capability,
|
|
122
|
+
artifact: remote,
|
|
123
|
+
targetPath: target,
|
|
124
|
+
signal
|
|
125
|
+
}, { signal, taskId });
|
|
126
|
+
const stat = fs.lstatSync(target);
|
|
127
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size !== downloaded.bytes) {
|
|
128
|
+
throw Object.assign(new Error('Provider 物化结果与文件证据不一致'), {
|
|
129
|
+
stage: 'download', category: 'local_io', acceptance: 'accepted'
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
recordCoreDownloadCompleted(dataRoot, taskId);
|
|
134
|
+
for (const staged of stagedFiles) {
|
|
135
|
+
const artifact = createCoreArtifact(dataRoot, {
|
|
136
|
+
kind: profile.kind,
|
|
137
|
+
mediaType: staged.mediaType,
|
|
138
|
+
bytes: fs.readFileSync(staged.target),
|
|
139
|
+
metadata: { taskId, capability: task.capability }
|
|
140
|
+
});
|
|
141
|
+
artifactIds.push(artifact.id);
|
|
142
|
+
}
|
|
143
|
+
return completeCoreDelivery(dataRoot, taskId, artifactIds);
|
|
144
|
+
} catch (error) {
|
|
145
|
+
return failCoreDelivery(dataRoot, taskId, error);
|
|
146
|
+
} finally {
|
|
147
|
+
for (const staged of stagedFiles) {
|
|
148
|
+
try { fs.rmSync(staged.target, { force: true }); } catch (_) { /* 后续显式清理可处理残留 */ }
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function observeWithinRoot(dataRoot, taskId, adapter, signal) {
|
|
154
|
+
const before = inspectCoreTask(dataRoot, taskId);
|
|
155
|
+
checkAdapterForTask(before, adapter);
|
|
156
|
+
activateCoreWatch(dataRoot, taskId);
|
|
157
|
+
let result;
|
|
158
|
+
try {
|
|
159
|
+
result = await invokeProviderOperation(adapter, 'poll', {
|
|
160
|
+
capability: before.capability,
|
|
161
|
+
remoteTaskId: before.remoteTaskId,
|
|
162
|
+
signal
|
|
163
|
+
}, { signal, taskId });
|
|
164
|
+
} catch (error) {
|
|
165
|
+
return recordCoreWatchError(dataRoot, taskId, error);
|
|
166
|
+
}
|
|
167
|
+
const recorded = recordCorePollResult(dataRoot, taskId, result);
|
|
168
|
+
if (result.kind !== 'succeeded') return recorded;
|
|
169
|
+
return deliverArtifacts(dataRoot, taskId, adapter, result.artifacts, signal);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* FakeProvider 与未来真实 Host 共用的生命周期调度候选。它不拥有 timer,也不会
|
|
174
|
+
* 自动循环轮询;每次 observe 只消费一个 poll 结果,使重启和故障边界可重复验证。
|
|
175
|
+
*/
|
|
176
|
+
export function createProviderTaskRunner(runtime) {
|
|
177
|
+
checkRuntime(runtime);
|
|
178
|
+
let activeTasks = runtimeTaskGates.get(runtime);
|
|
179
|
+
if (!activeTasks) {
|
|
180
|
+
activeTasks = new Set();
|
|
181
|
+
runtimeTaskGates.set(runtime, activeTasks);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function runExclusive(taskId, operation, callback) {
|
|
185
|
+
const key = String(taskId || '');
|
|
186
|
+
if (activeTasks.has(key)) {
|
|
187
|
+
throw new ProviderTaskRunnerError(
|
|
188
|
+
'IRIS_PROVIDER_TASK_BUSY',
|
|
189
|
+
'该 Task 已有生命周期操作正在执行'
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
activeTasks.add(key);
|
|
193
|
+
try {
|
|
194
|
+
return await runtime.run(operation, callback);
|
|
195
|
+
} finally {
|
|
196
|
+
activeTasks.delete(key);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function submit(input = {}) {
|
|
201
|
+
return runtime.run('execute', async ({ dataRoot, signal }) => {
|
|
202
|
+
const capability = String(input.capability || '').trim();
|
|
203
|
+
const candidates = normalizedCandidates(capability, input.candidates);
|
|
204
|
+
const task = createCoreTask(dataRoot, { capability, retriedFrom: input.retriedFrom });
|
|
205
|
+
const wrapped = candidates.map(({ adapter, model, providerModel }) => ({
|
|
206
|
+
id: adapter.id,
|
|
207
|
+
model,
|
|
208
|
+
adapter,
|
|
209
|
+
submit: (providerInput, context) => invokeProviderOperation(
|
|
210
|
+
adapter,
|
|
211
|
+
'submit',
|
|
212
|
+
{ capability, model: providerModel, input: providerInput, signal },
|
|
213
|
+
{ ...context, signal, taskId: task.id }
|
|
214
|
+
)
|
|
215
|
+
}));
|
|
216
|
+
const submitted = await submitWithAcceptanceBoundary(wrapped, input.providerInput ?? {}, {
|
|
217
|
+
beforeAttempt: async (attempt) => {
|
|
218
|
+
const candidate = candidates[attempt.ordinal - 1];
|
|
219
|
+
return beginCoreAttempt(dataRoot, task.id, {
|
|
220
|
+
...attempt,
|
|
221
|
+
...(candidate.providerBinding !== undefined ? { providerBinding: candidate.providerBinding } : {}),
|
|
222
|
+
...(candidate.selectionReason !== undefined ? { selectionReason: candidate.selectionReason } : {})
|
|
223
|
+
});
|
|
224
|
+
},
|
|
225
|
+
afterResult: async (attempt) => recordCoreAttemptResult(dataRoot, task.id, attempt)
|
|
226
|
+
});
|
|
227
|
+
if (submitted.localError) {
|
|
228
|
+
return Object.freeze({ taskId: task.id, task: inspectCoreTask(dataRoot, task.id), localError: submitted.localError });
|
|
229
|
+
}
|
|
230
|
+
if (!submitted.result) throw new Error('Provider 提交流程没有返回结果');
|
|
231
|
+
if (submitted.result.kind === 'not_accepted') {
|
|
232
|
+
finalizeCoreNoAcceptance(dataRoot, task.id, submitted.result.error);
|
|
233
|
+
} else if (submitted.result.kind === 'completed') {
|
|
234
|
+
const artifacts = submitted.result.artifacts;
|
|
235
|
+
return Object.freeze({
|
|
236
|
+
taskId: task.id,
|
|
237
|
+
task: await deliverArtifacts(dataRoot, task.id, submitted.candidate.adapter, artifacts, signal),
|
|
238
|
+
attempts: submitted.attempts
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
return Object.freeze({
|
|
242
|
+
taskId: task.id,
|
|
243
|
+
task: inspectCoreTask(dataRoot, task.id),
|
|
244
|
+
attempts: submitted.attempts
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function inspect(taskId) {
|
|
250
|
+
return runtime.run('inspect', ({ dataRoot }) => inspectCoreTask(dataRoot, taskId));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function observe(taskId, adapter, { resumeActive = false } = {}) {
|
|
254
|
+
return runExclusive(taskId, 'execute', ({ dataRoot, signal }) => {
|
|
255
|
+
if (resumeActive) {
|
|
256
|
+
const current = inspectCoreTask(dataRoot, taskId);
|
|
257
|
+
checkAdapterForTask(current, adapter);
|
|
258
|
+
if (current.watchState === 'active') recoverCoreTask(dataRoot, taskId);
|
|
259
|
+
}
|
|
260
|
+
return observeWithinRoot(dataRoot, taskId, adapter, signal);
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function redeliver(taskId, adapter) {
|
|
265
|
+
return runExclusive(taskId, 'execute', async ({ dataRoot, signal }) => {
|
|
266
|
+
const task = inspectCoreTask(dataRoot, taskId);
|
|
267
|
+
checkAdapterForTask(task, adapter);
|
|
268
|
+
prepareCoreRedelivery(dataRoot, taskId);
|
|
269
|
+
let result;
|
|
270
|
+
try {
|
|
271
|
+
result = await invokeProviderOperation(adapter, 'poll', {
|
|
272
|
+
capability: task.capability,
|
|
273
|
+
remoteTaskId: task.remoteTaskId,
|
|
274
|
+
signal
|
|
275
|
+
}, { signal, taskId, redelivery: true });
|
|
276
|
+
} catch (error) {
|
|
277
|
+
return failCoreDelivery(dataRoot, taskId, error);
|
|
278
|
+
}
|
|
279
|
+
if (result.kind !== 'succeeded') {
|
|
280
|
+
return failCoreDelivery(dataRoot, taskId, providerErrorRecord(
|
|
281
|
+
result.error || '重新查询没有得到可交付产物',
|
|
282
|
+
{ stage: 'download', category: 'protocol', acceptance: 'accepted' }
|
|
283
|
+
));
|
|
284
|
+
}
|
|
285
|
+
return deliverArtifacts(dataRoot, taskId, adapter, result.artifacts, signal);
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function cancel(taskId, adapter) {
|
|
290
|
+
return runExclusive(taskId, 'execute', async ({ dataRoot, signal }) => {
|
|
291
|
+
const task = inspectCoreTask(dataRoot, taskId);
|
|
292
|
+
checkAdapterForTask(task, adapter);
|
|
293
|
+
requestCoreCancel(dataRoot, taskId);
|
|
294
|
+
if (!hasProviderOperation(adapter, 'cancel')) {
|
|
295
|
+
return recordCoreCancelResult(dataRoot, taskId, { kind: 'not_supported' });
|
|
296
|
+
}
|
|
297
|
+
const result = await invokeProviderOperation(adapter, 'cancel', {
|
|
298
|
+
capability: task.capability,
|
|
299
|
+
remoteTaskId: task.remoteTaskId,
|
|
300
|
+
signal
|
|
301
|
+
}, { signal, taskId });
|
|
302
|
+
return recordCoreCancelResult(dataRoot, taskId, result);
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function recover(taskId, adapter) {
|
|
307
|
+
return runExclusive(taskId, 'recover', async ({ dataRoot, signal }) => {
|
|
308
|
+
const recovered = recoverCoreTask(dataRoot, taskId);
|
|
309
|
+
if (recovered.acceptance === 'accepted' && recovered.remoteTaskId
|
|
310
|
+
&& recovered.outcome === 'none'
|
|
311
|
+
&& ['accepted', 'running'].includes(recovered.phase)) {
|
|
312
|
+
checkAdapterForTask(recovered, adapter);
|
|
313
|
+
return observeWithinRoot(dataRoot, taskId, adapter, signal);
|
|
314
|
+
}
|
|
315
|
+
return recovered;
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return Object.freeze({
|
|
320
|
+
version: PROVIDER_TASK_RUNNER_VERSION,
|
|
321
|
+
submit,
|
|
322
|
+
inspect,
|
|
323
|
+
observe,
|
|
324
|
+
redeliver,
|
|
325
|
+
cancel,
|
|
326
|
+
recover
|
|
327
|
+
});
|
|
328
|
+
}
|
package/lib/render.js
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* Promise<{ png: Buffer }>
|
|
7
7
|
*
|
|
8
8
|
* BrowserHtmlRenderer:基于宿主 webServer 的 /iris/render/ 静态路由 +
|
|
9
|
-
*
|
|
10
|
-
* - 不 import dsh-builtin-browser
|
|
9
|
+
* DSH Host Adapter 注入的 browser 端口底层服务(可选访问,软耦合)。
|
|
10
|
+
* - 不 import dsh-builtin-browser 包,不在通用渲染层探测 DSH ctx
|
|
11
11
|
* - HTML 通过宿主路由以同源 HTTP 打开(避免 file:// 的 CORS 限制)
|
|
12
12
|
* - 每次调用独立临时渲染目录,用后即删
|
|
13
13
|
*/
|
|
@@ -25,14 +25,42 @@ export class RenderError extends Error {
|
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
function throwIfAborted(signal) {
|
|
29
|
+
if (!signal?.aborted) return;
|
|
30
|
+
const error = new RenderError('iris_html_screenshot: 已取消');
|
|
31
|
+
error.name = 'AbortError';
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function waitForRender(ms, signal) {
|
|
36
|
+
if (!(ms > 0)) return Promise.resolve();
|
|
37
|
+
throwIfAborted(signal);
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
const done = () => {
|
|
40
|
+
clearTimeout(timer);
|
|
41
|
+
signal?.removeEventListener?.('abort', canceled);
|
|
42
|
+
resolve();
|
|
43
|
+
};
|
|
44
|
+
const canceled = () => {
|
|
45
|
+
clearTimeout(timer);
|
|
46
|
+
signal?.removeEventListener?.('abort', canceled);
|
|
47
|
+
const error = new RenderError('iris_html_screenshot: 已取消');
|
|
48
|
+
error.name = 'AbortError';
|
|
49
|
+
reject(error);
|
|
50
|
+
};
|
|
51
|
+
const timer = setTimeout(done, ms);
|
|
52
|
+
signal?.addEventListener?.('abort', canceled, { once: true });
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
28
56
|
/** 渲染临时目录根:$DSH_HOME/iris/v1/render/ */
|
|
29
57
|
export function renderRoot() {
|
|
30
58
|
return path.join(irisHome(), 'render');
|
|
31
59
|
}
|
|
32
60
|
|
|
33
61
|
/**
|
|
34
|
-
* 基于
|
|
35
|
-
* @param {{browser?: object, waitMs?: number}} opts browser
|
|
62
|
+
* 基于 DSH Adapter 持有的原生 browser 服务完成 HTML → PNG。
|
|
63
|
+
* @param {{browser?: object, waitMs?: number}} opts browser 只在 DSH Adapter 内构造
|
|
36
64
|
*/
|
|
37
65
|
export class BrowserHtmlRenderer {
|
|
38
66
|
constructor({ browser, waitMs = 1200 }) {
|
|
@@ -40,13 +68,14 @@ export class BrowserHtmlRenderer {
|
|
|
40
68
|
this.waitMs = waitMs;
|
|
41
69
|
}
|
|
42
70
|
|
|
43
|
-
async render({ html, width, height, fullPage }) {
|
|
71
|
+
async render({ html, width, height, fullPage, signal }) {
|
|
44
72
|
const b = this.browser;
|
|
45
73
|
if (!b || typeof b.open !== 'function' || typeof b.openUrl !== 'function' || typeof b.screenshot !== 'function') {
|
|
46
|
-
throw new RenderError('iris:
|
|
74
|
+
throw new RenderError('iris: 浏览器服务不可用——需要启用兼容的 dsh-builtin-browser 插件');
|
|
47
75
|
}
|
|
48
76
|
const text = String(html || '').trim();
|
|
49
77
|
if (!text) throw new RenderError('iris_html_screenshot: html 不能为空');
|
|
78
|
+
throwIfAborted(signal);
|
|
50
79
|
|
|
51
80
|
// 每次调用独立子目录(随机名),写 index.html
|
|
52
81
|
const dir = path.join(renderRoot(), crypto.randomBytes(6).toString('hex'));
|
|
@@ -63,13 +92,15 @@ export class BrowserHtmlRenderer {
|
|
|
63
92
|
const pngPath = path.join(dir, 'render.png');
|
|
64
93
|
|
|
65
94
|
try {
|
|
66
|
-
const session = await b.open();
|
|
95
|
+
const session = await b.open({ signal });
|
|
96
|
+
throwIfAborted(signal);
|
|
67
97
|
try {
|
|
68
|
-
await b.openUrl(session, { url, newTab: true });
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
await b.screenshot(session, { fullPage: fullPage !== false, savePath: pngPath });
|
|
98
|
+
await b.openUrl(session, { url, newTab: true, signal });
|
|
99
|
+
throwIfAborted(signal);
|
|
100
|
+
await waitForRender(this.waitMs, signal); // 等页面加载(无加载完成事件可用的折中)
|
|
101
|
+
throwIfAborted(signal);
|
|
102
|
+
await b.screenshot(session, { fullPage: fullPage !== false, savePath: pngPath, signal });
|
|
103
|
+
throwIfAborted(signal);
|
|
73
104
|
} finally {
|
|
74
105
|
try { await b.close(session); } catch (_) { /* 幂等 */ }
|
|
75
106
|
}
|
package/lib/tasks.js
CHANGED
|
@@ -19,6 +19,7 @@ import { parseModelRef } from './models.js';
|
|
|
19
19
|
import { attentionDisposition, deriveLegacyStatus, deriveUserState, semanticViolations } from './task-semantics.js';
|
|
20
20
|
export { attentionDisposition } from './task-semantics.js';
|
|
21
21
|
import { providerErrorRecord, redactProviderMessage } from './provider-contract.js';
|
|
22
|
+
import { providerPollTaskView } from './provider-adapter.js';
|
|
22
23
|
|
|
23
24
|
const FILE = () => path.join(irisHome(), 'tasks.json');
|
|
24
25
|
/** 产物目录:$DSH_HOME/iris/v1/outputs */
|
|
@@ -454,15 +455,25 @@ export function all() {
|
|
|
454
455
|
return [...load().tasks].sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)));
|
|
455
456
|
}
|
|
456
457
|
|
|
458
|
+
/** 终态或已显式处理提醒的 Task v2 可删;活跃/未处理任务仍受保护。 */
|
|
459
|
+
export function taskRecordRemovable(task) {
|
|
460
|
+
if (!task) return false;
|
|
461
|
+
if (task.status !== 'running') return true;
|
|
462
|
+
return isV2Task(task)
|
|
463
|
+
&& ATTENTION_USER_STATES.has(deriveUserState(task))
|
|
464
|
+
&& attentionDisposition(task).status === 'acknowledged';
|
|
465
|
+
}
|
|
466
|
+
|
|
457
467
|
/**
|
|
458
|
-
*
|
|
468
|
+
* 删除单条任务元数据。事实终态可直接删除;状态未知只有在用户已读/已重试归档后才可删除。
|
|
459
469
|
* 只删记录,产物文件留在 outputs/(媒体链接因 token 随记录消失而自然失效)。
|
|
460
470
|
* @returns {{ok:boolean, reason?:string}}
|
|
461
471
|
*/
|
|
462
472
|
export function remove(id) {
|
|
463
473
|
const t = get(id);
|
|
464
474
|
if (!t) return { ok: false, reason: '任务不存在' };
|
|
465
|
-
if (t
|
|
475
|
+
if (!taskRecordRemovable(t)) return { ok: false, reason: '活跃或待确认任务请先取消、完成提醒处置,或标为已读后再删除' };
|
|
476
|
+
stopWatch(id);
|
|
466
477
|
const arr = load().tasks;
|
|
467
478
|
const i = arr.findIndex((x) => x.id === id);
|
|
468
479
|
if (i >= 0) arr.splice(i, 1);
|
|
@@ -480,8 +491,10 @@ export function prune(pred) {
|
|
|
480
491
|
const removed = [];
|
|
481
492
|
const kept = [];
|
|
482
493
|
for (const t of arr) {
|
|
483
|
-
if (t
|
|
484
|
-
|
|
494
|
+
if (taskRecordRemovable(t) && pred(t)) {
|
|
495
|
+
stopWatch(t.id);
|
|
496
|
+
removed.push(t.id);
|
|
497
|
+
} else kept.push(t);
|
|
485
498
|
}
|
|
486
499
|
if (removed.length) {
|
|
487
500
|
load().tasks = kept;
|
|
@@ -508,8 +521,8 @@ export function stopWatchAll() {
|
|
|
508
521
|
* 开始盯守一个异步任务(提交成功后调用)。
|
|
509
522
|
* @param {object} task 已 create 的任务记录(须含 remoteTaskId)
|
|
510
523
|
* @param {object} deps
|
|
511
|
-
* key(): string
|
|
512
|
-
* poll({key
|
|
524
|
+
* key?(): string —— 仅供旧 poll 兼容;v0 Adapter 自持凭据闭包
|
|
525
|
+
* poll({key?, remoteTaskId}): Provider poll v0(兼容旧 done/ok 形状)—— 协议适配器查询
|
|
513
526
|
* onSuccess(task, {urls}): string[] —— 结果转存,返回相对 outputs/ 的文件名列表
|
|
514
527
|
* intervalMs?: number —— 轮询间隔(默认 2500)
|
|
515
528
|
* maxWatchMs?: number —— 盯守上限(默认 20 分钟)
|
|
@@ -551,7 +564,11 @@ export function watch(task, deps) {
|
|
|
551
564
|
}
|
|
552
565
|
let r;
|
|
553
566
|
try {
|
|
554
|
-
|
|
567
|
+
const raw = await deps.poll({
|
|
568
|
+
key: typeof deps.key === 'function' ? deps.key() : undefined,
|
|
569
|
+
remoteTaskId: t.remoteTaskId
|
|
570
|
+
});
|
|
571
|
+
r = providerPollTaskView(raw);
|
|
555
572
|
} catch (err) {
|
|
556
573
|
const n = (errStreak.get(task.id) || 0) + 1;
|
|
557
574
|
errStreak.set(task.id, n);
|
|
@@ -571,10 +588,29 @@ export function watch(task, deps) {
|
|
|
571
588
|
}
|
|
572
589
|
if (!r.ok) {
|
|
573
590
|
const message = redactProviderMessage(r.message || '任务失败');
|
|
574
|
-
if (!v2)
|
|
591
|
+
if (!v2) {
|
|
592
|
+
return finishLegacy({ status: r.kind === 'canceled' ? 'canceled' : 'failed', error: message });
|
|
593
|
+
}
|
|
594
|
+
if (r.kind === 'unknown') {
|
|
595
|
+
return finishV2({
|
|
596
|
+
phase: 'terminal', watchState: 'exhausted', outcome: 'unknown',
|
|
597
|
+
lastError: r.error || providerErrorRecord(message, {
|
|
598
|
+
stage: 'poll', category: 'provider', acceptance: 'accepted'
|
|
599
|
+
}),
|
|
600
|
+
error: message, finishedAt: new Date().toISOString()
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
if (r.kind === 'canceled') {
|
|
604
|
+
return finishV2({
|
|
605
|
+
phase: 'terminal', watchState: 'idle', outcome: 'canceled', cancelState: 'remote_confirmed',
|
|
606
|
+
lastError: r.error, error: message, finishedAt: new Date().toISOString()
|
|
607
|
+
});
|
|
608
|
+
}
|
|
575
609
|
return finishV2({
|
|
576
610
|
phase: 'terminal', watchState: 'idle', outcome: 'failed',
|
|
577
|
-
lastError: providerErrorRecord(message, {
|
|
611
|
+
lastError: r.error || providerErrorRecord(message, {
|
|
612
|
+
stage: 'poll', category: 'provider', acceptance: 'accepted'
|
|
613
|
+
}),
|
|
578
614
|
error: message, finishedAt: new Date().toISOString()
|
|
579
615
|
});
|
|
580
616
|
}
|