@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/CHANGELOG.md +34 -1
- package/README.en.md +16 -2
- package/README.md +16 -2
- package/bin/dsh-iris.js +23 -0
- package/docs/ARCHITECTURE.md +81 -0
- package/docs/FAULT_INJECTION.md +26 -0
- package/docs/PROVIDER_SUBMISSION_CONTRACT.md +60 -0
- package/docs/ROADMAP.md +8 -8
- package/docs/SECURITY.md +53 -0
- package/docs/TASK_SEMANTICS.md +147 -0
- package/docs/releases/0.1.3.md +30 -0
- package/lib/actions.js +609 -175
- package/lib/api.js +80 -7
- package/lib/client.js +109 -14
- package/lib/doctor.js +243 -0
- package/lib/index.js +46 -45
- package/lib/provider-contract.js +234 -0
- package/lib/task-semantics.js +209 -0
- package/lib/tasks.js +473 -29
- package/package.json +7 -2
- package/user_guide.md +20 -0
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
266
|
-
if (
|
|
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
|
-
|
|
333
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
390
|
-
if (
|
|
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
|
+
}
|