@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.
Files changed (91) hide show
  1. package/.dsh/skills/iris-compose-media/SKILL.md +23 -49
  2. package/.dsh/skills/iris-compose-media/references/task-v2-recovery.md +30 -0
  3. package/.dsh/skills/iris-compose-media/references/workflows.md +38 -0
  4. package/.dsh/skills/iris-verify-ui/SKILL.md +3 -25
  5. package/.dsh/skills/iris-verify-ui/agents/openai.yaml +4 -0
  6. package/.dsh/skills/iris-verify-ui/references/verdict-and-degradation.md +31 -0
  7. package/CHANGELOG.md +77 -2
  8. package/README.en.md +98 -19
  9. package/README.md +98 -19
  10. package/bin/dsh-iris.js +392 -13
  11. package/docs/AGENT_QUICKSTART.md +35 -0
  12. package/docs/ARCHITECTURE.md +82 -52
  13. package/docs/ARTIFACT_LIBRARY.md +24 -0
  14. package/docs/ARTIFACT_MANIFEST.md +60 -0
  15. package/docs/CORE_RUNTIME_CONTRACT.md +83 -0
  16. package/docs/DSH_CORE_MIGRATION.md +106 -0
  17. package/docs/DSH_RC2_ADAPTATION.md +36 -0
  18. package/docs/HEADLESS_CLI.md +217 -0
  19. package/docs/HOST_ADAPTER_CONTRACT.md +105 -0
  20. package/docs/HOST_DOCTOR.md +49 -0
  21. package/docs/MODEL_PORT_CONTRACT.md +300 -0
  22. package/docs/PROVIDER_ADAPTER_CONTRACT.md +138 -0
  23. package/docs/PROVIDER_HEALTH.md +39 -0
  24. package/docs/PROVIDER_RUNTIME_HARNESS.md +33 -0
  25. package/docs/PROVIDER_SUBMISSION_CONTRACT.md +2 -2
  26. package/docs/ROADMAP.md +42 -76
  27. package/docs/SECURITY.md +10 -5
  28. package/docs/TASK_SEMANTICS.md +3 -2
  29. package/docs/assets/diagrams/iris-architecture.png +0 -0
  30. package/docs/assets/diagrams/iris-architecture.svg +5 -5
  31. package/docs/assets/logo/README.md +55 -0
  32. package/docs/assets/logo/iris-bubble-concept.svg +67 -0
  33. package/docs/assets/logo/iris-bubble.svg +14 -0
  34. package/docs/assets/logo/iris-interim-flower.png +0 -0
  35. package/docs/assets/logo/iris-logo-mono-inverse.svg +78 -0
  36. package/docs/assets/logo/iris-logo-mono.svg +78 -0
  37. package/docs/assets/logo/iris-logo.svg +78 -0
  38. package/docs/assets/logo/iris-mark-mono-inverse.svg +42 -0
  39. package/docs/assets/logo/iris-mark-mono.svg +42 -0
  40. package/docs/assets/logo/iris-mark.svg +42 -0
  41. package/docs/releases/0.1.4.md +59 -0
  42. package/docs/releases/0.2.0.md +48 -0
  43. package/lib/actions.js +673 -525
  44. package/lib/adapters.js +58 -21
  45. package/lib/api.js +182 -16
  46. package/lib/artifacts.js +239 -0
  47. package/lib/bundled-skills.js +5 -5
  48. package/lib/client.js +1116 -86
  49. package/lib/command-service.js +420 -0
  50. package/lib/config.js +245 -49
  51. package/lib/core-artifact-store.js +598 -0
  52. package/lib/core-artifacts.js +4 -0
  53. package/lib/core-attention.js +103 -0
  54. package/lib/core-contract.js +105 -0
  55. package/lib/core-doctor.js +41 -0
  56. package/lib/core-lease-recovery.js +170 -0
  57. package/lib/core-runtime.js +232 -0
  58. package/lib/core-tasks.js +560 -0
  59. package/lib/core-user-projection.js +192 -0
  60. package/lib/doctor.js +260 -30
  61. package/lib/dsh-core-adapter.js +599 -0
  62. package/lib/dsh-host-adapter.js +240 -0
  63. package/lib/generation-input.js +95 -0
  64. package/lib/guard.js +3 -3
  65. package/lib/host-contract.js +131 -0
  66. package/lib/host-runtime.js +156 -0
  67. package/lib/index.js +255 -166
  68. package/lib/locate.js +1 -1
  69. package/lib/media.js +16 -6
  70. package/lib/model-invoker.js +218 -0
  71. package/lib/model-port-contract.js +333 -0
  72. package/lib/models.js +97 -11
  73. package/lib/prompt-optimizer.js +10 -20
  74. package/lib/provider-adapter.js +397 -0
  75. package/lib/provider-adapters.js +342 -0
  76. package/lib/provider-catalog.js +171 -0
  77. package/lib/provider-contract.js +72 -3
  78. package/lib/provider-health.js +59 -0
  79. package/lib/provider-protocol.js +51 -2
  80. package/lib/provider-task-runner.js +328 -0
  81. package/lib/render.js +43 -12
  82. package/lib/tasks.js +45 -9
  83. package/lib/vision.js +51 -56
  84. package/package.json +20 -14
  85. package/user_guide.md +67 -12
  86. package/docs/assets/diagrams/iris-architecture.drawio +0 -1
  87. package/docs/assets/diagrams/iris-task-lifecycle.drawio +0 -1
  88. package/docs/assets/diagrams/iris-workflow-compose.drawio +0 -1
  89. package/docs/releases/0.1.1.md +0 -51
  90. package/docs/releases/0.1.2.md +0 -26
  91. package/docs/releases/0.1.3.md +0 -30
@@ -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 && DASH_SCOPE_HOST.test(url.hostname);
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 isDashScopeBaseUrl(baseUrl) ? 'dashscope' : 'openai-images';
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
- * dsh-builtin-browser 的 ctx.browser 服务(可选访问,软耦合)。
10
- * - 不 import dsh-builtin-browser 包,只在运行时探测 ctx.get('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
- * 基于 ctx.browser(dsh-builtin-browser)的 HTML → PNG 渲染器。
35
- * @param {{browser?: object, waitMs?: number}} opts browser 为 ctx.get('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: 浏览器服务不可用(ctx.get("browser") 为空)——需要启用 dsh-builtin-browser 插件');
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
- if (this.waitMs > 0) {
70
- await new Promise((r) => setTimeout(r, this.waitMs)); // 等页面加载(无加载完成事件可用的折中)
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
- * 删除单条任务元数据(仅终态;运行中须先 cancel)。
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.status === 'running') return { ok: false, reason: '运行中任务请先取消再删除' };
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.status !== 'running' && pred(t)) removed.push(t.id);
484
- else kept.push(t);
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, remoteTaskId}): {done,ok,urls,message} —— 协议适配器的查询函数
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
- r = await deps.poll({ key: deps.key(), remoteTaskId: t.remoteTaskId });
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) return finishLegacy({ status: 'failed', error: message });
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, { stage: 'poll', category: 'provider', acceptance: 'accepted' }),
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
  }