@mokuyoaxis/dsh-iris 0.1.4 → 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 (64) hide show
  1. package/CHANGELOG.md +54 -1
  2. package/README.en.md +72 -15
  3. package/README.md +72 -15
  4. package/bin/dsh-iris.js +392 -13
  5. package/docs/AGENT_QUICKSTART.md +35 -0
  6. package/docs/ARCHITECTURE.md +80 -64
  7. package/docs/ARTIFACT_MANIFEST.md +60 -0
  8. package/docs/CORE_RUNTIME_CONTRACT.md +83 -0
  9. package/docs/DSH_CORE_MIGRATION.md +106 -0
  10. package/docs/DSH_RC2_ADAPTATION.md +36 -0
  11. package/docs/HEADLESS_CLI.md +217 -0
  12. package/docs/HOST_ADAPTER_CONTRACT.md +31 -12
  13. package/docs/HOST_DOCTOR.md +2 -0
  14. package/docs/MODEL_PORT_CONTRACT.md +300 -0
  15. package/docs/PROVIDER_ADAPTER_CONTRACT.md +51 -10
  16. package/docs/PROVIDER_RUNTIME_HARNESS.md +33 -0
  17. package/docs/PROVIDER_SUBMISSION_CONTRACT.md +1 -1
  18. package/docs/ROADMAP.md +42 -86
  19. package/docs/SECURITY.md +4 -3
  20. package/docs/assets/logo/README.md +55 -0
  21. package/docs/assets/logo/iris-bubble-concept.svg +67 -0
  22. package/docs/assets/logo/iris-bubble.svg +14 -0
  23. package/docs/assets/logo/iris-interim-flower.png +0 -0
  24. package/docs/assets/logo/iris-logo-mono-inverse.svg +78 -0
  25. package/docs/assets/logo/iris-logo-mono.svg +78 -0
  26. package/docs/assets/logo/iris-logo.svg +78 -0
  27. package/docs/assets/logo/iris-mark-mono-inverse.svg +42 -0
  28. package/docs/assets/logo/iris-mark-mono.svg +42 -0
  29. package/docs/assets/logo/iris-mark.svg +42 -0
  30. package/docs/releases/0.2.0.md +48 -0
  31. package/lib/actions.js +374 -362
  32. package/lib/adapters.js +52 -15
  33. package/lib/api.js +152 -5
  34. package/lib/client.js +763 -48
  35. package/lib/command-service.js +420 -0
  36. package/lib/config.js +56 -43
  37. package/lib/core-artifact-store.js +598 -0
  38. package/lib/core-artifacts.js +4 -0
  39. package/lib/core-attention.js +103 -0
  40. package/lib/core-contract.js +105 -0
  41. package/lib/core-doctor.js +41 -0
  42. package/lib/core-lease-recovery.js +170 -0
  43. package/lib/core-runtime.js +232 -0
  44. package/lib/core-tasks.js +560 -0
  45. package/lib/core-user-projection.js +192 -0
  46. package/lib/doctor.js +92 -33
  47. package/lib/dsh-core-adapter.js +599 -0
  48. package/lib/dsh-host-adapter.js +58 -7
  49. package/lib/generation-input.js +95 -0
  50. package/lib/guard.js +3 -3
  51. package/lib/index.js +159 -54
  52. package/lib/model-invoker.js +218 -0
  53. package/lib/model-port-contract.js +333 -0
  54. package/lib/models.js +97 -11
  55. package/lib/provider-adapter.js +49 -23
  56. package/lib/provider-adapters.js +84 -21
  57. package/lib/provider-catalog.js +171 -0
  58. package/lib/provider-contract.js +72 -3
  59. package/lib/provider-protocol.js +51 -2
  60. package/lib/provider-task-runner.js +328 -0
  61. package/lib/tasks.js +16 -4
  62. package/lib/vision.js +28 -7
  63. package/package.json +8 -8
  64. package/user_guide.md +27 -12
@@ -42,10 +42,32 @@ export const PROVIDER_ERROR_CATEGORIES = Object.freeze([
42
42
  ]);
43
43
 
44
44
  const ACCEPTANCE = new Set(['not_accepted', 'accepted', 'unknown']);
45
- const SECRET_QUERY = /([?&](?:access[_-]?key|api[_-]?key|authorization|credential|signature|token)=)[^&#\s]+/gi;
45
+ // 凭据查询参数:允许厂商前缀(x-amz- / x-oss- / OSSAccessKeyId 这类驼峰),但**限制尾部后缀**
46
+ // (审计 C-9 要求覆盖带前缀参数名)。
47
+ //
48
+ // 正则分两级,是为了同时满足两个方向:
49
+ // ① 强凭据词(key/credential/signature/secret):允许任意字母前缀 + 有限后缀(id|secret|ids),
50
+ // 保证 OSSAccessKeyId / X-Amz-Credential / gemini-signature 一律脱敏;
51
+ // ② 弱凭据词(token/auth/authorization):只允许【白名单厂商前缀】,且后缀限 id|secret|ids。
52
+ // 这是 C-9 首版修复的更正——首版给两级都开了无限 `[a-z0-9_-]*` 后缀,导致
53
+ // page_token / tokenizer / signature_mode 这类非凭据参数被误脱敏,诊断信息丢失。
54
+ const SECRET_STRONG = '(?:access[_-]?key|api[_-]?key|apikey|credential|signature|secret)';
55
+ const SECRET_WEAK = '(?:token|auth|authorization)';
56
+ const SECRET_SUFFIX = '(?:id|secret|ids)?';
57
+ const SECRET_VENDOR = '(?:(?:x|amz|oss|aliyun|aws|s3|gcp|azure|sts|sec|security|sig|sign|auth|oauth|bearer|jwt|session|csrf|access|api|private|secret|refresh|id|tmp|temp)[-_]?)*';
58
+ const SECRET_NAME = '(?:' + '(?:[a-z0-9]+[-_])*[a-z0-9]*' + SECRET_STRONG + SECRET_SUFFIX
59
+ + '|' + SECRET_VENDOR + SECRET_WEAK + SECRET_SUFFIX + ')';
60
+ const SECRET_QUERY = new RegExp('([?&]' + SECRET_NAME + '=)[^&#\\s]+', 'gi');
46
61
  const BEARER = /\bBearer\s+[^\s,;]+/gi;
47
62
  const API_KEY = /\bsk-[A-Za-z0-9_-]{8,}\b/g;
48
- const JSON_SECRET = /(["']?(?:api[_-]?key|authorization|access[_-]?token|secret)["']?\s*[:=]\s*["']?)[^"',\s}]+/gi;
63
+ // 结构化凭据字段:与 SECRET_QUERY 同构,同样限制弱凭据词的后缀。
64
+ // 左边界 `NAME_START` 不可省:否则 `page_token=x` 里的子串 `token=x` 也会被匹配,
65
+ // 非凭据参数将被误脱敏(本次收紧时发现,旧实现同样存在该缺陷)。
66
+ const NAME_START = '(?<![A-Za-z0-9_-])';
67
+ const JSON_SECRET = new RegExp('(\"?\'?' + NAME_START
68
+ + '(?:' + '(?:[A-Za-z0-9]+[-_])*' + SECRET_STRONG + SECRET_SUFFIX
69
+ + '|' + SECRET_VENDOR.replace(/\[-_\]\?/g, '[-_]?') + SECRET_WEAK + SECRET_SUFFIX + ')'
70
+ + '[\"\']?\\s*[:=]\\s*[\"\']?)[^\"\',\\s}]+', 'gi');
49
71
  const POSIX_PATH = /(^|[\s("'=])\/(?:[^/\s"'`,;)}\]]+\/)*[^/\s"'`,;)}\]]+/gm;
50
72
  const WINDOWS_PATH = /\b[A-Za-z]:\\(?:[^\\\s"'`,;)}\]]+\\)*[^\\\s"'`,;)}\]]+/g;
51
73
 
@@ -103,6 +125,43 @@ export class ProviderContractError extends Error {
103
125
  }
104
126
  }
105
127
 
128
+ /**
129
+ * Provider 交付描述只允许远端 URL 或短生命周期的内联 base64。内联正文故意设为
130
+ * non-enumerable:Runner 可以物化它,但日志、JSON 和 Task 持久化不会顺手复制图片。
131
+ */
132
+ export function normalizeProviderArtifacts(value) {
133
+ if (value === undefined) return Object.freeze([]);
134
+ if (!Array.isArray(value)) throw new TypeError('Provider artifacts 必须是数组');
135
+ return Object.freeze(value.map((item) => {
136
+ if (!item || typeof item !== 'object' || Array.isArray(item)) {
137
+ throw new TypeError('Provider artifact 必须是对象');
138
+ }
139
+ const mediaType = typeof item.mediaType === 'string' && item.mediaType.trim()
140
+ ? item.mediaType.trim()
141
+ : undefined;
142
+ if (item.kind === 'remote-url') {
143
+ const url = String(item.url || '').trim();
144
+ if (!url) throw new TypeError('Provider remote-url artifact 缺少 url');
145
+ return Object.freeze({ kind: 'remote-url', url, ...(mediaType ? { mediaType } : {}) });
146
+ }
147
+ if (item.kind === 'inline-base64') {
148
+ const data = String(item.data || '').trim();
149
+ if (!data || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(data)) {
150
+ throw new TypeError('Provider inline-base64 artifact 数据无效');
151
+ }
152
+ const padding = data.endsWith('==') ? 2 : (data.endsWith('=') ? 1 : 0);
153
+ const artifact = {
154
+ kind: 'inline-base64',
155
+ byteLength: (data.length / 4) * 3 - padding,
156
+ ...(mediaType ? { mediaType } : {})
157
+ };
158
+ Object.defineProperty(artifact, 'data', { value: data, enumerable: false });
159
+ return Object.freeze(artifact);
160
+ }
161
+ throw new TypeError('Provider artifact kind 仅支持 remote-url/inline-base64');
162
+ }));
163
+ }
164
+
106
165
  export function normalizeSubmissionResult(value) {
107
166
  if (!value || typeof value !== 'object' || Array.isArray(value)) {
108
167
  throw new TypeError('Provider submit 必须返回结构化结果');
@@ -111,7 +170,17 @@ export function normalizeSubmissionResult(value) {
111
170
  throw new TypeError('未知 Provider submit kind: ' + String(value.kind));
112
171
  }
113
172
  if (value.kind === 'completed') {
114
- return { kind: 'completed', acceptance: 'accepted', value: value.value };
173
+ const result = {
174
+ kind: 'completed', acceptance: 'accepted',
175
+ ...(value.artifacts !== undefined ? { artifacts: normalizeProviderArtifacts(value.artifacts) } : {})
176
+ };
177
+ if (value.value !== undefined) {
178
+ Object.defineProperty(result, 'value', {
179
+ value: value.value,
180
+ enumerable: value.artifacts === undefined
181
+ });
182
+ }
183
+ return result;
115
184
  }
116
185
  if (value.kind === 'accepted') {
117
186
  const remoteTaskId = String(value.remoteTaskId || '').trim();
@@ -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/tasks.js CHANGED
@@ -455,15 +455,25 @@ export function all() {
455
455
  return [...load().tasks].sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)));
456
456
  }
457
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
+
458
467
  /**
459
- * 删除单条任务元数据(仅终态;运行中须先 cancel)。
468
+ * 删除单条任务元数据。事实终态可直接删除;状态未知只有在用户已读/已重试归档后才可删除。
460
469
  * 只删记录,产物文件留在 outputs/(媒体链接因 token 随记录消失而自然失效)。
461
470
  * @returns {{ok:boolean, reason?:string}}
462
471
  */
463
472
  export function remove(id) {
464
473
  const t = get(id);
465
474
  if (!t) return { ok: false, reason: '任务不存在' };
466
- if (t.status === 'running') return { ok: false, reason: '运行中任务请先取消再删除' };
475
+ if (!taskRecordRemovable(t)) return { ok: false, reason: '活跃或待确认任务请先取消、完成提醒处置,或标为已读后再删除' };
476
+ stopWatch(id);
467
477
  const arr = load().tasks;
468
478
  const i = arr.findIndex((x) => x.id === id);
469
479
  if (i >= 0) arr.splice(i, 1);
@@ -481,8 +491,10 @@ export function prune(pred) {
481
491
  const removed = [];
482
492
  const kept = [];
483
493
  for (const t of arr) {
484
- if (t.status !== 'running' && pred(t)) removed.push(t.id);
485
- else kept.push(t);
494
+ if (taskRecordRemovable(t) && pred(t)) {
495
+ stopWatch(t.id);
496
+ removed.push(t.id);
497
+ } else kept.push(t);
486
498
  }
487
499
  if (removed.length) {
488
500
  load().tasks = kept;
package/lib/vision.js CHANGED
@@ -1,4 +1,5 @@
1
1
  'use strict';
2
+ import { providerApiKey, unsupportedProtocolError } from './provider-protocol.js';
2
3
  /**
3
4
  * Iris 视觉后端抽象(阶段 1)。
4
5
  *
@@ -12,12 +13,14 @@
12
13
  */
13
14
  import * as adapters from './adapters.js';
14
15
  import { hasHostPort } from './host-contract.js';
16
+ import { resolveModelRef, parseModelRef } from './models.js';
15
17
 
16
18
  /** 后端接口约定:id / kind / model 只读,analyze(request) → Promise<string> */
17
19
 
18
20
  export class SelfStackVisionBackend {
19
- constructor({ provider }) {
21
+ constructor({ provider, model }) {
20
22
  this.provider = provider;
23
+ this.requestedModel = model;
21
24
  }
22
25
  get id() {
23
26
  return this.provider.id;
@@ -26,13 +29,18 @@ export class SelfStackVisionBackend {
26
29
  return 'selfstack';
27
30
  }
28
31
  get model() {
29
- return this.provider.visionModel || 'qwen-vl-plus';
32
+ const ref = resolveModelRef(this.provider, 'vision', this.requestedModel);
33
+ return parseModelRef(ref)?.modelId || '';
30
34
  }
31
35
  async analyze({ question, imageDataUrl, signal }) {
36
+ const model = this.model;
37
+ if (!model) throw Object.assign(new Error('iris: 未配置视觉模型,请发现或手工添加并标注 vision 能力'), {
38
+ code: 'IRIS_PROVIDER_MODEL_UNAVAILABLE'
39
+ });
32
40
  return adapters.visionStream({
33
- key: this.provider.apiKey,
41
+ key: providerApiKey(this.provider),
34
42
  baseUrl: this.provider.baseUrl,
35
- model: this.model,
43
+ model,
36
44
  prompt: question,
37
45
  imageDataUrl,
38
46
  signal
@@ -48,13 +56,24 @@ export class HostVisionBackend {
48
56
  async analyze(request) { return this.port.analyze(request); }
49
57
  }
50
58
 
59
+ const VISION_FACTORIES = new Map([
60
+ ['openai', (provider, model) => new SelfStackVisionBackend({ provider, model })]
61
+ ]);
62
+
51
63
  /** Host Adapter 入口;Command 不读取 DSH ctx。 */
52
64
  export function buildVisionBackendsFromHost(host, { providers, model }) {
53
65
  const backends = [];
54
66
  for (const p of providers || []) {
55
- if (p && p.type === 'openai') {
56
- const provider = model ? { ...p, visionModel: model } : p;
57
- backends.push(new SelfStackVisionBackend({ provider }));
67
+ if (p) {
68
+ const type = typeof p.type === 'string' && p.type.trim() ? p.type.trim() : 'openai';
69
+ const factory = VISION_FACTORIES.get(type);
70
+ if (!factory) {
71
+ // 保留不可用证据,让宿主视觉端口仍能按既有顺序降级。
72
+ backends.push({
73
+ id: p.id, kind: 'selfstack', model: String(model || p.visionModel || ''),
74
+ async analyze() { throw unsupportedProtocolError(type, 'vision'); }
75
+ });
76
+ } else backends.push(factory(p, model));
58
77
  }
59
78
  }
60
79
  if (hasHostPort(host, 'visionModel') && typeof host.ports.visionModel.analyze === 'function') {
@@ -83,6 +102,7 @@ export async function askWithBackends(backends, request) {
83
102
  backendId: b.id,
84
103
  kind: b.kind,
85
104
  model: b.model,
105
+ code: err && err.code,
86
106
  category: err && err.category,
87
107
  status: err && err.status,
88
108
  message: String((err && err.message) || err)
@@ -90,6 +110,7 @@ export async function askWithBackends(backends, request) {
90
110
  }
91
111
  }
92
112
  const err = new Error('iris: 视觉模型不可用——自持栈与全局视觉模型都失败了');
113
+ if (errors.length && errors[0].code && errors.every((error) => error.code === errors[0].code)) err.code = errors[0].code;
93
114
  err.errors = errors;
94
115
  throw err;
95
116
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mokuyoaxis/dsh-iris",
3
- "version": "0.1.4",
3
+ "version": "0.2.0",
4
4
  "description": "Multi-provider image, video, and speech generation, transcription, visual understanding, and prompt optimization with an integrated Iris workbench for DeepSeek Harness.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -33,7 +33,7 @@
33
33
  ],
34
34
  "dsh": {
35
35
  "engines": {
36
- "dsh": ">=0.1.2-rc.1 <0.1.3-0 || 0.1.5-rc.1"
36
+ "dsh": "0.2.0-rc.2"
37
37
  },
38
38
  "bundle": {
39
39
  "patch": "./cordis.patch.yml"
@@ -63,7 +63,7 @@
63
63
  ],
64
64
  "license": "MIT",
65
65
  "engines": {
66
- "node": ">=20.10.0"
66
+ "node": ">=22.0.0"
67
67
  },
68
68
  "scripts": {
69
69
  "test": "node scripts/test.mjs",
@@ -71,11 +71,11 @@
71
71
  "prepublishOnly": "npm test"
72
72
  },
73
73
  "peerDependencies": {
74
- "@deepseek-ai/dsh-client-locale": "^0.1.2-rc.1",
75
- "@deepseek-ai/dsh-client-ui-conversation": "^0.1.2-rc.1",
76
- "@deepseek-ai/dsh-client-ui-renderer": "^0.1.2-rc.1",
77
- "@deepseek-ai/dsh-client-ui-session": "^0.1.2-rc.1",
78
- "@deepseek-ai/dsh-client-ui-settings": "^0.1.2-rc.1"
74
+ "@deepseek-ai/dsh-client-locale": "0.2.0-rc.2",
75
+ "@deepseek-ai/dsh-client-ui-conversation": "0.2.0-rc.2",
76
+ "@deepseek-ai/dsh-client-ui-renderer": "0.2.0-rc.2",
77
+ "@deepseek-ai/dsh-client-ui-session": "0.2.0-rc.2",
78
+ "@deepseek-ai/dsh-client-ui-settings": "0.2.0-rc.2"
79
79
  },
80
80
  "peerDependenciesMeta": {
81
81
  "@deepseek-ai/dsh-client-locale": {