@mokuyoaxis/dsh-iris 0.1.2 → 0.1.3

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/lib/index.js CHANGED
@@ -21,10 +21,11 @@ import { serveRender, BrowserHtmlRenderer } from './render.js';
21
21
  import { ensurePrivateDir, hardenPrivateTree } from './private-storage.js';
22
22
  import { guarded } from './guard.js';
23
23
  import { longOcr } from './ocr.js';
24
- import { runAction, listActions } from './actions.js';
24
+ import { runAction, listActions, taskPollDeps } from './actions.js';
25
25
  import { ffmpegAvailable, extractFrames, probeVideo, extractAudioTrack } from './media-probe.js';
26
26
  import { buildContactSheet, summarizeMedia } from './summarize.js';
27
27
  import { registerBundledSkills } from './bundled-skills.js';
28
+ export { doctor, formatDoctorReport } from './doctor.js';
28
29
 
29
30
  const MEDIA_TYPES = {
30
31
  '.png': 'image/png',
@@ -81,48 +82,47 @@ function mountIrisRoutes(routeCtx) {
81
82
  }
82
83
  }
83
84
 
84
- /** 盯守依赖:按能力给轮询间隔与结果转存扩展名 */
85
+ /** 向后兼容导出;真实实现与人工接管共用 actions.taskPollDeps。 */
85
86
  export function pollDeps(provider, cap) {
86
- if (cap === 'transcribe') {
87
- // 转写任务的结果在 transcription_text 而非 urls——重启接管必须走专用轮询器,
88
- // 否则 pollTask 拿到空 urls,盯守会「成功」但转写文本静默丢失。
89
- // (与 iris_transcribe_audio 工具内的盯守依赖保持同构。)
90
- return {
91
- key: () => provider.apiKey,
92
- poll: async ({ key, remoteTaskId }) => {
93
- const r = await adapters.pollTranscriptionTask({ key, baseUrl: provider.baseUrl, remoteTaskId });
94
- return { done: r.done, ok: r.ok, urls: r.text ? [r.text] : [], message: r.message };
95
- },
96
- intervalMs: 2500,
97
- onSuccess: async (t, r) => {
98
- tasks.update(t.id, { transcribeText: (r.urls && r.urls.join('')) || '' });
99
- return [];
100
- }
101
- };
87
+ return taskPollDeps(provider, { cap });
88
+ }
89
+
90
+ /** 旧 status 与 Task v2 事实轴共同决定工具是否应停止等待。 */
91
+ export function taskWaitFinished(task) {
92
+ if (!task) return false;
93
+ if (task.status !== 'running') return true;
94
+ return tasks.isV2Task(task) && task.phase === 'terminal';
95
+ }
96
+
97
+ /** 把 v2 正交事实转换成不会误导用户的工具错误;空串表示已成功可交付。 */
98
+ export function taskTerminalProblem(task, label = '任务') {
99
+ if (!task) return label + '记录不存在';
100
+ if (!tasks.isV2Task(task)) {
101
+ if (task.status === 'canceled') return '已取消';
102
+ if (task.status !== 'succeeded') return label + '失败:' + (task.error || '未知原因');
103
+ return '';
102
104
  }
103
- return {
104
- key: () => provider.apiKey,
105
- poll: ({ key, remoteTaskId }) => adapters.pollTask({ key, baseUrl: provider.baseUrl, remoteTaskId }),
106
- intervalMs: cap === 'video' ? 6000 : 2500,
107
- onSuccess: async (t, r) => {
108
- fs.mkdirSync(outputDir(), { recursive: true });
109
- const names = [];
110
- for (const url of r.urls || []) {
111
- const name = `${t.id}-${names.length}.${adapters.extFromUrl(url, cap === 'video' ? 'mp4' : 'png')}`;
112
- await adapters.downloadTo(url, path.join(outputDir(), name));
113
- names.push(name);
114
- }
115
- // 登记媒体链接(token 授权),对话流里可点播
116
- for (const n of names) registerMedia(t.id, path.join(outputDir(), n));
117
- return names;
118
- }
119
- };
105
+ if (task.outcome === 'succeeded' && task.deliveryState === 'ready') return '';
106
+ if (task.outcome === 'succeeded' && task.deliveryState === 'failed') {
107
+ return label + '已成功,但产物交付失败:' + (task.error || '可稍后重新交付;禁止重新生成');
108
+ }
109
+ if (task.acceptance === 'unknown' || task.outcome === 'unknown') {
110
+ return label + '的远端受理或结果状态未知:' + (task.error || '禁止自动重提,请先人工确认');
111
+ }
112
+ if (task.cancelState === 'unknown') {
113
+ return label + '的远端取消状态未知:' + (task.error || '请先人工确认');
114
+ }
115
+ if (task.outcome === 'canceled') return '已取消';
116
+ if (task.outcome === 'failed') return label + '失败:' + (task.error || '供应商明确返回失败');
117
+ return task.phase === 'terminal'
118
+ ? label + '需要人工处理:' + (task.error || '状态未能自动收口')
119
+ : '';
120
120
  }
121
121
 
122
122
  /**
123
123
  * 在工具调用内同步等待任务到终态。
124
124
  * @returns 终态任务记录;null = 等待超时(任务已转后台继续盯守)
125
- * @throws 取消时抛错并标记 canceled
125
+ * @throws 取消时抛错并记录保守取消事实
126
126
  */
127
127
  async function awaitTerminal(taskId, { timeoutMs, signal }) {
128
128
  const deadline = Date.now() + timeoutMs;
@@ -132,7 +132,7 @@ async function awaitTerminal(taskId, { timeoutMs, signal }) {
132
132
  throw new Error('已取消');
133
133
  }
134
134
  const t = tasks.get(taskId);
135
- if (t && t.status !== 'running') return t;
135
+ if (taskWaitFinished(t)) return t;
136
136
  if (Date.now() > deadline) return null;
137
137
  await new Promise((r) => setTimeout(r, 1000));
138
138
  }
@@ -256,14 +256,14 @@ export async function apply(ctx) {
256
256
  if (!prompt) throw new Error('iris: prompt 不能为空');
257
257
  const submitted = await runAction(ctx, 'image', args, { signal: exec.signal });
258
258
  let final = tasks.get(submitted.taskId);
259
- if (final && final.status === 'running') {
259
+ if (final && !taskWaitFinished(final)) {
260
260
  final = await awaitTerminal(submitted.taskId, { timeoutMs: AWAIT_MS.image, signal: exec.signal });
261
261
  }
262
262
  if (!final) {
263
263
  return { blocks: [{ type: 'text', text: `[iris] 图像任务仍在后台盯守(task: ${submitted.taskId})。稍后可用 iris_task_status 查询进度与文件路径。` }] };
264
264
  }
265
- if (final.status === 'canceled') throw new Error('已取消');
266
- if (final.status !== 'succeeded') throw new Error('生成失败:' + (final.error || '未知原因'));
265
+ const terminalProblem = taskTerminalProblem(final, '图像生成');
266
+ if (terminalProblem) throw new Error(terminalProblem);
267
267
 
268
268
  const files = (final.files || []).map((f) => path.join(outputDir(), f));
269
269
  const blocks = [];
@@ -329,8 +329,8 @@ export async function apply(ctx) {
329
329
  return `[iris] 视频任务已提交并转入后台盯守(task: ${submitted.taskId},remoteTask: ${submitted.remoteTaskId})。\n` +
330
330
  '渲染通常需要数分钟;完成后用 iris_task_status 查询产物路径。';
331
331
  }
332
- if (final.status === 'canceled') throw new Error('已取消');
333
- if (final.status !== 'succeeded') throw new Error('视频生成失败:' + (final.error || '未知原因'));
332
+ const terminalProblem = taskTerminalProblem(final, '视频生成');
333
+ if (terminalProblem) throw new Error(terminalProblem);
334
334
  const paths = (final.files || []).map((f) => path.join(outputDir(), f));
335
335
  const links = mediaLinksOf(final);
336
336
  const secs = Math.round((Date.now() - new Date(final.createdAt).getTime()) / 1000);
@@ -359,7 +359,8 @@ export async function apply(ctx) {
359
359
  async execute(args, exec) {
360
360
  const submitted = await runAction(ctx, 'tts', args, { signal: exec.signal });
361
361
  const final = tasks.get(submitted.taskId);
362
- if (!final || final.status !== 'succeeded') throw new Error('语音合成失败:' + ((final && final.error) || '未知原因'));
362
+ const terminalProblem = taskTerminalProblem(final, '语音合成');
363
+ if (terminalProblem) throw new Error(terminalProblem);
363
364
  const fileName = (final.files || [])[0];
364
365
  const p = path.join(outputDir(), fileName);
365
366
  const media = (final.media || [])[0];
@@ -386,8 +387,8 @@ export async function apply(ctx) {
386
387
  const submitted = await runAction(ctx, 'transcribe', args, { signal: exec.signal });
387
388
  const final = await awaitTerminal(submitted.taskId, { timeoutMs: 120000, signal: exec.signal });
388
389
  if (!final) return `[iris] 音频转写任务已提交并转入后台(task: ${submitted.taskId})。完成后用 iris_task_status 查询。`;
389
- if (final.status === 'canceled') throw new Error('已取消');
390
- if (final.status !== 'succeeded') throw new Error('转写失败:' + (final.error || '未知原因'));
390
+ const terminalProblem = taskTerminalProblem(final, '音频转写');
391
+ if (terminalProblem) throw new Error(terminalProblem);
391
392
  return `[iris] 音频转写完成(${submitted.model || final.model}):\n${final.transcribeText || ''}`;
392
393
  }
393
394
  });
@@ -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,209 @@
1
+ 'use strict';
2
+ /**
3
+ * Iris Task/Attempt v2 的纯语义契约。
4
+ *
5
+ * 本模块只定义名称、派生规则和不可违反的约束,不直接读写任务或调用供应商;
6
+ * tasks、API 与 Doctor 共同消费这里的语义,避免各自解释状态。
7
+ */
8
+
9
+ export const TASK_PHASES = Object.freeze(['queued', 'submitting', 'accepted', 'running', 'terminal']);
10
+ export const ACCEPTANCE_STATES = Object.freeze(['none', 'not_accepted', 'accepted', 'unknown']);
11
+ export const WATCH_STATES = Object.freeze(['idle', 'active', 'suspended', 'exhausted']);
12
+ export const TASK_OUTCOMES = Object.freeze(['none', 'succeeded', 'failed', 'canceled', 'unknown']);
13
+ export const DELIVERY_STATES = Object.freeze(['none', 'pending', 'downloading', 'ready', 'failed']);
14
+ export const CANCEL_STATES = Object.freeze(['none', 'requested', 'remote_confirmed', 'local_confirmed', 'unknown']);
15
+
16
+ export const USER_STATES = Object.freeze([
17
+ 'queued',
18
+ 'running',
19
+ 'watching_paused',
20
+ 'needs_attention',
21
+ 'artifact_unavailable',
22
+ 'succeeded',
23
+ 'failed',
24
+ 'canceled'
25
+ ]);
26
+
27
+ const includes = (values, value) => values.includes(value);
28
+
29
+ /**
30
+ * 自动 failover 的唯一许可条件。
31
+ * retryable、HTTP 状态或网络错误本身都不能推翻受理边界。
32
+ */
33
+ export function allowsAutomaticFailover(attempt) {
34
+ return Boolean(attempt && attempt.acceptance === 'not_accepted');
35
+ }
36
+
37
+ /**
38
+ * 给仍只理解 running/succeeded/failed/canceled 的旧消费者提供保守视图。
39
+ * 无法准确表达的未知、观察暂停和交付失败一律不伪装成终态。
40
+ */
41
+ export function deriveLegacyStatus(task) {
42
+ if (!task || typeof task !== 'object') return 'running';
43
+ if (task.outcome === 'succeeded' && task.deliveryState === 'ready') return 'succeeded';
44
+ if (task.outcome === 'failed') return 'failed';
45
+ if (
46
+ task.outcome === 'canceled' &&
47
+ (task.cancelState === 'remote_confirmed' || task.cancelState === 'local_confirmed')
48
+ ) return 'canceled';
49
+ return 'running';
50
+ }
51
+
52
+ /** 面向工作台的稳定语义;UI 文案可以本地化,但不能改变事实分类。 */
53
+ export function deriveUserState(task) {
54
+ if (!task || typeof task !== 'object') return 'needs_attention';
55
+ if (task.outcome === 'succeeded' && task.deliveryState === 'ready') return 'succeeded';
56
+ if (task.outcome === 'succeeded' && task.deliveryState === 'failed') return 'artifact_unavailable';
57
+ if (task.outcome === 'failed') return 'failed';
58
+ if (
59
+ task.outcome === 'canceled' &&
60
+ (task.cancelState === 'remote_confirmed' || task.cancelState === 'local_confirmed')
61
+ ) return 'canceled';
62
+ if (
63
+ task.acceptance === 'unknown' ||
64
+ task.outcome === 'unknown' ||
65
+ task.cancelState === 'unknown'
66
+ ) return 'needs_attention';
67
+ if (task.watchState === 'suspended' || task.watchState === 'exhausted') return 'watching_paused';
68
+ if (task.phase === 'queued') return 'queued';
69
+ return 'running';
70
+ }
71
+
72
+ /**
73
+ * 提醒处置是事实轴之外的用户工作流视图。事件优先;早期 0.1.3 只有
74
+ * manualRetries 的记录也应视为已通过重试处理,避免升级后重复提醒。
75
+ */
76
+ export function attentionDisposition(task) {
77
+ const events = Array.isArray(task && task.attentionEvents) ? task.attentionEvents : [];
78
+ const last = [...events].reverse().find((item) => item && ['acknowledged', 'restored'].includes(item.type));
79
+ if (last) {
80
+ return {
81
+ status: last.type === 'acknowledged' ? 'acknowledged' : 'open',
82
+ reason: last.reason || (last.type === 'restored' ? 'restored' : 'read'),
83
+ updatedAt: last.at || '',
84
+ ...(last.relatedTaskId ? { relatedTaskId: last.relatedTaskId } : {})
85
+ };
86
+ }
87
+ const retries = Array.isArray(task && task.manualRetries) ? task.manualRetries : [];
88
+ const retry = retries[retries.length - 1];
89
+ if (retry && retry.taskId) {
90
+ return {
91
+ status: 'acknowledged', reason: 'retried', updatedAt: retry.createdAt || '',
92
+ relatedTaskId: retry.taskId, inferred: true
93
+ };
94
+ }
95
+ return { status: 'open', reason: '', updatedAt: '' };
96
+ }
97
+
98
+ /**
99
+ * 校验 v2 记录内部是否自洽。返回稳定错误码,调用方决定如何展示。
100
+ * 这里只检查不可违反的事实关系,不替持久化层补默认值。
101
+ */
102
+ export function semanticViolations(task) {
103
+ const errors = [];
104
+ if (!task || typeof task !== 'object' || Array.isArray(task)) return ['task.object'];
105
+
106
+ if (!includes(TASK_PHASES, task.phase)) errors.push('task.phase');
107
+ if (!includes(ACCEPTANCE_STATES, task.acceptance)) errors.push('task.acceptance');
108
+ if (!includes(WATCH_STATES, task.watchState)) errors.push('task.watchState');
109
+ if (!includes(TASK_OUTCOMES, task.outcome)) errors.push('task.outcome');
110
+ if (!includes(DELIVERY_STATES, task.deliveryState)) errors.push('task.deliveryState');
111
+ if (!includes(CANCEL_STATES, task.cancelState)) errors.push('task.cancelState');
112
+
113
+ if (task.watchState === 'active' && task.acceptance !== 'accepted') {
114
+ errors.push('watch.active_requires_acceptance');
115
+ }
116
+ if (task.phase === 'terminal' && task.watchState === 'active') {
117
+ errors.push('terminal.active_watch');
118
+ }
119
+ if (task.deliveryState !== 'none' && task.outcome !== 'succeeded') {
120
+ errors.push('delivery.requires_success');
121
+ }
122
+ if (
123
+ (task.cancelState === 'remote_confirmed' || task.cancelState === 'local_confirmed') &&
124
+ task.outcome !== 'canceled'
125
+ ) {
126
+ errors.push('cancel.confirmed_requires_canceled_outcome');
127
+ }
128
+ if (
129
+ task.outcome === 'canceled' &&
130
+ task.cancelState !== 'remote_confirmed' &&
131
+ task.cancelState !== 'local_confirmed'
132
+ ) {
133
+ errors.push('canceled.requires_confirmation');
134
+ }
135
+ return errors;
136
+ }
137
+
138
+ const V2_FIELDS = ['phase', 'acceptance', 'watchState', 'outcome', 'deliveryState', 'cancelState'];
139
+ const DELIVERY_FAILURE = /转存失败|下载[^::]*失败|落盘失败|delivery/i;
140
+ const WATCH_FAILURE = /盯守超时|轮询连续失败|无法恢复|任务已超时/;
141
+
142
+ function hasCompleteV2Shape(task) {
143
+ return V2_FIELDS.every((field) => Object.prototype.hasOwnProperty.call(task, field));
144
+ }
145
+
146
+ /**
147
+ * 将旧四态任务转换为保守的 v2 内存视图,不修改输入,也不执行磁盘写入。
148
+ * 无法从旧记录证明的受理、结果与取消事实一律使用 unknown。
149
+ */
150
+ export function normalizeLegacyTask(task) {
151
+ if (!task || typeof task !== 'object' || Array.isArray(task)) {
152
+ throw new TypeError('旧任务必须是对象');
153
+ }
154
+ const out = {
155
+ ...task,
156
+ ...(Array.isArray(task.attempts) ? { attempts: task.attempts.map((attempt) => ({ ...attempt })) } : {})
157
+ };
158
+ if (hasCompleteV2Shape(out)) return out;
159
+
160
+ const status = String(task.status || 'running');
161
+ const hasRemoteId = typeof task.remoteTaskId === 'string' && Boolean(task.remoteTaskId.trim());
162
+ const error = String(task.error || '');
163
+ const defaults = {
164
+ phase: 'terminal',
165
+ acceptance: hasRemoteId ? 'accepted' : 'unknown',
166
+ watchState: 'idle',
167
+ outcome: 'unknown',
168
+ deliveryState: 'none',
169
+ cancelState: 'none'
170
+ };
171
+
172
+ if (status === 'succeeded') {
173
+ Object.assign(defaults, {
174
+ acceptance: 'accepted',
175
+ outcome: 'succeeded',
176
+ deliveryState: 'ready'
177
+ });
178
+ } else if (status === 'running' && hasRemoteId) {
179
+ Object.assign(defaults, {
180
+ phase: 'running',
181
+ acceptance: 'accepted',
182
+ watchState: 'suspended',
183
+ outcome: 'none'
184
+ });
185
+ } else if (status === 'failed' && DELIVERY_FAILURE.test(error)) {
186
+ Object.assign(defaults, {
187
+ acceptance: 'accepted',
188
+ outcome: 'succeeded',
189
+ deliveryState: 'failed'
190
+ });
191
+ } else if (status === 'failed' && WATCH_FAILURE.test(error)) {
192
+ Object.assign(defaults, {
193
+ acceptance: hasRemoteId ? 'accepted' : 'unknown',
194
+ watchState: hasRemoteId ? 'exhausted' : 'idle',
195
+ outcome: 'unknown'
196
+ });
197
+ } else if (status === 'canceled') {
198
+ Object.assign(defaults, {
199
+ acceptance: hasRemoteId ? 'accepted' : 'unknown',
200
+ outcome: 'unknown',
201
+ cancelState: 'unknown'
202
+ });
203
+ }
204
+
205
+ for (const field of V2_FIELDS) {
206
+ if (!Object.prototype.hasOwnProperty.call(out, field)) out[field] = defaults[field];
207
+ }
208
+ return out;
209
+ }