@mokuyoaxis/dsh-iris 0.1.2 → 0.1.4

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 (54) 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 +57 -2
  8. package/README.en.md +43 -7
  9. package/README.md +43 -7
  10. package/bin/dsh-iris.js +23 -0
  11. package/docs/ARCHITECTURE.md +95 -0
  12. package/docs/ARTIFACT_LIBRARY.md +24 -0
  13. package/docs/FAULT_INJECTION.md +26 -0
  14. package/docs/HOST_ADAPTER_CONTRACT.md +86 -0
  15. package/docs/HOST_DOCTOR.md +47 -0
  16. package/docs/PROVIDER_ADAPTER_CONTRACT.md +97 -0
  17. package/docs/PROVIDER_HEALTH.md +39 -0
  18. package/docs/PROVIDER_SUBMISSION_CONTRACT.md +60 -0
  19. package/docs/ROADMAP.md +21 -11
  20. package/docs/SECURITY.md +57 -0
  21. package/docs/TASK_SEMANTICS.md +148 -0
  22. package/docs/assets/diagrams/iris-architecture.png +0 -0
  23. package/docs/assets/diagrams/iris-architecture.svg +5 -5
  24. package/docs/releases/0.1.4.md +59 -0
  25. package/lib/actions.js +860 -290
  26. package/lib/adapters.js +7 -7
  27. package/lib/api.js +110 -18
  28. package/lib/artifacts.js +239 -0
  29. package/lib/bundled-skills.js +5 -5
  30. package/lib/client.js +478 -68
  31. package/lib/config.js +192 -9
  32. package/lib/doctor.js +414 -0
  33. package/lib/dsh-host-adapter.js +189 -0
  34. package/lib/host-contract.js +131 -0
  35. package/lib/host-runtime.js +156 -0
  36. package/lib/index.js +142 -157
  37. package/lib/locate.js +1 -1
  38. package/lib/media.js +16 -6
  39. package/lib/prompt-optimizer.js +10 -20
  40. package/lib/provider-adapter.js +371 -0
  41. package/lib/provider-adapters.js +279 -0
  42. package/lib/provider-contract.js +234 -0
  43. package/lib/provider-health.js +59 -0
  44. package/lib/render.js +43 -12
  45. package/lib/task-semantics.js +209 -0
  46. package/lib/tasks.js +500 -32
  47. package/lib/vision.js +23 -49
  48. package/package.json +19 -8
  49. package/user_guide.md +62 -2
  50. package/docs/assets/diagrams/iris-architecture.drawio +0 -1
  51. package/docs/assets/diagrams/iris-task-lifecycle.drawio +0 -1
  52. package/docs/assets/diagrams/iris-workflow-compose.drawio +0 -1
  53. package/docs/releases/0.1.1.md +0 -51
  54. package/docs/releases/0.1.2.md +0 -26
@@ -0,0 +1,279 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 已支持协议到 Provider Adapter v0 的映射。
5
+ *
6
+ * 凭据和 baseUrl 只被操作闭包捕获,不进入 Adapter 描述或能力快照。
7
+ * transport 默认为现有低层 HTTP 实现;测试可注入零网络 fixture。
8
+ */
9
+ import * as defaultTransport from './adapters.js';
10
+ import { ProviderContractError, providerErrorRecord } from './provider-contract.js';
11
+ import { defineProviderAdapter } from './provider-adapter.js';
12
+ import { inferMediaProtocol } from './provider-protocol.js';
13
+
14
+ const AMBIGUOUS_4XX = new Set([408, 409, 425, 499]);
15
+
16
+ function providerIdentity(provider) {
17
+ const id = String(provider?.id || '').trim();
18
+ const key = String(provider?.apiKey || '');
19
+ const baseUrl = String(provider?.baseUrl || '').trim();
20
+ if (!id) throw new TypeError('Provider 配置缺少 id');
21
+ if (!baseUrl) throw new TypeError('Provider 配置缺少 baseUrl');
22
+ return { id, key, baseUrl };
23
+ }
24
+
25
+ function mappedCategory(error, status) {
26
+ const raw = String(error?.category || '').toLowerCase();
27
+ if (error?.name === 'AbortError' || error?.code === 'ABORT_ERR') return 'aborted';
28
+ if (error?.name === 'TimeoutError' || /timeout|timed out|超时/i.test(String(error?.message || ''))) return 'timeout';
29
+ if (status === 401 || status === 403 || raw === 'auth') return 'authentication';
30
+ if (status === 429 && raw === 'quota') return 'quota';
31
+ if (status === 429 || raw === 'rate_limit') return 'rate_limit';
32
+ if (raw === 'invalid_parameter') return 'invalid_request';
33
+ if (raw === 'network' || error instanceof TypeError) return 'network';
34
+ if (raw === 'server' || status >= 500) return 'provider';
35
+ if (!status && /未返回|响应|协议|结构|json/i.test(String(error?.message || ''))) return 'protocol';
36
+ return 'provider';
37
+ }
38
+
39
+ /** 当前两个协议共享的错误映射;不保留 cause、rootCause、正文、URL 或凭据。 */
40
+ export function mapProviderAdapterError(error, context = {}) {
41
+ const statusValue = Number(error?.httpStatus ?? error?.status);
42
+ const httpStatus = Number.isInteger(statusValue) && statusValue >= 100 && statusValue <= 599
43
+ ? statusValue
44
+ : undefined;
45
+ const stage = String(context.stage || error?.stage || 'submit');
46
+ const category = mappedCategory(error, httpStatus);
47
+ let acceptance = context.acceptance || error?.acceptance;
48
+ if (stage === 'validate' || stage === 'prepare' || stage === 'upload') {
49
+ acceptance = 'not_accepted';
50
+ } else if (stage === 'submit') {
51
+ acceptance = httpStatus && httpStatus >= 400 && httpStatus < 500 && !AMBIGUOUS_4XX.has(httpStatus)
52
+ ? 'not_accepted'
53
+ : (error?.acceptance === 'not_accepted' ? 'not_accepted' : 'unknown');
54
+ } else if (!acceptance) {
55
+ acceptance = 'accepted';
56
+ }
57
+ const retryable = Boolean(
58
+ context.retryable ?? error?.retryable
59
+ ?? (httpStatus === 429 || (httpStatus && httpStatus >= 500)
60
+ || category === 'network' || category === 'timeout')
61
+ );
62
+ return providerErrorRecord(error, {
63
+ stage,
64
+ category,
65
+ acceptance,
66
+ retryable,
67
+ ...(httpStatus ? { httpStatus } : {})
68
+ });
69
+ }
70
+
71
+ function unsupportedCapability(capability, protocol) {
72
+ return new ProviderContractError(
73
+ '协议 ' + protocol + ' 不支持任务能力 ' + String(capability || ''),
74
+ { stage: 'validate', category: 'invalid_request', acceptance: 'not_accepted' }
75
+ );
76
+ }
77
+
78
+ function pollFailureKind(status) {
79
+ const value = String(status || '').toUpperCase();
80
+ if (value === 'CANCELED' || value === 'CANCELLED') return 'canceled';
81
+ if (value === 'UNKNOWN') return 'unknown';
82
+ return 'failed';
83
+ }
84
+
85
+ function operationsForDashScope(provider, transport) {
86
+ const { key, baseUrl } = providerIdentity(provider);
87
+ return {
88
+ async discover({ signal, timeoutMs } = {}) {
89
+ return { models: await transport.listModels({ key, baseUrl, signal, timeoutMs }) };
90
+ },
91
+
92
+ async submit(request = {}) {
93
+ const input = request.input || {};
94
+ const common = {
95
+ key, baseUrl, model: request.model, signal: request.signal,
96
+ ...(request.timeoutMs ? { timeoutMs: request.timeoutMs } : {})
97
+ };
98
+ if (request.capability === 'image') {
99
+ if (transport.dashscopeImageMode(request.model) === 'legacy-async') {
100
+ const remoteTaskId = await transport.submitImage({
101
+ ...common, prompt: input.prompt, size: input.size, n: input.n
102
+ });
103
+ return { kind: 'accepted', remoteTaskId };
104
+ }
105
+ const urls = await transport.generateImageMultimodal({
106
+ ...common, prompt: input.prompt, size: input.size, n: input.n
107
+ });
108
+ return { kind: 'completed', value: { kind: 'urls', items: urls } };
109
+ }
110
+ if (request.capability === 'video') {
111
+ const remoteTaskId = await transport.submitVideo({
112
+ ...common,
113
+ prompt: input.prompt,
114
+ imgDataUrl: input.imgDataUrl,
115
+ size: input.size,
116
+ duration: input.duration,
117
+ audioUrl: input.audioUrl,
118
+ resolution: input.resolution
119
+ });
120
+ return { kind: 'accepted', remoteTaskId };
121
+ }
122
+ if (request.capability === 'transcribe') {
123
+ const remoteTaskId = await transport.submitTranscription({
124
+ ...common, audioUrl: input.audioUrl
125
+ });
126
+ return { kind: 'accepted', remoteTaskId };
127
+ }
128
+ if (request.capability === 'tts') {
129
+ const value = await transport.synthesizeTts({
130
+ ...common, text: input.text, voice: input.voice
131
+ });
132
+ return { kind: 'completed', value };
133
+ }
134
+ throw unsupportedCapability(request.capability, 'dashscope');
135
+ },
136
+
137
+ async poll(request = {}) {
138
+ const common = {
139
+ key, baseUrl, remoteTaskId: request.remoteTaskId, signal: request.signal,
140
+ ...(request.timeoutMs ? { timeoutMs: request.timeoutMs } : {})
141
+ };
142
+ if (request.capability === 'transcribe') {
143
+ const result = await transport.pollTranscriptionTask(common);
144
+ if (!result.done) return { kind: 'pending', progress: result.status || 'running' };
145
+ if (!result.ok) {
146
+ return {
147
+ kind: pollFailureKind(result.status),
148
+ error: mapProviderAdapterError(result.message || '转写任务失败', {
149
+ stage: 'poll', acceptance: 'accepted'
150
+ })
151
+ };
152
+ }
153
+ return { kind: 'succeeded', value: { kind: 'text', text: String(result.text || '') } };
154
+ }
155
+ if (request.capability === 'image' || request.capability === 'video') {
156
+ const result = await transport.pollTask(common);
157
+ if (!result.done) return { kind: 'pending', progress: result.status || 'running' };
158
+ if (!result.ok) {
159
+ return {
160
+ kind: pollFailureKind(result.status),
161
+ error: mapProviderAdapterError(result.message || '生成任务失败', {
162
+ stage: 'poll', acceptance: 'accepted'
163
+ })
164
+ };
165
+ }
166
+ return {
167
+ kind: 'succeeded',
168
+ artifacts: (result.urls || []).map((url) => ({ kind: 'remote-url', url }))
169
+ };
170
+ }
171
+ throw unsupportedCapability(request.capability, 'dashscope');
172
+ },
173
+
174
+ async download(request = {}) {
175
+ const artifact = request.artifact || {};
176
+ if (artifact.kind !== 'remote-url' || !artifact.url) {
177
+ throw new ProviderContractError('download 需要 remote-url artifact', {
178
+ stage: 'download', category: 'protocol', acceptance: 'accepted'
179
+ });
180
+ }
181
+ const targetPath = String(request.targetPath || '').trim();
182
+ if (!targetPath) {
183
+ throw new ProviderContractError('download 需要 targetPath', {
184
+ stage: 'download', category: 'local_io', acceptance: 'accepted'
185
+ });
186
+ }
187
+ return {
188
+ bytes: await transport.downloadTo(artifact.url, targetPath, {
189
+ signal: request.signal,
190
+ ...(request.timeoutMs ? { timeoutMs: request.timeoutMs } : {})
191
+ })
192
+ };
193
+ },
194
+
195
+ mapError: mapProviderAdapterError
196
+ };
197
+ }
198
+
199
+ export function createDashScopeProviderAdapter(provider, { transport = defaultTransport } = {}) {
200
+ return defineProviderAdapter({
201
+ id: providerIdentity(provider).id,
202
+ protocol: 'dashscope',
203
+ capabilities: ['image', 'video', 'tts', 'transcribe'],
204
+ operations: operationsForDashScope(provider, transport),
205
+ unsupported: {
206
+ cancel: '当前 DashScope 媒体协议未提供经过验证的远端取消实现'
207
+ }
208
+ });
209
+ }
210
+
211
+ function operationsForOpenAiImages(provider, transport) {
212
+ const { key, baseUrl } = providerIdentity(provider);
213
+ return {
214
+ async discover({ signal, timeoutMs } = {}) {
215
+ return { models: await transport.listModels({ key, baseUrl, signal, timeoutMs }) };
216
+ },
217
+
218
+ async submit(request = {}) {
219
+ if (request.capability !== 'image') {
220
+ throw unsupportedCapability(request.capability, 'openai-images');
221
+ }
222
+ const input = request.input || {};
223
+ const items = await transport.openAiGenerateImage({
224
+ key,
225
+ baseUrl,
226
+ model: request.model,
227
+ prompt: input.prompt,
228
+ size: input.size,
229
+ n: input.n,
230
+ signal: request.signal,
231
+ ...(request.timeoutMs ? { timeoutMs: request.timeoutMs } : {})
232
+ });
233
+ return { kind: 'completed', value: { kind: 'openai', items } };
234
+ },
235
+
236
+ async download(request = {}) {
237
+ const artifact = request.artifact || {};
238
+ if (artifact.kind !== 'remote-url' || !artifact.url) {
239
+ throw new ProviderContractError('download 需要 remote-url artifact', {
240
+ stage: 'download', category: 'protocol', acceptance: 'accepted'
241
+ });
242
+ }
243
+ const targetPath = String(request.targetPath || '').trim();
244
+ if (!targetPath) {
245
+ throw new ProviderContractError('download 需要 targetPath', {
246
+ stage: 'download', category: 'local_io', acceptance: 'accepted'
247
+ });
248
+ }
249
+ return {
250
+ bytes: await transport.downloadTo(artifact.url, targetPath, {
251
+ signal: request.signal,
252
+ ...(request.timeoutMs ? { timeoutMs: request.timeoutMs } : {})
253
+ })
254
+ };
255
+ },
256
+
257
+ mapError: mapProviderAdapterError
258
+ };
259
+ }
260
+
261
+ export function createOpenAiImagesProviderAdapter(provider, { transport = defaultTransport } = {}) {
262
+ return defineProviderAdapter({
263
+ id: providerIdentity(provider).id,
264
+ protocol: 'openai-images',
265
+ capabilities: ['image'],
266
+ operations: operationsForOpenAiImages(provider, transport),
267
+ unsupported: {
268
+ poll: 'OpenAI Images 兼容路径在当前实现中同步返回结果',
269
+ cancel: '同步图片响应没有可验证的远端取消任务'
270
+ }
271
+ });
272
+ }
273
+
274
+ export function createConfiguredProviderAdapter(provider, options = {}) {
275
+ const protocol = options.protocol || provider?.mediaProtocol || inferMediaProtocol(provider?.baseUrl);
276
+ if (protocol === 'dashscope') return createDashScopeProviderAdapter(provider, options);
277
+ if (protocol === 'openai-images') return createOpenAiImagesProviderAdapter(provider, options);
278
+ throw new TypeError('iris: 不支持的 Provider 协议:' + String(protocol));
279
+ }
@@ -0,0 +1,234 @@
1
+ 'use strict';
2
+ /**
3
+ * v0.1.3 最小 Provider 提交契约。
4
+ *
5
+ * 这里只定义提交结果、脱敏错误和候选调度边界,不依赖 DSH、配置存储或具体供应商。
6
+ */
7
+ import { allowsAutomaticFailover } from './task-semantics.js';
8
+ import { parseModelRef } from './models.js';
9
+
10
+ export const SUBMISSION_KINDS = Object.freeze([
11
+ 'completed',
12
+ 'accepted',
13
+ 'not_accepted',
14
+ 'acceptance_unknown'
15
+ ]);
16
+
17
+ export const PROVIDER_STAGES = Object.freeze([
18
+ 'validate',
19
+ 'prepare',
20
+ 'upload',
21
+ 'submit',
22
+ 'response',
23
+ 'poll',
24
+ 'cancel',
25
+ 'download',
26
+ 'persist',
27
+ 'emit'
28
+ ]);
29
+
30
+ export const PROVIDER_ERROR_CATEGORIES = Object.freeze([
31
+ 'invalid_request',
32
+ 'authentication',
33
+ 'quota',
34
+ 'rate_limit',
35
+ 'network',
36
+ 'timeout',
37
+ 'aborted',
38
+ 'provider',
39
+ 'protocol',
40
+ 'local_io',
41
+ 'unknown'
42
+ ]);
43
+
44
+ const ACCEPTANCE = new Set(['not_accepted', 'accepted', 'unknown']);
45
+ const SECRET_QUERY = /([?&](?:access[_-]?key|api[_-]?key|authorization|credential|signature|token)=)[^&#\s]+/gi;
46
+ const BEARER = /\bBearer\s+[^\s,;]+/gi;
47
+ 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;
49
+ const POSIX_PATH = /(^|[\s("'=])\/(?:[^/\s"'`,;)}\]]+\/)*[^/\s"'`,;)}\]]+/gm;
50
+ const WINDOWS_PATH = /\b[A-Za-z]:\\(?:[^\\\s"'`,;)}\]]+\\)*[^\\\s"'`,;)}\]]+/g;
51
+
52
+ export function redactProviderMessage(value, maxLength = 1000) {
53
+ return String(value || '供应商操作失败')
54
+ .replace(SECRET_QUERY, '$1[REDACTED]')
55
+ .replace(BEARER, 'Bearer [REDACTED]')
56
+ .replace(API_KEY, '[REDACTED]')
57
+ .replace(JSON_SECRET, '$1[REDACTED]')
58
+ .replace(WINDOWS_PATH, '[PATH]')
59
+ .replace(POSIX_PATH, '$1[PATH]')
60
+ .slice(0, maxLength);
61
+ }
62
+
63
+ function enumValue(values, value, fallback) {
64
+ return values.includes(value) ? value : fallback;
65
+ }
66
+
67
+ function integerOrUndefined(value) {
68
+ const number = Number(value);
69
+ return Number.isInteger(number) && number >= 100 && number <= 599 ? number : undefined;
70
+ }
71
+
72
+ /** 只返回允许持久化的结构化字段,不包含 cause、stack、请求体或响应正文。 */
73
+ export function providerErrorRecord(error, overrides = {}) {
74
+ const source = error && typeof error === 'object' ? error : {};
75
+ const acceptance = ACCEPTANCE.has(overrides.acceptance)
76
+ ? overrides.acceptance
77
+ : (ACCEPTANCE.has(source.acceptance) ? source.acceptance : 'unknown');
78
+ const out = {
79
+ stage: enumValue(PROVIDER_STAGES, overrides.stage || source.stage, 'submit'),
80
+ category: enumValue(PROVIDER_ERROR_CATEGORIES, overrides.category || source.category, 'unknown'),
81
+ acceptance,
82
+ retryable: Boolean(overrides.retryable ?? source.retryable),
83
+ safeMessage: redactProviderMessage(overrides.safeMessage || source.safeMessage || source.message || error)
84
+ };
85
+ const httpStatus = integerOrUndefined(overrides.httpStatus ?? source.httpStatus ?? source.status);
86
+ if (httpStatus !== undefined) out.httpStatus = httpStatus;
87
+ const providerCode = overrides.providerCode ?? source.providerCode ?? source.code;
88
+ if (typeof providerCode === 'string' && providerCode.trim()) {
89
+ out.providerCode = redactProviderMessage(providerCode.trim(), 128);
90
+ }
91
+ return out;
92
+ }
93
+
94
+ export class ProviderContractError extends Error {
95
+ constructor(message, details = {}) {
96
+ super(redactProviderMessage(message));
97
+ this.name = 'ProviderContractError';
98
+ Object.assign(this, providerErrorRecord({ ...details, message }, details));
99
+ }
100
+
101
+ toJSON() {
102
+ return providerErrorRecord(this);
103
+ }
104
+ }
105
+
106
+ export function normalizeSubmissionResult(value) {
107
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
108
+ throw new TypeError('Provider submit 必须返回结构化结果');
109
+ }
110
+ if (!SUBMISSION_KINDS.includes(value.kind)) {
111
+ throw new TypeError('未知 Provider submit kind: ' + String(value.kind));
112
+ }
113
+ if (value.kind === 'completed') {
114
+ return { kind: 'completed', acceptance: 'accepted', value: value.value };
115
+ }
116
+ if (value.kind === 'accepted') {
117
+ const remoteTaskId = String(value.remoteTaskId || '').trim();
118
+ if (!remoteTaskId) throw new TypeError('accepted 结果必须包含 remoteTaskId');
119
+ return { kind: 'accepted', acceptance: 'accepted', remoteTaskId };
120
+ }
121
+ const acceptance = value.kind === 'not_accepted' ? 'not_accepted' : 'unknown';
122
+ return {
123
+ kind: value.kind,
124
+ acceptance,
125
+ error: providerErrorRecord(value.error, { acceptance })
126
+ };
127
+ }
128
+
129
+ export function resultFromThrown(error) {
130
+ const safe = providerErrorRecord(error);
131
+ if (safe.acceptance === 'not_accepted') {
132
+ return normalizeSubmissionResult({ kind: 'not_accepted', error: safe });
133
+ }
134
+ return normalizeSubmissionResult({ kind: 'acceptance_unknown', error: safe });
135
+ }
136
+
137
+ function safeAttemptSnapshot(attempt, result) {
138
+ const out = {
139
+ id: attempt.id,
140
+ ordinal: attempt.ordinal,
141
+ providerId: attempt.providerId,
142
+ model: attempt.model,
143
+ acceptance: result.acceptance,
144
+ resultKind: result.kind
145
+ };
146
+ if (result.remoteTaskId) out.remoteTaskId = result.remoteTaskId;
147
+ if (result.error) out.error = result.error;
148
+ return out;
149
+ }
150
+
151
+ /**
152
+ * 串行尝试 Provider 候选,并强制执行写前记录与受理边界。
153
+ *
154
+ * beforeAttempt 必须把 Attempt 写入持久化存储后再返回。afterResult 的落盘失败
155
+ * 会作为 localError 返回并立即停止;即使结果明确未受理,也不在证据未落盘时继续。
156
+ */
157
+ export async function submitWithAcceptanceBoundary(candidates, input, hooks = {}) {
158
+ if (!Array.isArray(candidates) || candidates.length === 0) {
159
+ throw new TypeError('至少需要一个 Provider 候选');
160
+ }
161
+ if (typeof hooks.beforeAttempt !== 'function') {
162
+ throw new TypeError('beforeAttempt 写前持久化 Hook 必填');
163
+ }
164
+
165
+ const attempts = [];
166
+ for (let index = 0; index < candidates.length; index++) {
167
+ const candidate = candidates[index];
168
+ if (!candidate || typeof candidate.submit !== 'function') {
169
+ throw new TypeError(`Provider 候选 ${index + 1} 缺少 submit()`);
170
+ }
171
+ const initial = {
172
+ ordinal: index + 1,
173
+ providerId: String(candidate.id || '').trim(),
174
+ model: String(candidate.model || '').trim(),
175
+ acceptance: 'none',
176
+ stage: 'submitting'
177
+ };
178
+ const parsedModel = parseModelRef(initial.model);
179
+ if (!initial.providerId || !parsedModel || parsedModel.providerId !== initial.providerId) {
180
+ throw new TypeError(`Provider 候选 ${index + 1} 缺少 id、复合 model,或两者身份不一致`);
181
+ }
182
+
183
+ // 写前 Hook 失败时尚未调用供应商;错误原样抛出,由本地持久化层处理。
184
+ const persisted = await hooks.beforeAttempt({ ...initial });
185
+ const attemptId = persisted && typeof persisted.id === 'string' ? persisted.id.trim() : '';
186
+ if (!attemptId) throw new TypeError('beforeAttempt 必须返回已持久化的稳定 Attempt ID');
187
+ // Hook 只能补充持久化身份和可选元数据,不能改写候选 Provider、模型或 ordinal。
188
+ const attempt = {
189
+ ...initial,
190
+ id: attemptId,
191
+ ...(typeof persisted.startedAt === 'string' ? { startedAt: persisted.startedAt } : {}),
192
+ ...(typeof persisted.idempotencyKey === 'string' ? { idempotencyKey: persisted.idempotencyKey } : {})
193
+ };
194
+
195
+ let result;
196
+ try {
197
+ result = normalizeSubmissionResult(await candidate.submit(input, { attempt: { ...attempt } }));
198
+ } catch (error) {
199
+ result = resultFromThrown(error);
200
+ }
201
+
202
+ const snapshot = safeAttemptSnapshot(attempt, result);
203
+ attempts.push(snapshot);
204
+ if (typeof hooks.afterResult === 'function') {
205
+ try {
206
+ await hooks.afterResult({ ...snapshot });
207
+ } catch (error) {
208
+ return {
209
+ candidate,
210
+ result,
211
+ attempts,
212
+ localError: providerErrorRecord(error, {
213
+ stage: 'persist',
214
+ category: 'local_io',
215
+ acceptance: result.acceptance
216
+ })
217
+ };
218
+ }
219
+ }
220
+
221
+ if (!allowsAutomaticFailover(result)) {
222
+ return { candidate, result, attempts, localError: null };
223
+ }
224
+ }
225
+
226
+ return {
227
+ candidate: null,
228
+ result: attempts.length
229
+ ? { kind: 'not_accepted', acceptance: 'not_accepted', error: attempts.at(-1).error }
230
+ : null,
231
+ attempts,
232
+ localError: null
233
+ };
234
+ }
@@ -0,0 +1,59 @@
1
+ 'use strict';
2
+
3
+ export const PROVIDER_HEALTH_SCHEMA_VERSION = 1;
4
+ export const PROVIDER_HEALTH_FRESH_MS = 7 * 24 * 60 * 60 * 1000;
5
+ export const PROVIDER_HEALTH_STATES = Object.freeze([
6
+ 'unconfigured',
7
+ 'configured',
8
+ 'verified',
9
+ 'failed'
10
+ ]);
11
+
12
+ const DEFINITIVE_FAILURES = new Set(['authentication', 'auth', 'permission']);
13
+
14
+ function validTime(value) {
15
+ const ms = Date.parse(String(value || ''));
16
+ return Number.isFinite(ms) ? ms : 0;
17
+ }
18
+
19
+ export function isDefinitiveHealthFailure(value) {
20
+ const status = Number(value && (value.httpStatus ?? value.status));
21
+ const category = String(value && value.category || '').toLowerCase();
22
+ return status === 401 || status === 403 || DEFINITIVE_FAILURES.has(category);
23
+ }
24
+
25
+ export function healthObservationState(observation, {
26
+ now = Date.now(),
27
+ freshMs = PROVIDER_HEALTH_FRESH_MS
28
+ } = {}) {
29
+ if (!observation || typeof observation !== 'object') return 'configured';
30
+ const successAt = validTime(observation.lastSuccess && observation.lastSuccess.at);
31
+ const failureAt = validTime(observation.lastFailure && observation.lastFailure.at);
32
+ const cutoff = Number(now) - Number(freshMs);
33
+
34
+ if (failureAt >= cutoff && failureAt >= successAt
35
+ && isDefinitiveHealthFailure(observation.lastFailure)) {
36
+ return 'failed';
37
+ }
38
+ if (successAt >= cutoff && successAt > failureAt) return 'verified';
39
+ return 'configured';
40
+ }
41
+
42
+ export function aggregateHealthStates(states) {
43
+ const list = (Array.isArray(states) ? states : []).filter((state) =>
44
+ PROVIDER_HEALTH_STATES.includes(state) && state !== 'unconfigured');
45
+ if (!list.length) return 'unconfigured';
46
+ if (list.includes('verified')) return 'verified';
47
+ if (list.includes('configured')) return 'configured';
48
+ return 'failed';
49
+ }
50
+
51
+ export function newestHealthEvidence(observation) {
52
+ const entries = [
53
+ observation && observation.lastSuccess,
54
+ observation && observation.lastFailure,
55
+ observation && observation.lastTransient
56
+ ].filter((item) => item && validTime(item.at));
57
+ entries.sort((a, b) => validTime(b.at) - validTime(a.at));
58
+ return entries[0] || null;
59
+ }
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
  }