zipcalm 0.0.0-stage → 1.0.1
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/LICENSE +21 -0
- package/README.md +259 -2
- package/bin/zipcalm.js +191 -0
- package/package.json +64 -4
- package/server/app.js +309 -0
- package/server/boot.js +145 -0
- package/server/config/defaults.js +153 -0
- package/server/config/index.js +140 -0
- package/server/config/schema.js +244 -0
- package/server/core/auth/password.js +89 -0
- package/server/core/auth/store.js +261 -0
- package/server/core/compress/blacklist.js +236 -0
- package/server/core/compress/capRegistry.js +171 -0
- package/server/core/compress/deleter.js +285 -0
- package/server/core/compress/hardlinkScan.js +67 -0
- package/server/core/compress/orchestrator.js +783 -0
- package/server/core/compress/preflight.js +376 -0
- package/server/core/compress/registry.js +240 -0
- package/server/core/compress/sourceWalk.js +84 -0
- package/server/core/compress/verify.js +47 -0
- package/server/core/engineRunner.js +369 -0
- package/server/core/fs/list.js +164 -0
- package/server/core/fs/repairNames.js +150 -0
- package/server/core/fs/roots.js +185 -0
- package/server/core/fs/safePath.js +173 -0
- package/server/core/fs/search.js +108 -0
- package/server/core/fs/stat.js +110 -0
- package/server/core/fs/systemTrash.js +212 -0
- package/server/core/fs/writeOps.js +257 -0
- package/server/core/hardlink/executor.js +106 -0
- package/server/core/hardlink/scanner.js +193 -0
- package/server/core/metrics/collector.js +279 -0
- package/server/core/metrics/procfs.js +107 -0
- package/server/core/task/events.js +82 -0
- package/server/core/task/manager.js +987 -0
- package/server/core/task/recovery.js +116 -0
- package/server/core/task/store.js +240 -0
- package/server/http/middleware/auth.js +130 -0
- package/server/http/middleware/error.js +54 -0
- package/server/http/middleware/logger.js +27 -0
- package/server/http/middleware/pathGuard.js +119 -0
- package/server/http/router.js +29 -0
- package/server/http/routes/auth.js +115 -0
- package/server/http/routes/blacklist.js +97 -0
- package/server/http/routes/capabilities.js +21 -0
- package/server/http/routes/compress.js +339 -0
- package/server/http/routes/fs.js +109 -0
- package/server/http/routes/hardlink.js +160 -0
- package/server/http/routes/health.js +52 -0
- package/server/http/routes/metrics.js +36 -0
- package/server/http/routes/tasks.js +304 -0
- package/server/lib/atomic-json.js +118 -0
- package/server/lib/errors.js +145 -0
- package/server/lib/fs-probe.js +25 -0
- package/server/lib/logger.js +68 -0
- package/server/lib/runtime-guard.js +49 -0
- package/server/lib/throttle.js +42 -0
- package/server/plugins/capabilities/password/index.js +300 -0
- package/server/plugins/capabilities/password/methods/aes256.js +31 -0
- package/server/plugins/capabilities/password/methods/zip20.js +29 -0
- package/server/plugins/formats/_shared/createGuards.js +26 -0
- package/server/plugins/formats/_shared/entrySafety.js +174 -0
- package/server/plugins/formats/_shared/nameRepair.js +123 -0
- package/server/plugins/formats/_shared/streams.js +18 -0
- package/server/plugins/formats/_shared/tar/compression.js +114 -0
- package/server/plugins/formats/_shared/tar/reader.js +74 -0
- package/server/plugins/formats/_shared/tar/writer.js +206 -0
- package/server/plugins/formats/_shared/tarFamily.js +207 -0
- package/server/plugins/formats/rar/index.js +354 -0
- package/server/plugins/formats/rar/js-engine.js +122 -0
- package/server/plugins/formats/rar/js-runner.mjs +129 -0
- package/server/plugins/formats/rar/unrar.js +266 -0
- package/server/plugins/formats/sevenzip/engine.js +355 -0
- package/server/plugins/formats/sevenzip/index.js +461 -0
- package/server/plugins/formats/sevenzip/wasm-runner.mjs +72 -0
- package/server/plugins/formats/tar/index.js +19 -0
- package/server/plugins/formats/targz/index.js +19 -0
- package/server/plugins/formats/tarzst/index.js +26 -0
- package/server/plugins/formats/zip/index.js +587 -0
- package/shared/app-version.js +7 -0
- package/shared/capabilities.js +21 -0
- package/shared/compress-modes.js +38 -0
- package/shared/error-codes.js +117 -0
- package/shared/format-list.js +101 -0
- package/shared/hardlink-hint.js +21 -0
- package/shared/task-phase.js +62 -0
- package/shared/task-status.js +84 -0
- package/web/dist/assets/index-D9Vdmrb9.css +1 -0
- package/web/dist/assets/index-DiDB4ClR.js +131 -0
- package/web/dist/index.html +18 -0
|
@@ -0,0 +1,987 @@
|
|
|
1
|
+
// 任务管理器(M3:落盘 + 恢复 + 串行队列 + 事件发布)
|
|
2
|
+
//
|
|
3
|
+
// 契约:docs/M0/数据模型.md §二(任务对象)/ §三(状态机)/ §四(进度)/ §五(队列)/ §六(落盘)
|
|
4
|
+
//
|
|
5
|
+
// 本模块负责:
|
|
6
|
+
// - 任务对象生命周期(创建 → 入队 → 执行 → 终态),字段严格对齐 §2.1;
|
|
7
|
+
// - **串行队列**:全局同时最多 1 个 running,FIFO 按 createdAt(§五);
|
|
8
|
+
// - **落盘**:创建/状态变化立即原子落盘,进度变更 500ms 节流(§4.2、INV-10);
|
|
9
|
+
// - **启动恢复**:非终态任务一律迁 interrupted + `error.code=INTERRUPTED`(§六.4);
|
|
10
|
+
// - **事件发布**:状态/phase 变化立即发布,进度 ≤10 次/秒(SSE 与磁盘是两条线,§4.2);
|
|
11
|
+
// - 二次确认删除的门闩(M2 已有)+ 重试=新任务(新 UUID + `retryOf`,§3.2)。
|
|
12
|
+
//
|
|
13
|
+
// 与执行器的边界:管理器不知道"怎么压",执行器不知道"怎么存"。
|
|
14
|
+
// 绝对路径(absPath 等)只存在于 record.internal 与 tasks.json,**绝不进 API 响应**;
|
|
15
|
+
// 执行与订阅耦合为零:零订阅者时任务照常跑、照常落盘(INV-02/INV-04)。
|
|
16
|
+
//
|
|
17
|
+
// 结构:所有可变状态放在 `ctx`(tasks / queue / 节流时间戳 / 活动任务 id),
|
|
18
|
+
// 具体动作都是**模块级小函数**(每个只做一件事),工厂函数只把动作绑到 ctx 上。
|
|
19
|
+
|
|
20
|
+
import crypto from 'node:crypto';
|
|
21
|
+
|
|
22
|
+
import { ERROR_CODES, apiError } from '../../lib/errors.js';
|
|
23
|
+
import { reconcileAfterBoot } from './recovery.js';
|
|
24
|
+
|
|
25
|
+
const TERMINAL_STATUSES = new Set(['done', 'failed', 'aborted', 'interrupted']);
|
|
26
|
+
/** 终态中要留在 tasks.json 的(可重试);done/aborted 写历史后移除(§六.2) */
|
|
27
|
+
const PERSISTED_TERMINAL_STATUSES = new Set(['failed', 'interrupted']);
|
|
28
|
+
/** 允许取消的 phase(§3.2 迁移表守卫条件) */
|
|
29
|
+
/** 允许取消的 phase(§3.2 迁移表守卫条件;契约 v2 已把 waiting_confirm 纳入——取消=放弃删除) */
|
|
30
|
+
const CANCELLABLE_PHASES = new Set(['precheck', 'scanning', 'compressing', 'extracting', 'verifying', 'waiting_confirm']);
|
|
31
|
+
/** waiting_confirm 默认超时(数据模型 §八:默认 1800000=30 分钟,0=不限) */
|
|
32
|
+
const DEFAULT_CONFIRM_TIMEOUT_MS = 1800000;
|
|
33
|
+
const PROGRESS_PUBLISH_INTERVAL_MS = 100;
|
|
34
|
+
const SPEED_WINDOW_MS = 5000;
|
|
35
|
+
|
|
36
|
+
function now() {
|
|
37
|
+
return Date.now();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function buildContext(deps) {
|
|
41
|
+
return {
|
|
42
|
+
logger: deps.logger,
|
|
43
|
+
store: deps.store,
|
|
44
|
+
events: deps.events,
|
|
45
|
+
// PW-1:任务落盘前的能力参数脱敏器(由 capRegistry 提供通用实现;单测未注入时跳过)
|
|
46
|
+
sanitizeOptionsForPersist: typeof deps.sanitizeOptionsForPersist === 'function' ? deps.sanitizeOptionsForPersist : null,
|
|
47
|
+
bootId: crypto.randomUUID(),
|
|
48
|
+
/**
|
|
49
|
+
* 保留策略参数(来自配置,不写死;数据模型 §六 规则 6):
|
|
50
|
+
* `failed`/`interrupted` 保留最近 max 条或 days 天,任一先到即清理。
|
|
51
|
+
*/
|
|
52
|
+
retention: {
|
|
53
|
+
max: Number.isInteger(deps.retention && deps.retention.max) ? deps.retention.max : 100,
|
|
54
|
+
days: Number.isFinite(deps.retention && deps.retention.days) ? deps.retention.days : 7,
|
|
55
|
+
},
|
|
56
|
+
/**
|
|
57
|
+
* waiting_confirm 最长等待(数据模型 §八 / §3.3 硬要求 1)。
|
|
58
|
+
* 0 = 不超时;非法值回落默认 30 分钟(与 schema 告警口径一致)。
|
|
59
|
+
* 超时按放弃删除结算——超时绝不等于确认。
|
|
60
|
+
*/
|
|
61
|
+
confirmTimeoutMs: Number.isInteger(deps.confirmTimeoutMs) && deps.confirmTimeoutMs >= 0
|
|
62
|
+
? deps.confirmTimeoutMs
|
|
63
|
+
: (deps.confirmTimeoutMs === 0 ? 0 : DEFAULT_CONFIRM_TIMEOUT_MS),
|
|
64
|
+
/**
|
|
65
|
+
* 启动对账所需路径(M5-F09:trash 孤儿补记 + deleting 中断收敛)。
|
|
66
|
+
* 未提供时对账跳过(单测/最小装配不受影响)。
|
|
67
|
+
*/
|
|
68
|
+
recoveryPaths: deps.trashDir && deps.logDir && deps.rootAbs
|
|
69
|
+
? { trashDir: deps.trashDir, logDir: deps.logDir, rootAbs: deps.rootAbs }
|
|
70
|
+
: null,
|
|
71
|
+
/** Map<id, record>:record = {info(契约任务)、internal(绝对路径等)、runner、abortController、…} */
|
|
72
|
+
tasks: new Map(),
|
|
73
|
+
/** FIFO 队列(只存 id;按 createdAt 升序,§五) */
|
|
74
|
+
queue: [],
|
|
75
|
+
activeId: null,
|
|
76
|
+
pumpScheduled: false,
|
|
77
|
+
/** 进度样本(内存,不落盘):用于 speedBps / etaSeconds */
|
|
78
|
+
speedSamples: new Map(),
|
|
79
|
+
/** 进度发布节流:Map<id, number> */
|
|
80
|
+
lastPublishAt: new Map(),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ===== 任务对象构造(纯函数) =====
|
|
85
|
+
|
|
86
|
+
function emptyProgress() {
|
|
87
|
+
return {
|
|
88
|
+
percent: 0,
|
|
89
|
+
processedBytes: 0,
|
|
90
|
+
totalBytes: 0,
|
|
91
|
+
processedFiles: 0,
|
|
92
|
+
totalFiles: 0,
|
|
93
|
+
currentFile: null,
|
|
94
|
+
packagesDone: 0,
|
|
95
|
+
packagesTotal: 1,
|
|
96
|
+
speedBps: 0,
|
|
97
|
+
etaSeconds: null,
|
|
98
|
+
updatedAt: now(),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function emptyDeleteState() {
|
|
103
|
+
return {
|
|
104
|
+
requested: true,
|
|
105
|
+
confirmed: false,
|
|
106
|
+
confirmedAt: null,
|
|
107
|
+
status: 'pending',
|
|
108
|
+
targets: [],
|
|
109
|
+
trashBatch: null,
|
|
110
|
+
deletedAt: null,
|
|
111
|
+
logRef: null,
|
|
112
|
+
error: null,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** 取字节数:优先 sizeBytes,兼容旧的 size 字段;都没有则 null(表示"尚未扫描") */
|
|
117
|
+
function pickSizeBytes(raw) {
|
|
118
|
+
if (Number.isFinite(raw.sizeBytes)) return raw.sizeBytes;
|
|
119
|
+
if (Number.isFinite(raw.size)) return raw.size;
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** 取条目数:优先 entryCount,兼容 fileCount;单个文件恒为 1,目录未扫描则 null */
|
|
124
|
+
function pickEntryCount(raw) {
|
|
125
|
+
if (Number.isInteger(raw.entryCount)) return raw.entryCount;
|
|
126
|
+
if (Number.isInteger(raw.fileCount)) return raw.fileCount;
|
|
127
|
+
return raw.kind === 'file' ? 1 : null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** 源条目 → 契约字段(sizeBytes/entryCount 是"扫描后回填"字段,§2.2)
|
|
131
|
+
* 注意:`absPath` 是**进程内部字段**(运行时与落盘需要,API 视图由 publicTask 剥离)。
|
|
132
|
+
*/
|
|
133
|
+
function toSource(raw) {
|
|
134
|
+
return {
|
|
135
|
+
path: raw.path || raw.userPath || '/',
|
|
136
|
+
name: raw.name || '/',
|
|
137
|
+
kind: raw.kind || 'file',
|
|
138
|
+
sizeBytes: pickSizeBytes(raw),
|
|
139
|
+
entryCount: pickEntryCount(raw),
|
|
140
|
+
absPath: raw.absPath || null,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 对外源条目(剥掉 absPath 等内部字段;API 响应里绝不出现绝对路径) */
|
|
145
|
+
function toPublicSource(source) {
|
|
146
|
+
const { absPath, userPath, size, fileCount, ...rest } = source;
|
|
147
|
+
return rest;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* 对外删除目标条目:**白名单式**只保留契约形状(docs/M0/数据模型.md §2.2:{path, kind, sizeBytes})。
|
|
152
|
+
*
|
|
153
|
+
* 红线(契约 v2 §2.2):absPath 等内部字段只允许存在于**传参链路**(如 stageDelete 的 input.targets),
|
|
154
|
+
* 绝不进 API。当前上游 buildDeleteTargets(orchestrator)已映射好契约形状,这里不漏;
|
|
155
|
+
* 白名单是**防御纵深**——将来任何调用方绕过映射直接塞进 targets,也会在此被剥离,
|
|
156
|
+
* 而不是靠"上游恰好映射对了"。
|
|
157
|
+
*/
|
|
158
|
+
function toPublicDeleteTarget(target) {
|
|
159
|
+
return {
|
|
160
|
+
path: target.path,
|
|
161
|
+
kind: target.kind,
|
|
162
|
+
sizeBytes: Number.isFinite(target.sizeBytes) ? target.sizeBytes : null,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** 是否需要删除子状态:压缩侧 keepOriginal=false / 解压侧 deleteArchiveAfterExtract=true */
|
|
167
|
+
function needsDeleteState(input) {
|
|
168
|
+
if (input.kind === 'extract') return Boolean(input.options && input.options.deleteArchiveAfterExtract);
|
|
169
|
+
return Boolean(input.options && input.options.keepOriginal === false);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* API 视图用的能力参数脱敏器。由压缩层在装配完成后注入(见 __setCapabilitySanitizer)。
|
|
174
|
+
*
|
|
175
|
+
* 为什么 API 出口也要脱敏:正常路径上 stageCapabilityVerify 会把脱敏值写回 options,
|
|
176
|
+
* 但 failed/interrupted 等提前退出的任务走不到那一步 —— 若这里直接透传 info.options,
|
|
177
|
+
* 明文密码就会出现在响应里(PW-1)。所以出口统一过一遍,不依赖执行阶段。
|
|
178
|
+
*/
|
|
179
|
+
let apiOptionsSanitizer = null;
|
|
180
|
+
|
|
181
|
+
/** 完整任务视图(剥离 internal;契约 §2.4 的"完整 Task")—— 也可用于任意 info 快照 */
|
|
182
|
+
function publicTask(info) {
|
|
183
|
+
const rawOptions = { ...info.options };
|
|
184
|
+
return {
|
|
185
|
+
...info,
|
|
186
|
+
sources: info.sources.map(toPublicSource),
|
|
187
|
+
options: apiOptionsSanitizer ? apiOptionsSanitizer(rawOptions) : rawOptions,
|
|
188
|
+
progress: { ...info.progress },
|
|
189
|
+
result: info.result ? { ...info.result, packages: info.result.packages.map((pkg) => ({ ...pkg })) } : null,
|
|
190
|
+
delete: info.delete ? { ...info.delete, targets: info.delete.targets.map(toPublicDeleteTarget) } : null,
|
|
191
|
+
error: info.error ? { ...info.error } : null,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function toApiTask(record) {
|
|
196
|
+
return publicTask(record.info);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** 任务摘要(列表项与 SSE 增量用,§2.4):只保留列表/任务条需要的字段 */
|
|
200
|
+
function toSummary(record) {
|
|
201
|
+
const { info } = record;
|
|
202
|
+
return {
|
|
203
|
+
id: info.id,
|
|
204
|
+
kind: info.kind,
|
|
205
|
+
status: info.status,
|
|
206
|
+
phase: info.phase,
|
|
207
|
+
mode: info.mode,
|
|
208
|
+
format: info.format,
|
|
209
|
+
sources: info.sources.map((source) => ({ path: source.path, name: source.name, kind: source.kind })),
|
|
210
|
+
options: { keepOriginal: info.options.keepOriginal === true },
|
|
211
|
+
progress: { ...info.progress },
|
|
212
|
+
result: info.result ? { savedBytes: info.result.savedBytes, ratio: info.result.ratio } : null,
|
|
213
|
+
delete: info.delete ? { status: info.delete.status, requested: info.delete.requested } : null,
|
|
214
|
+
error: info.error ? { code: info.error.code, message: info.error.message } : null,
|
|
215
|
+
createdAt: info.createdAt,
|
|
216
|
+
startedAt: info.startedAt,
|
|
217
|
+
finishedAt: info.finishedAt,
|
|
218
|
+
updatedAt: info.updatedAt,
|
|
219
|
+
retryOf: info.retryOf,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ===== 落盘 / 发布 =====
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* 剔除 internal 里「只该活在进程内存」的能力参数(PW-1)。
|
|
227
|
+
*
|
|
228
|
+
* capabilityParams 存的是明文密码,用于压缩后自测(cap.verify)与删除前复核;
|
|
229
|
+
* 它一旦落盘,options 侧的脱敏就失去意义。重启后需要密码的场景(重试)由用户重新输入。
|
|
230
|
+
*/
|
|
231
|
+
function withoutCapabilityParams(internal) {
|
|
232
|
+
if (!internal || typeof internal !== 'object') return internal || {};
|
|
233
|
+
const { capabilityParams: _dropped, ...rest } = internal;
|
|
234
|
+
return rest;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** 当前需要落盘的任务集合(非终态 + failed/interrupted) */
|
|
238
|
+
function persistedTasks(ctx) {
|
|
239
|
+
const list = [];
|
|
240
|
+
for (const record of ctx.tasks.values()) {
|
|
241
|
+
const { status } = record.info;
|
|
242
|
+
if (!TERMINAL_STATUSES.has(status) || PERSISTED_TERMINAL_STATUSES.has(status)) {
|
|
243
|
+
// PW-1:落盘前通用脱敏——capabilities 里的 secret 字段(明文密码)永不落 tasks.json
|
|
244
|
+
const infoForPersist = ctx.sanitizeOptionsForPersist
|
|
245
|
+
? { ...record.info, options: ctx.sanitizeOptionsForPersist(record.info.options) }
|
|
246
|
+
: record.info;
|
|
247
|
+
list.push({
|
|
248
|
+
info: { ...toApiTask({ ...record, info: infoForPersist }), options: infoForPersist.options },
|
|
249
|
+
// internal 里额外带上源的绝对路径(重启后重试要用;API 视图已剥离)。
|
|
250
|
+
// PW-1:capabilityParams 存的是**明文密码**(只该活在进程内存),绝不能落盘——
|
|
251
|
+
// 否则 options 那边脱敏了、internal 这边又漏一遍,等于白做。
|
|
252
|
+
internal: {
|
|
253
|
+
...withoutCapabilityParams(record.internal),
|
|
254
|
+
sourceAbsPaths: record.info.sources.map((source) => source.absPath || null),
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
list.sort((a, b) => a.info.createdAt - b.info.createdAt);
|
|
260
|
+
return list;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** 状态/phase/终态变化:立即原子落盘(不等节流,INV-10) */
|
|
264
|
+
async function persistNow(ctx) {
|
|
265
|
+
await ctx.store.saveNow(persistedTasks(ctx));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** 进度类变更:500ms 节流落盘(§4.2) */
|
|
269
|
+
function persistThrottled(ctx) {
|
|
270
|
+
ctx.store.scheduleSave(persistedTasks(ctx));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** 发布任务事件(摘要);进度类受 100ms 节流,状态/phase 类立即发 */
|
|
274
|
+
function publishTask(ctx, record, options = {}) {
|
|
275
|
+
const id = record.info.id;
|
|
276
|
+
if (options.immediate !== true) {
|
|
277
|
+
const last = ctx.lastPublishAt.get(id) || 0;
|
|
278
|
+
if (now() - last < PROGRESS_PUBLISH_INTERVAL_MS) return;
|
|
279
|
+
}
|
|
280
|
+
ctx.lastPublishAt.set(id, now());
|
|
281
|
+
ctx.events.publish('task', { task: toSummary(record) });
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ===== 创建 / 读取 =====
|
|
285
|
+
|
|
286
|
+
/** 压缩级别缺省值(未指定时用格式默认档;这里只兜底,真实校验在格式包) */
|
|
287
|
+
const DEFAULT_COMPRESS_LEVEL = 6;
|
|
288
|
+
|
|
289
|
+
/** 黑名单意图(M6):随任务落盘,打包阶段按同一份意图重新解析用户层 */
|
|
290
|
+
function normalizeBlacklist(raw) {
|
|
291
|
+
const blacklist = raw || {};
|
|
292
|
+
return {
|
|
293
|
+
enabled: blacklist.enabled !== false,
|
|
294
|
+
extraPatterns: Array.isArray(blacklist.extraPatterns)
|
|
295
|
+
? blacklist.extraPatterns.filter((pattern) => typeof pattern === 'string')
|
|
296
|
+
: [],
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** 归一化 options:缺省值集中一处,避免创建路径里塞满三元表达式 */
|
|
301
|
+
function normalizeOptions(raw) {
|
|
302
|
+
const options = raw || {};
|
|
303
|
+
return {
|
|
304
|
+
level: Number.isInteger(options.level) ? options.level : DEFAULT_COMPRESS_LEVEL,
|
|
305
|
+
keepOriginal: options.keepOriginal !== false,
|
|
306
|
+
deleteConfirmed: Boolean(options.deleteConfirmed),
|
|
307
|
+
deleteArchiveAfterExtract: Boolean(options.deleteArchiveAfterExtract),
|
|
308
|
+
outputDir: options.outputDir || null,
|
|
309
|
+
parallel: false,
|
|
310
|
+
// 能力参数(docs/19 §六):原样透传(明文只在内存存活到压缩完成),
|
|
311
|
+
// 落盘时 store.js 会用能力插件的 redact() 剔除明文——这里不认识具体能力。
|
|
312
|
+
...(options.capabilities && typeof options.capabilities === 'object'
|
|
313
|
+
? { capabilities: options.capabilities }
|
|
314
|
+
: {}),
|
|
315
|
+
// P4:解压加密包的密码(明文仅内存;落盘脱敏走 PW-1 通道)
|
|
316
|
+
...(typeof options.password === 'string' && options.password.length > 0
|
|
317
|
+
? { password: options.password }
|
|
318
|
+
: {}),
|
|
319
|
+
// 只有调用方显式给了 blacklist 才写入(解压任务不该凭空多出这个字段)
|
|
320
|
+
...(options.blacklist ? { blacklist: normalizeBlacklist(options.blacklist) } : {}),
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** 组装任务 info(纯数据、无副作用;落盘与广播由调用方负责) */
|
|
325
|
+
function buildTaskInfo(ctx, input, id) {
|
|
326
|
+
return {
|
|
327
|
+
id,
|
|
328
|
+
kind: input.kind,
|
|
329
|
+
status: 'queued',
|
|
330
|
+
phase: 'precheck',
|
|
331
|
+
mode: input.mode || null,
|
|
332
|
+
format: input.format || null,
|
|
333
|
+
sources: (input.sources || []).map(toSource),
|
|
334
|
+
options: normalizeOptions(input.options),
|
|
335
|
+
// 解压任务对外可见的两个路径字段(都是 API 用户路径)
|
|
336
|
+
archivePath: input.archivePath || null,
|
|
337
|
+
targetDir: input.targetDir || null,
|
|
338
|
+
progress: emptyProgress(),
|
|
339
|
+
result: null,
|
|
340
|
+
delete: needsDeleteState(input) ? emptyDeleteState() : null,
|
|
341
|
+
error: null,
|
|
342
|
+
createdAt: now(),
|
|
343
|
+
startedAt: null,
|
|
344
|
+
finishedAt: null,
|
|
345
|
+
updatedAt: now(),
|
|
346
|
+
retryOf: input.retryOf || null,
|
|
347
|
+
bootId: ctx.bootId,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** 组装运行时 record:绝对路径等进程内部字段放这里(**绝不进 API 响应**,但要落盘) */
|
|
352
|
+
function buildTaskRecord(info, input) {
|
|
353
|
+
return {
|
|
354
|
+
info,
|
|
355
|
+
internal: { ...(input.internal || {}) },
|
|
356
|
+
runner: null,
|
|
357
|
+
abortController: new AbortController(),
|
|
358
|
+
confirmResolver: null,
|
|
359
|
+
confirmPromise: null,
|
|
360
|
+
confirmTimer: null,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** 创建任务;**返回前必须已落盘**(契约 §六.1「创建即落盘」,返回后才写会让"创建即落盘"形同虚设) */
|
|
365
|
+
async function createTask(ctx, input) {
|
|
366
|
+
const id = crypto.randomUUID();
|
|
367
|
+
const record = buildTaskRecord(buildTaskInfo(ctx, input, id), input);
|
|
368
|
+
|
|
369
|
+
ctx.tasks.set(id, record);
|
|
370
|
+
ctx.logger.info('task created', { taskId: id, kind: record.info.kind, retryOf: record.info.retryOf });
|
|
371
|
+
// 创建即落盘(§六.1,**等写完再返回**)+ 立即广播(已连接的客户端立刻看到新任务)
|
|
372
|
+
await persistNow(ctx);
|
|
373
|
+
publishTask(ctx, record, { immediate: true });
|
|
374
|
+
return toApiTask(record);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** 任务不存在时的统一文案(三处抛错共用同一句,避免文案漂移) */
|
|
378
|
+
const TASK_NOT_FOUND_MESSAGE = '任务不存在';
|
|
379
|
+
|
|
380
|
+
function requireRecord(ctx, id) {
|
|
381
|
+
const record = ctx.tasks.get(id);
|
|
382
|
+
if (!record) throw apiError(ERROR_CODES.TASK_NOT_FOUND, TASK_NOT_FOUND_MESSAGE, { taskId: id });
|
|
383
|
+
return record;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function getTask(ctx, id) {
|
|
387
|
+
const record = ctx.tasks.get(id);
|
|
388
|
+
if (record) return toApiTask(record);
|
|
389
|
+
// 已进历史的任务:从历史缓存里还原(终态任务不再驻留内存)
|
|
390
|
+
const entry = ctx.store.getHistoryEntry(id);
|
|
391
|
+
return entry ? { ...entry } : null;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function listTasks(ctx, filter = {}) {
|
|
395
|
+
const limit = Number.isInteger(filter.limit) ? filter.limit : 50;
|
|
396
|
+
const items = [...ctx.tasks.values()]
|
|
397
|
+
.filter((record) => (filter.status ? filter.status.includes(record.info.status) : true))
|
|
398
|
+
.map(toSummary)
|
|
399
|
+
.sort((a, b) => b.createdAt - a.createdAt)
|
|
400
|
+
.slice(0, limit);
|
|
401
|
+
return {
|
|
402
|
+
items,
|
|
403
|
+
queue: { active: ctx.activeId, waiting: ctx.queue.length, parallel: false },
|
|
404
|
+
history: ctx.store.listHistory({ limit: 20, status: filter.status || null }),
|
|
405
|
+
seq: ctx.events.currentSeq(),
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** SSE 首帧快照(INV-05:连接即发当前状态,不依赖增量补齐) */
|
|
410
|
+
function snapshotOf(ctx) {
|
|
411
|
+
return {
|
|
412
|
+
tasks: [...ctx.tasks.values()]
|
|
413
|
+
.filter((record) => !['done', 'aborted'].includes(record.info.status))
|
|
414
|
+
.map((record) => toApiTask(record))
|
|
415
|
+
.sort((a, b) => a.createdAt - b.createdAt),
|
|
416
|
+
queue: { active: ctx.activeId, waiting: ctx.queue.length, parallel: false },
|
|
417
|
+
bootId: ctx.bootId,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// ===== 队列与执行 =====
|
|
422
|
+
|
|
423
|
+
/** 入队:runner 负责最终 finalize;异常由 pump 兜底转 failed */
|
|
424
|
+
function enqueueTask(ctx, id, runner) {
|
|
425
|
+
const record = requireRecord(ctx, id);
|
|
426
|
+
record.runner = runner;
|
|
427
|
+
ctx.queue.push(id);
|
|
428
|
+
void pump(ctx);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** 取队首(FIFO:按 createdAt 升序,防并发创建导致顺序错乱) */
|
|
432
|
+
function takeNext(ctx) {
|
|
433
|
+
if (ctx.queue.length === 0) return null;
|
|
434
|
+
ctx.queue.sort((a, b) => (ctx.tasks.get(a)?.info.createdAt || 0) - (ctx.tasks.get(b)?.info.createdAt || 0));
|
|
435
|
+
return ctx.queue.shift();
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** 开始执行一个任务(状态置 running 并立即落盘) */
|
|
439
|
+
async function beginRun(ctx, record) {
|
|
440
|
+
record.info.status = 'running';
|
|
441
|
+
record.info.startedAt = record.info.startedAt || now();
|
|
442
|
+
record.info.updatedAt = now();
|
|
443
|
+
ctx.logger.info('task started', { taskId: record.info.id, kind: record.info.kind });
|
|
444
|
+
publishTask(ctx, record, { immediate: true });
|
|
445
|
+
await persistNow(ctx);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** 失败任务的契约错误对象(aborted 时不带 error,返回 null 由调用方决定) */
|
|
449
|
+
function taskErrorPayload(error, code) {
|
|
450
|
+
return {
|
|
451
|
+
code,
|
|
452
|
+
message: (error && error.message) || '任务失败',
|
|
453
|
+
details: (error && error.details) || null,
|
|
454
|
+
at: now(),
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** 执行器抛错:ABORTED → aborted(无 error);其余 → failed(带契约错误码) */
|
|
459
|
+
function handleTaskError(ctx, id, error, record) {
|
|
460
|
+
const code = (error && error.code) || ERROR_CODES.INTERNAL_ERROR;
|
|
461
|
+
const aborted = code === ERROR_CODES.ABORTED || record.abortController.signal.aborted;
|
|
462
|
+
finalizeTask(ctx, id, aborted ? 'aborted' : 'failed', {
|
|
463
|
+
error: aborted ? null : taskErrorPayload(error, code),
|
|
464
|
+
});
|
|
465
|
+
if (!aborted) ctx.logger.error('task failed', { taskId: id, code });
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** 单执行器循环:同一时刻最多一个 running(§五 串行) */
|
|
469
|
+
async function pump(ctx) {
|
|
470
|
+
if (ctx.activeId || ctx.pumpScheduled) return;
|
|
471
|
+
ctx.pumpScheduled = true;
|
|
472
|
+
await Promise.resolve();
|
|
473
|
+
ctx.pumpScheduled = false;
|
|
474
|
+
|
|
475
|
+
const nextId = takeNext(ctx);
|
|
476
|
+
if (!nextId) return;
|
|
477
|
+
const record = ctx.tasks.get(nextId);
|
|
478
|
+
if (!record || record.info.status !== 'queued') {
|
|
479
|
+
void pump(ctx);
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
ctx.activeId = nextId;
|
|
484
|
+
await beginRun(ctx, record);
|
|
485
|
+
try {
|
|
486
|
+
await record.runner(record);
|
|
487
|
+
if (!TERMINAL_STATUSES.has(record.info.status)) finalizeTask(ctx, nextId, 'done');
|
|
488
|
+
} catch (error) {
|
|
489
|
+
if (!TERMINAL_STATUSES.has(record.info.status)) handleTaskError(ctx, nextId, error, record);
|
|
490
|
+
} finally {
|
|
491
|
+
// waiting_confirm 期间 activeId 已被让位给后续任务(见 markWaitingConfirm);
|
|
492
|
+
// 此处只在仍是自己时才清空,避免误清新任务的 activeId。
|
|
493
|
+
if (ctx.activeId === nextId) ctx.activeId = null;
|
|
494
|
+
void pump(ctx);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// ===== 状态与进度 =====
|
|
499
|
+
|
|
500
|
+
function setPhase(ctx, id, phase) {
|
|
501
|
+
const record = ctx.tasks.get(id);
|
|
502
|
+
if (!record) return;
|
|
503
|
+
record.info.phase = phase;
|
|
504
|
+
record.info.updatedAt = now();
|
|
505
|
+
publishTask(ctx, record, { immediate: true });
|
|
506
|
+
void persistNow(ctx);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* 等待执行槽位(deleting 独占性,P-03 让位机制的回收侧)。
|
|
511
|
+
*
|
|
512
|
+
* 背景:markWaitingConfirm 让位后 activeId 可能属于后续任务 B;A 被确认后进入 deleting——
|
|
513
|
+
* 这是真正的执行相位(rename/移入回收站),**必须与 B 的压缩(读源)互斥**,否则 source
|
|
514
|
+
* 重叠时 B 正在读的文件会被 A 移走(ENOENT / 残缺包)。删除不可逆,该窗口必须封死。
|
|
515
|
+
*
|
|
516
|
+
* 语义:A 的 runner 在进入 deleting 前调用本函数,直到 activeId 空闲(或又是自己)才返回;
|
|
517
|
+
* orchestrator 拿到槽位后立即 setPhase('deleting') 并执行删除。确认 HTTP 请求**不挂起**
|
|
518
|
+
* (确认只做 gate resolve;等待发生在 runner 内部,与方案 2 的差异点)。
|
|
519
|
+
*
|
|
520
|
+
* 前提(已知边界,将来排查从这里看起):本函数是无上限轮询(50ms 间隔),**依赖
|
|
521
|
+
* activeId 一定会被释放**——pump 的 finally 保证正常/异常路径都清 activeId,但若某个
|
|
522
|
+
* runner 卡死在非异步阻塞上(activeId 永不释放),本函数会永久轮询且不响应取消。
|
|
523
|
+
* 该形态属于「runner 卡死」这一前置缺陷的放大,本函数不做超时兜底(谁卡死谁负责)。
|
|
524
|
+
*/
|
|
525
|
+
function waitForExecutionSlot(ctx, id) {
|
|
526
|
+
return new Promise((resolve) => {
|
|
527
|
+
const acquire = () => {
|
|
528
|
+
if (ctx.activeId === null || ctx.activeId === id) {
|
|
529
|
+
ctx.activeId = id;
|
|
530
|
+
resolve();
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
setTimeout(acquire, 50);
|
|
534
|
+
};
|
|
535
|
+
acquire();
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** 滑动窗口速率(最近 5s)+ ETA:样本不足时给 0 / null(不编数) */
|
|
540
|
+
function computeSpeed(ctx, record, processedBytes) {
|
|
541
|
+
const samples = ctx.speedSamples.get(record.info.id) || [];
|
|
542
|
+
samples.push({ t: now(), bytes: processedBytes });
|
|
543
|
+
while (samples.length > 0 && now() - samples[0].t > SPEED_WINDOW_MS) samples.shift();
|
|
544
|
+
ctx.speedSamples.set(record.info.id, samples);
|
|
545
|
+
if (samples.length < 2) return { speedBps: 0, etaSeconds: null };
|
|
546
|
+
|
|
547
|
+
const span = (samples[samples.length - 1].t - samples[0].t) / 1000;
|
|
548
|
+
if (span <= 0) return { speedBps: 0, etaSeconds: null };
|
|
549
|
+
const delta = samples[samples.length - 1].bytes - samples[0].bytes;
|
|
550
|
+
const speedBps = Math.max(0, Math.round(delta / span));
|
|
551
|
+
const { totalBytes } = record.info.progress;
|
|
552
|
+
if (!speedBps || !Number.isFinite(totalBytes) || totalBytes <= 0) return { speedBps, etaSeconds: null };
|
|
553
|
+
return { speedBps, etaSeconds: Math.round(Math.max(0, totalBytes - processedBytes) / speedBps) };
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** 进度里的数值字段:只在调用方给了有限数时才覆盖(undefined/NaN 一律忽略) */
|
|
557
|
+
const PROGRESS_NUMBER_FIELDS = [
|
|
558
|
+
'processedBytes',
|
|
559
|
+
'totalBytes',
|
|
560
|
+
'processedFiles',
|
|
561
|
+
'totalFiles',
|
|
562
|
+
'packagesDone',
|
|
563
|
+
'packagesTotal',
|
|
564
|
+
];
|
|
565
|
+
|
|
566
|
+
function applyProgressNumbers(progress, patch) {
|
|
567
|
+
for (const field of PROGRESS_NUMBER_FIELDS) {
|
|
568
|
+
if (Number.isFinite(patch[field])) progress[field] = patch[field];
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/** 百分比(一位小数):优先按字节算,没有总字节就按文件数算,都缺则保持原值 */
|
|
573
|
+
function computePercent(progress) {
|
|
574
|
+
if (progress.totalBytes > 0) {
|
|
575
|
+
return Math.min(100, Math.round((progress.processedBytes / progress.totalBytes) * 1000) / 10);
|
|
576
|
+
}
|
|
577
|
+
if (progress.totalFiles > 0) {
|
|
578
|
+
return Math.min(100, Math.round((progress.processedFiles / progress.totalFiles) * 1000) / 10);
|
|
579
|
+
}
|
|
580
|
+
return progress.percent;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function updateProgress(ctx, id, patch) {
|
|
584
|
+
const record = ctx.tasks.get(id);
|
|
585
|
+
if (!record) return;
|
|
586
|
+
const progress = record.info.progress;
|
|
587
|
+
applyProgressNumbers(progress, patch);
|
|
588
|
+
if (patch.currentFile !== undefined) progress.currentFile = patch.currentFile;
|
|
589
|
+
progress.percent = computePercent(progress);
|
|
590
|
+
|
|
591
|
+
const speed = computeSpeed(ctx, record, progress.processedBytes);
|
|
592
|
+
progress.speedBps = speed.speedBps;
|
|
593
|
+
progress.etaSeconds = speed.etaSeconds;
|
|
594
|
+
progress.updatedAt = now();
|
|
595
|
+
record.info.updatedAt = progress.updatedAt;
|
|
596
|
+
|
|
597
|
+
publishTask(ctx, record);
|
|
598
|
+
persistThrottled(ctx);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// ===== 终态 =====
|
|
602
|
+
|
|
603
|
+
/** 终态进度语义(§4.3):done→100%;aborted→0/null;failed/interrupted 保留最后真实值 */
|
|
604
|
+
function applyTerminalProgress(info, status) {
|
|
605
|
+
if (status === 'done') info.progress.percent = 100;
|
|
606
|
+
else if (status === 'aborted') {
|
|
607
|
+
info.progress.percent = 0;
|
|
608
|
+
info.progress.currentFile = null;
|
|
609
|
+
}
|
|
610
|
+
info.progress.updatedAt = now();
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function finalizeTask(ctx, id, status, extra = {}) {
|
|
614
|
+
const record = ctx.tasks.get(id);
|
|
615
|
+
if (!record) return null;
|
|
616
|
+
const info = record.info;
|
|
617
|
+
if (TERMINAL_STATUSES.has(info.status)) return toApiTask(record);
|
|
618
|
+
|
|
619
|
+
info.status = status;
|
|
620
|
+
info.finishedAt = now();
|
|
621
|
+
info.updatedAt = info.finishedAt;
|
|
622
|
+
info.error = status === 'failed' || status === 'interrupted' ? (extra.error || info.error) : null;
|
|
623
|
+
if (extra.result) info.result = extra.result;
|
|
624
|
+
if (extra.delete) info.delete = { ...(info.delete || emptyDeleteState()), ...extra.delete };
|
|
625
|
+
applyTerminalProgress(info, status);
|
|
626
|
+
|
|
627
|
+
if (record.confirmResolver) {
|
|
628
|
+
record.confirmResolver.reject(apiError(ERROR_CODES.ABORTED, '任务已结束'));
|
|
629
|
+
clearConfirmState(record);
|
|
630
|
+
} else if (record.confirmTimer) {
|
|
631
|
+
clearConfirmState(record);
|
|
632
|
+
}
|
|
633
|
+
ctx.speedSamples.delete(id);
|
|
634
|
+
ctx.lastPublishAt.delete(id);
|
|
635
|
+
ctx.logger.info('task finalized', { taskId: id, status });
|
|
636
|
+
|
|
637
|
+
// 终态事件必须立即发布(不能被进度节流吞掉)
|
|
638
|
+
ctx.events.publish('task', { task: toSummary(record) });
|
|
639
|
+
// 历史追加 + 立即落盘(done/aborted 从 tasks.json 移除)
|
|
640
|
+
void ctx.store.appendHistory(toApiTask(record));
|
|
641
|
+
void persistNow(ctx);
|
|
642
|
+
// 每完成一个任务后按保留策略清一次(兜底,防 failed/interrupted 无上限增长)
|
|
643
|
+
void pruneRetention(ctx);
|
|
644
|
+
return toApiTask(record);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function cancelTask(ctx, id) {
|
|
648
|
+
const record = requireRecord(ctx, id);
|
|
649
|
+
const { info } = record;
|
|
650
|
+
if (TERMINAL_STATUSES.has(info.status)) {
|
|
651
|
+
throw apiError(ERROR_CODES.TASK_NOT_CANCELLABLE, '任务已结束,无法取消', { status: info.status });
|
|
652
|
+
}
|
|
653
|
+
if (!['queued', 'running'].includes(info.status)) {
|
|
654
|
+
throw apiError(ERROR_CODES.TASK_NOT_CANCELLABLE, '当前状态不可取消', { status: info.status });
|
|
655
|
+
}
|
|
656
|
+
if (info.status === 'running' && !CANCELLABLE_PHASES.has(info.phase)) {
|
|
657
|
+
throw apiError(ERROR_CODES.INVALID_STATE, '该阶段不允许取消', { phase: info.phase });
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
if (info.status === 'queued') {
|
|
661
|
+
// 排队中的任务没有执行器可响应 abort:由队列自身立即结算为 aborted(§3.2 queued → cancelling → aborted)
|
|
662
|
+
const index = ctx.queue.indexOf(id);
|
|
663
|
+
if (index >= 0) ctx.queue.splice(index, 1);
|
|
664
|
+
ctx.logger.info('排队任务取消,直接结算为 aborted', { taskId: id });
|
|
665
|
+
return finalizeTask(ctx, id, 'aborted');
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// waiting_confirm 的取消 = 放弃删除(契约 §3.2:不回滚压缩结果,delete.status='skipped',
|
|
669
|
+
// reason=user_cancelled,任务终态 done)。resolveConfirm 走「放弃」分支唤醒 runner,
|
|
670
|
+
// 由 orchestrator 正常收尾为 done——不经 abort(abort 会把任务错误地打成 aborted)。
|
|
671
|
+
if (info.phase === 'waiting_confirm') {
|
|
672
|
+
ctx.logger.info('waiting_confirm 任务取消 = 放弃删除', { taskId: id });
|
|
673
|
+
return resolveConfirm(ctx, id, { confirmed: false, reason: 'user_cancelled' });
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
info.status = 'cancelling';
|
|
677
|
+
info.updatedAt = now();
|
|
678
|
+
record.abortController.abort();
|
|
679
|
+
if (record.confirmResolver) {
|
|
680
|
+
record.confirmResolver.reject(apiError(ERROR_CODES.ABORTED, '任务已取消'));
|
|
681
|
+
clearConfirmState(record);
|
|
682
|
+
} else if (record.confirmTimer) {
|
|
683
|
+
clearConfirmState(record);
|
|
684
|
+
}
|
|
685
|
+
ctx.logger.info('task cancelling', { taskId: id, phase: info.phase });
|
|
686
|
+
publishTask(ctx, record, { immediate: true });
|
|
687
|
+
void persistNow(ctx);
|
|
688
|
+
return toApiTask(record);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/** 终态任务的历史记录(转成 record 形状,供 retry 复用) */
|
|
692
|
+
function historyRecord(ctx, id) {
|
|
693
|
+
const entry = ctx.store.getHistoryEntry(id);
|
|
694
|
+
return entry ? { info: entry, internal: {} } : null;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/** 重试 = 新任务(新 UUID + retryOf 指向原任务;原任务保持终态不变,§3.2) */
|
|
698
|
+
async function retryTask(ctx, id) {
|
|
699
|
+
const source = ctx.tasks.get(id) || historyRecord(ctx, id);
|
|
700
|
+
if (!source) throw apiError(ERROR_CODES.TASK_NOT_FOUND, '任务不存在', { taskId: id });
|
|
701
|
+
const info = source.info;
|
|
702
|
+
if (!['failed', 'interrupted'].includes(info.status)) {
|
|
703
|
+
throw apiError(ERROR_CODES.INVALID_STATE, '只有失败或中断的任务可以重试', { status: info.status });
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
const created = await createTask(ctx, {
|
|
707
|
+
kind: info.kind,
|
|
708
|
+
mode: info.mode,
|
|
709
|
+
format: info.format,
|
|
710
|
+
sources: (info.sources || []).map((sourceItem, index) => ({
|
|
711
|
+
path: sourceItem.path,
|
|
712
|
+
name: sourceItem.name,
|
|
713
|
+
kind: sourceItem.kind,
|
|
714
|
+
sizeBytes: sourceItem.sizeBytes,
|
|
715
|
+
entryCount: sourceItem.entryCount,
|
|
716
|
+
// 重试要能真正跑起来:绝对路径必须一起带上(来自原件或落盘记录)
|
|
717
|
+
absPath: sourceItem.absPath || (source.internal && source.internal.sourceAbsPaths
|
|
718
|
+
? source.internal.sourceAbsPaths[index]
|
|
719
|
+
: null),
|
|
720
|
+
})),
|
|
721
|
+
archivePath: info.archivePath,
|
|
722
|
+
targetDir: info.targetDir,
|
|
723
|
+
options: { ...info.options, deleteConfirmed: false },
|
|
724
|
+
retryOf: info.id,
|
|
725
|
+
internal: source.internal ? { ...source.internal } : {},
|
|
726
|
+
});
|
|
727
|
+
ctx.logger.info('task retried', { from: id, to: created.id });
|
|
728
|
+
return created;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// ===== 二次确认删除门闩(M2 已有,保持签名) =====
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* 等待二次确认(M2 门闩 + 契约 §3.3 硬要求 1/3:等待有界且不阻塞队列)。
|
|
735
|
+
*
|
|
736
|
+
* 超时语义(confirmTimeoutMs,0=不限):到点按**放弃删除**结算(gate.confirmed=false +
|
|
737
|
+
* reason=confirm_timeout)——超时绝不等于确认。定时器在确认/放弃/超时任一出口清理,
|
|
738
|
+
* 不留泄漏(runner 返回后队列立即恢复,后续任务不再被挂起)。
|
|
739
|
+
*/
|
|
740
|
+
function waitConfirm(ctx, id) {
|
|
741
|
+
const record = requireRecord(ctx, id);
|
|
742
|
+
if (!record.confirmPromise) {
|
|
743
|
+
record.confirmPromise = new Promise((resolve, reject) => {
|
|
744
|
+
record.confirmResolver = { resolve, reject };
|
|
745
|
+
const timeoutMs = ctx.confirmTimeoutMs;
|
|
746
|
+
if (Number.isInteger(timeoutMs) && timeoutMs > 0) {
|
|
747
|
+
record.confirmTimer = setTimeout(() => {
|
|
748
|
+
if (record.confirmResolver !== null) {
|
|
749
|
+
record.confirmResolver = null;
|
|
750
|
+
record.confirmPromise = null;
|
|
751
|
+
ctx.logger.warn('二次确认超时,按放弃删除结算(超时不等于确认)', { taskId: id, timeoutMs });
|
|
752
|
+
resolve({ confirmed: false, at: now(), reason: 'confirm_timeout' });
|
|
753
|
+
}
|
|
754
|
+
}, timeoutMs);
|
|
755
|
+
}
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
return record.confirmPromise;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/** 清理确认门闩的挂起状态(定时器 + resolver),防定时器泄漏与迟到结算 */
|
|
762
|
+
function clearConfirmState(record) {
|
|
763
|
+
if (record.confirmTimer) {
|
|
764
|
+
clearTimeout(record.confirmTimer);
|
|
765
|
+
record.confirmTimer = null;
|
|
766
|
+
}
|
|
767
|
+
record.confirmResolver = null;
|
|
768
|
+
record.confirmPromise = null;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function resolveConfirm(ctx, id, payload = {}) {
|
|
772
|
+
const record = requireRecord(ctx, id);
|
|
773
|
+
if (record.info.phase !== 'waiting_confirm') {
|
|
774
|
+
throw apiError(ERROR_CODES.INVALID_STATE, '当前不是待确认状态', { phase: record.info.phase });
|
|
775
|
+
}
|
|
776
|
+
const resolver = record.confirmResolver;
|
|
777
|
+
clearConfirmState(record);
|
|
778
|
+
if (payload.confirmed === false) {
|
|
779
|
+
if (resolver) resolver.resolve({ confirmed: false, at: now(), reason: payload.reason || 'user_cancelled' });
|
|
780
|
+
return toApiTask(record);
|
|
781
|
+
}
|
|
782
|
+
if (resolver) {
|
|
783
|
+
resolver.resolve({
|
|
784
|
+
confirmed: true,
|
|
785
|
+
at: now(),
|
|
786
|
+
expectedFiles: payload.expectedFiles,
|
|
787
|
+
expectedBytes: payload.expectedBytes,
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
return toApiTask(record);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
function markWaitingConfirm(ctx, id) {
|
|
794
|
+
const record = requireRecord(ctx, id);
|
|
795
|
+
record.info.phase = 'waiting_confirm';
|
|
796
|
+
// 该阶段任务整体仍是 running,但进度显示 100%(§3.3)
|
|
797
|
+
record.info.progress.percent = 100;
|
|
798
|
+
record.info.updatedAt = now();
|
|
799
|
+
// 契约 §3.3 硬要求 3「不得阻塞串行队列」:确认等待期间**让位**——释放 activeId,
|
|
800
|
+
// 队列里的下一个任务照常被 pump 执行。runner 仍挂在 waitConfirm 上,确认/取消/超时
|
|
801
|
+
// 唤醒后直接走 finalize 收尾(不再依赖 activeId)。串行语义不破坏:waiting_confirm
|
|
802
|
+
// 不是执行相位,是业务等待相位。
|
|
803
|
+
if (ctx.activeId === id) ctx.activeId = null;
|
|
804
|
+
publishTask(ctx, record, { immediate: true });
|
|
805
|
+
void persistNow(ctx);
|
|
806
|
+
return toApiTask(record);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// ===== 保留策略(数据模型 §六 规则 6/7/8) =====
|
|
810
|
+
|
|
811
|
+
/** 保留策略只针对"为可重试而保留的终态";非终态是活任务,任何情况下都不参与 */
|
|
812
|
+
const RETENTION_STATUSES = ['failed', 'interrupted'];
|
|
813
|
+
|
|
814
|
+
function retentionCandidates(ctx) {
|
|
815
|
+
return [...ctx.tasks.values()].filter((record) => RETENTION_STATUSES.includes(record.info.status));
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* 纯函数:挑出应清理的记录 —— **最近 max 条之外** 或 **早于 days 天**(任一先到即清)。
|
|
820
|
+
* 按完成时间(缺省用 updatedAt/createdAt)从新到旧排序后判断,便于单测。
|
|
821
|
+
*/
|
|
822
|
+
function selectExpired(records, options) {
|
|
823
|
+
const cutoff = options.nowTs - options.days * 24 * 60 * 60 * 1000;
|
|
824
|
+
const finishedAtOf = (record) => record.info.finishedAt || record.info.updatedAt || record.info.createdAt;
|
|
825
|
+
const sorted = [...records].sort((a, b) => finishedAtOf(b) - finishedAtOf(a));
|
|
826
|
+
const expired = [];
|
|
827
|
+
sorted.forEach((record, index) => {
|
|
828
|
+
if (index >= options.max || finishedAtOf(record) < cutoff) expired.push(record);
|
|
829
|
+
});
|
|
830
|
+
return expired;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/** 执行清理(时机:启动 + 每完成一个任务后);清理后立即原子落盘 */
|
|
834
|
+
async function pruneRetention(ctx) {
|
|
835
|
+
const candidates = retentionCandidates(ctx);
|
|
836
|
+
if (candidates.length === 0) return { examined: 0, removed: 0, kept: 0 };
|
|
837
|
+
|
|
838
|
+
const expired = selectExpired(candidates, { ...ctx.retention, nowTs: now() });
|
|
839
|
+
for (const record of expired) ctx.tasks.delete(record.info.id);
|
|
840
|
+
if (expired.length > 0) {
|
|
841
|
+
await persistNow(ctx);
|
|
842
|
+
ctx.logger.info('任务保留策略已清理活动记录(历史不受影响)', {
|
|
843
|
+
removed: expired.length,
|
|
844
|
+
kept: candidates.length - expired.length,
|
|
845
|
+
max: ctx.retention.max,
|
|
846
|
+
days: ctx.retention.days,
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
return { examined: candidates.length, removed: expired.length, kept: candidates.length - expired.length };
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* `DELETE /tasks/{id}`:只删**活动记录**(tasks.json),**绝不动 task-history.jsonl**(append-only 审计日志)。
|
|
854
|
+
* 仅终态可删;非终态要先取消。
|
|
855
|
+
*/
|
|
856
|
+
function removeTask(ctx, id) {
|
|
857
|
+
const record = ctx.tasks.get(id);
|
|
858
|
+
if (!record) {
|
|
859
|
+
const history = ctx.store.getHistoryEntry(id);
|
|
860
|
+
if (!history) throw apiError(ERROR_CODES.TASK_NOT_FOUND, '任务不存在', { taskId: id });
|
|
861
|
+
// 该任务已进历史、活动记录早已移出:删除请求等价于"已无活动记录"
|
|
862
|
+
return Promise.resolve({ id, removed: false, fromHistory: true, status: history.status });
|
|
863
|
+
}
|
|
864
|
+
if (!TERMINAL_STATUSES.has(record.info.status)) {
|
|
865
|
+
throw apiError(ERROR_CODES.INVALID_STATE, '任务尚未结束,请先取消再删除记录', { status: record.info.status });
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
ctx.tasks.delete(id);
|
|
869
|
+
// 让其他已连接客户端重新对齐(没有 deleted 事件类型,用快照最稳)
|
|
870
|
+
ctx.events.publish('snapshot', snapshotOf(ctx));
|
|
871
|
+
ctx.logger.info('任务活动记录已删除(历史保留)', { taskId: id, status: record.info.status });
|
|
872
|
+
// 等落盘完成再返回:调用方拿到 200 时 tasks.json 里必须已经没有这条(可立即核对)
|
|
873
|
+
return persistNow(ctx).then(() => ({ id, removed: true, fromHistory: false, status: record.info.status }));
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// ===== 启动恢复(§六.4 / INV-10) =====
|
|
877
|
+
|
|
878
|
+
/** 把一条落盘记录还原成内存 record(含源的绝对路径) */
|
|
879
|
+
function restoreRecord(entry) {
|
|
880
|
+
const info = entry.info || entry;
|
|
881
|
+
const internal = entry.internal || {};
|
|
882
|
+
const sourceAbsPaths = Array.isArray(internal.sourceAbsPaths) ? internal.sourceAbsPaths : [];
|
|
883
|
+
info.sources = (info.sources || []).map((source, index) => ({
|
|
884
|
+
...source,
|
|
885
|
+
absPath: source.absPath || sourceAbsPaths[index] || null,
|
|
886
|
+
}));
|
|
887
|
+
return {
|
|
888
|
+
info,
|
|
889
|
+
internal: { ...internal },
|
|
890
|
+
runner: null,
|
|
891
|
+
abortController: new AbortController(),
|
|
892
|
+
confirmResolver: null,
|
|
893
|
+
confirmPromise: null,
|
|
894
|
+
confirmTimer: null,
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/** 非终态 → interrupted(details.reason=SERVER_RESTART);`.part` 不碰、不自动续跑 */
|
|
899
|
+
async function markInterrupted(ctx, info) {
|
|
900
|
+
info.status = 'interrupted';
|
|
901
|
+
info.finishedAt = now();
|
|
902
|
+
info.updatedAt = info.finishedAt;
|
|
903
|
+
info.error = {
|
|
904
|
+
code: ERROR_CODES.INTERRUPTED,
|
|
905
|
+
message: '服务重启导致任务中断(.part 已保留,可重试)',
|
|
906
|
+
details: { reason: 'SERVER_RESTART' },
|
|
907
|
+
at: info.finishedAt,
|
|
908
|
+
};
|
|
909
|
+
info.progress = { ...emptyProgress(), ...info.progress, updatedAt: info.updatedAt };
|
|
910
|
+
// 历史只写"对外视图":此时 info.sources 已还原 absPath,绝不能原样落历史(否则泄漏到 API)
|
|
911
|
+
await ctx.store.appendHistory(publicTask(info));
|
|
912
|
+
ctx.logger.warn('任务恢复为 interrupted', { taskId: info.id, previousBootId: info.bootId });
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
async function recoverTasks(ctx) {
|
|
916
|
+
const persisted = await ctx.store.loadTasks();
|
|
917
|
+
let recovered = 0;
|
|
918
|
+
for (const entry of persisted) {
|
|
919
|
+
const record = restoreRecord(entry);
|
|
920
|
+
if (!TERMINAL_STATUSES.has(record.info.status)) {
|
|
921
|
+
recovered += 1;
|
|
922
|
+
await markInterrupted(ctx, record.info);
|
|
923
|
+
}
|
|
924
|
+
ctx.tasks.set(record.info.id, record);
|
|
925
|
+
}
|
|
926
|
+
// M5-F09 启动对账:trash 孤儿批次补记 + deleting 中断的 delete.status 收敛。
|
|
927
|
+
// 依赖注入缺失(单测/旧装配)时跳过,不阻断恢复主流程。
|
|
928
|
+
let reconciliation = null;
|
|
929
|
+
if (ctx.recoveryPaths) {
|
|
930
|
+
reconciliation = await reconcileAfterBoot(
|
|
931
|
+
{ ...ctx.recoveryPaths, logger: ctx.logger },
|
|
932
|
+
[...ctx.tasks.values()],
|
|
933
|
+
);
|
|
934
|
+
}
|
|
935
|
+
await persistNow(ctx);
|
|
936
|
+
// 启动时按保留策略清一次(非终态刚被迁为 interrupted,属于候选;活任务不受影响)
|
|
937
|
+
const pruned = await pruneRetention(ctx);
|
|
938
|
+
return { loaded: persisted.length, recovered, pruned, reconciliation };
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* 创建任务管理器:建 ctx 并把各动作绑上去(对外只暴露方法,不暴露内部状态)。
|
|
943
|
+
* @param {{logger: object, store: object, events: object}} deps
|
|
944
|
+
*/
|
|
945
|
+
export function createTaskManager(deps) {
|
|
946
|
+
const ctx = buildContext(deps);
|
|
947
|
+
return {
|
|
948
|
+
bootId: ctx.bootId,
|
|
949
|
+
recover: () => recoverTasks(ctx),
|
|
950
|
+
create: (input) => createTask(ctx, input),
|
|
951
|
+
get: (id) => getTask(ctx, id),
|
|
952
|
+
list: (filter) => listTasks(ctx, filter),
|
|
953
|
+
snapshot: () => snapshotOf(ctx),
|
|
954
|
+
enqueue: (id, runner) => enqueueTask(ctx, id, runner),
|
|
955
|
+
setPhase: (id, phase) => setPhase(ctx, id, phase),
|
|
956
|
+
updateProgress: (id, patch) => updateProgress(ctx, id, patch),
|
|
957
|
+
finalize: (id, status, extra) => finalizeTask(ctx, id, status, extra),
|
|
958
|
+
cancel: (id) => cancelTask(ctx, id),
|
|
959
|
+
retry: (id) => retryTask(ctx, id),
|
|
960
|
+
remove: (id) => removeTask(ctx, id),
|
|
961
|
+
prune: () => pruneRetention(ctx),
|
|
962
|
+
retention: () => ({ ...ctx.retention }),
|
|
963
|
+
waitConfirm: (id) => waitConfirm(ctx, id),
|
|
964
|
+
resolveConfirm: (id, payload) => resolveConfirm(ctx, id, payload),
|
|
965
|
+
markWaitingConfirm: (id) => markWaitingConfirm(ctx, id),
|
|
966
|
+
waitForExecutionSlot: (id) => waitForExecutionSlot(ctx, id),
|
|
967
|
+
requireTask: (id) => requireRecord(ctx, id),
|
|
968
|
+
getAbortSignal: (id) => requireRecord(ctx, id).abortController.signal,
|
|
969
|
+
toSummary,
|
|
970
|
+
toApiTask,
|
|
971
|
+
flush: () => ctx.store.flush(),
|
|
972
|
+
/** PW-1:压缩层创建 capRegistry 后补挂脱敏器(manager 先于能力层装配)。
|
|
973
|
+
* 同时挂到落盘路径(ctx)与 API 出口(模块级),两条路都不能漏。 */
|
|
974
|
+
__setCapabilitySanitizer: (fn) => {
|
|
975
|
+
if (typeof fn !== 'function') return;
|
|
976
|
+
ctx.sanitizeOptionsForPersist = fn;
|
|
977
|
+
apiOptionsSanitizer = fn;
|
|
978
|
+
},
|
|
979
|
+
stats: () => ({
|
|
980
|
+
total: ctx.tasks.size,
|
|
981
|
+
active: ctx.activeId,
|
|
982
|
+
waiting: ctx.queue.length,
|
|
983
|
+
subscribers: ctx.events.subscriberCount(),
|
|
984
|
+
seq: ctx.events.currentSeq(),
|
|
985
|
+
}),
|
|
986
|
+
};
|
|
987
|
+
}
|