dsh-task-dispatch-table 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +95 -31
  2. package/README.zh.md +113 -0
  3. package/assets/icon-scheduler.svg +1 -0
  4. package/cordis.patch.yml +9 -0
  5. package/dist/attachment-allowlist.d.ts +24 -0
  6. package/dist/attachment-allowlist.js +41 -0
  7. package/dist/calendar-plan.d.ts +55 -0
  8. package/dist/calendar-plan.js +104 -0
  9. package/dist/client.js +72655 -0
  10. package/dist/client.js.map +1 -0
  11. package/dist/config.d.ts +79 -0
  12. package/dist/config.js +80 -0
  13. package/dist/deps.d.ts +25 -0
  14. package/dist/deps.js +54 -0
  15. package/dist/dispatch.d.ts +133 -0
  16. package/dist/dispatch.js +509 -0
  17. package/dist/event-bus.d.ts +16 -0
  18. package/dist/event-bus.js +67 -0
  19. package/dist/event-catalog.d.ts +42 -0
  20. package/dist/event-catalog.js +60 -0
  21. package/dist/host.d.ts +299 -0
  22. package/dist/host.js +1 -0
  23. package/dist/index.d.ts +25 -0
  24. package/dist/index.js +0 -0
  25. package/dist/receipt.d.ts +38 -0
  26. package/dist/receipt.js +236 -0
  27. package/dist/reconcile.d.ts +105 -0
  28. package/dist/reconcile.js +641 -0
  29. package/dist/runtime-index.d.ts +102 -0
  30. package/dist/runtime-index.js +340 -0
  31. package/dist/schedule-next.d.ts +44 -0
  32. package/dist/schedule-next.js +216 -0
  33. package/dist/scheduler.d.ts +82 -0
  34. package/dist/scheduler.js +639 -0
  35. package/dist/store.d.ts +461 -0
  36. package/dist/store.js +845 -0
  37. package/dist/submit.d.ts +1 -0
  38. package/dist/submit.js +60 -0
  39. package/dist/task-assets.d.ts +124 -0
  40. package/dist/task-assets.js +380 -0
  41. package/dist/task-sort.d.ts +27 -0
  42. package/dist/task-sort.js +59 -0
  43. package/dist/tasks.d.ts +191 -0
  44. package/dist/tasks.js +527 -0
  45. package/locale/en.json +6 -0
  46. package/locale/zh.json +6 -0
  47. package/package.json +67 -6
  48. package/index.js +0 -7
@@ -0,0 +1 @@
1
+ export {};
package/dist/submit.js ADDED
@@ -0,0 +1,60 @@
1
+ // 回执提交 CLI(决策 19;**决策 24 起降级为手动 / 排查备用通道**)。
2
+ // ⚠️ 任务回执现在走插件注册的工具 `task_dispatch_table_receipt`(per-agent、在插件进程内写库):agent 的 bash
3
+ // 跑在沙箱里、写不了宿主状态库(决策 24 的真机证据),命令行通道在自动任务里必然失败。
4
+ // 本 CLI 只在插件未运行、人工补记、或需要绕过沙箱调试时使用。
5
+ // 只记录不裁决——对账由调度器统一做(控制平面单一裁决点);重复提交无害(调度器取最新)。
6
+ //
7
+ // 用法(手动执行,agent 不再调用):
8
+ // node submit.js --db <statePath> --task <taskId> --date <YYYY-MM-DD> \
9
+ // --session <sessionId> --status <s> [--outputs a.md,b.png] [--note "..."]
10
+ import { DatabaseSync } from 'node:sqlite';
11
+ function fail(message) {
12
+ console.error(`[dsh-task-dispatch-table] 回执提交失败: ${message}`);
13
+ process.exit(1);
14
+ }
15
+ // 极简参数解析:--key value(零依赖)。
16
+ const args = process.argv.slice(2);
17
+ const get = (key) => {
18
+ const index = args.indexOf(`--${key}`);
19
+ return index >= 0 ? args[index + 1] : undefined;
20
+ };
21
+ const dbPath = get('db');
22
+ const taskId = get('task');
23
+ const logicalDate = get('date');
24
+ const sessionId = get('session');
25
+ const status = get('status');
26
+ if (dbPath === undefined || taskId === undefined || logicalDate === undefined || sessionId === undefined || status === undefined) {
27
+ fail('缺少必填参数(--db --task --date --session --status)');
28
+ }
29
+ const outputs = (get('outputs') ?? '')
30
+ .split(',')
31
+ .map(part => part.trim())
32
+ .filter(part => part.length > 0);
33
+ const note = get('note');
34
+ let db;
35
+ try {
36
+ db = new DatabaseSync(dbPath);
37
+ }
38
+ catch (error) {
39
+ fail(`状态库打不开 ${dbPath}: ${String(error)}`);
40
+ }
41
+ db.exec('PRAGMA busy_timeout = 5000;'); // 与调度器进程并发写(WAL),等锁 5s
42
+ // 决策 25:实例 id 已改为不透明 UUID,身份 =(任务 + 计划刻度)。
43
+ // 手动通道仍按「任务 + 日期」定位,同日多刻度取最近一条。
44
+ const instance = db
45
+ .prepare(`SELECT id, status, session_id FROM task_instances
46
+ WHERE task_id = ? AND logical_date = ?
47
+ ORDER BY scheduled_at DESC LIMIT 1`)
48
+ .get(taskId, logicalDate);
49
+ if (instance === undefined)
50
+ fail(`实例不存在: ${taskId} @ ${logicalDate}`);
51
+ const instanceId = instance.id;
52
+ if (instance.status !== 'dispatched' && instance.status !== 'running' && instance.status !== 'unknown') {
53
+ fail(`实例已终态(${instance.status}),回执无效`);
54
+ }
55
+ if (instance.session_id !== sessionId) {
56
+ fail(`会话不匹配(回执来自 ${sessionId},实例当前会话 ${instance.session_id ?? 'null'})`);
57
+ }
58
+ db.prepare('INSERT INTO task_events (instance_id, ts, kind, detail) VALUES (?, ?, ?, ?)').run(instanceId, new Date().toISOString(), 'receipt', JSON.stringify({ status, outputs, note, session_id: sessionId }));
59
+ db.close();
60
+ console.log(`回执已提交: ${instanceId} (status=${status}${outputs.length > 0 ? `, outputs=${outputs.length} 项` : ''})`);
@@ -0,0 +1,124 @@
1
+ /** 任务目录根名。 */
2
+ export declare const TASKS_DIR_NAME = "tasks";
3
+ /** 上传临时区(新上传先落这里,保存时才搬进任务目录)。 */
4
+ export declare const TMP_DIR_NAME = "task-attachments-tmp";
5
+ /** 旧平铺目录(历史上传落在插件数据根下,迁移期只读不写)。 */
6
+ export declare const LEGACY_DIR_NAME = "task-attachments";
7
+ /** 资产根路径(state.db 所在目录 = 插件数据根;statePath 启动时定格,运行期不变)。 */
8
+ export interface AssetPaths {
9
+ dataRoot: string;
10
+ tasksRoot: string;
11
+ tmpDir: string;
12
+ legacyDir: string;
13
+ }
14
+ export declare function assetPaths(statePath: string): AssetPaths;
15
+ /** 任务目录(id 必须是 UUID,防路径穿越——校验由调用方 `isUuid` 保证)。 */
16
+ export declare function taskDirOf(paths: AssetPaths, taskId: string): string;
17
+ /**
18
+ * 落盘文件名清洗:**保留原始文件名(含中文与空格)**,只去掉路径分隔符、系统非法字符与控制字符。
19
+ * 用户要自己看得懂、自己管理;改成 UID 就没法管了。
20
+ */
21
+ export declare function safeFileName(name: string): string;
22
+ /** 时间戳文件名(本地墙上时间,毫秒):字典序 = 时间序。 */
23
+ export declare function stampOf(date: Date): string;
24
+ /** 时间戳文件名 → ISO 串(按本地时间解析;解不出返回原名,绝不编造时间)。 */
25
+ export declare function stampToIso(stamp: string): string;
26
+ export interface VersionEntry {
27
+ /** 文件名(= 时间戳 + .md)。 */
28
+ file: string;
29
+ /** ISO 时间(展示用)。 */
30
+ ts: string;
31
+ /** 备注(没填 = '')。 */
32
+ note: string;
33
+ }
34
+ /** 版本列表:新的在前。 */
35
+ export declare function listVersions(paths: AssetPaths, taskId: string): VersionEntry[];
36
+ /** 最新版本的内容(用于「变了才留版」的比较基准)。 */
37
+ export declare function latestVersionContent(paths: AssetPaths, taskId: string): string | null;
38
+ /**
39
+ * 留一版提示词:**与最新版本不同才写**(去首尾空白比较 ⇒ 只按了个回车不算改动)。
40
+ * 目录里没有版本(新建首次保存)⇒ 无条件写。
41
+ * @returns `created=false` 表示内容没变、没留新版。
42
+ */
43
+ export declare function saveVersion(paths: AssetPaths, taskId: string, content: string, note: string): {
44
+ created: boolean;
45
+ file: string;
46
+ };
47
+ /** 读某个版本的提示词全文(文件不存在 ⇒ null,绝不返回假内容)。 */
48
+ export declare function readVersion(paths: AssetPaths, taskId: string, file: string): string | null;
49
+ /** 删除一个版本(用户在版本面板自己删;系统**从不**自动删)。 */
50
+ export declare function deleteVersion(paths: AssetPaths, taskId: string, file: string): boolean;
51
+ export interface SnapshotEntry {
52
+ file: string;
53
+ ts: string;
54
+ }
55
+ /** 快照列表:新的在前。 */
56
+ export declare function listSnapshots(paths: AssetPaths, taskId: string): SnapshotEntry[];
57
+ /**
58
+ * 留一份整份配置快照:**与最新快照不同才写**(连点保存不刷屏)。
59
+ * @param definition 任务定义对象(含 id;快照就是它的原文)。
60
+ */
61
+ export declare function saveSnapshot(paths: AssetPaths, taskId: string, definition: unknown): {
62
+ created: boolean;
63
+ file: string;
64
+ };
65
+ /** 读快照内容(解析失败 ⇒ null)。 */
66
+ export declare function readSnapshot(paths: AssetPaths, taskId: string, file: string): unknown | null;
67
+ /** 删除一个快照。 */
68
+ export declare function deleteSnapshot(paths: AssetPaths, taskId: string, file: string): boolean;
69
+ export interface AttachmentRef {
70
+ id: string;
71
+ name: string;
72
+ kind: 'link' | 'upload';
73
+ ref: string;
74
+ /** link 型:来源工作区 title(同一路径在不同工作区指向不同文件)。 */
75
+ workspace?: string;
76
+ }
77
+ export interface ReconcileResult {
78
+ /** 落定后的附件清单(upload 型的 ref 已改写成「相对任务目录」的路径)。 */
79
+ attachments: AttachmentRef[];
80
+ /** 引用了但盘上找不到的(临时区被清 / 手删了)⇒ 上层提示「需重新上传」。 */
81
+ missing: string[];
82
+ /** 本次从清单里移除、已真删的文件名。 */
83
+ removed: string[];
84
+ /** 搬移 / 删除过程中出错的文件名。 */
85
+ errors: string[];
86
+ }
87
+ /**
88
+ * 附件落定第一步(保存时调用):**只搬移、不真删**。
89
+ * ① upload 型:临时区(或旧平铺目录)的文件 → 搬进 `tasks/<id>/attachments/<原始名>`,
90
+ * ref 改写为**相对任务目录**的路径;已在任务目录里的不动。
91
+ * ② 上次有、这次没有的 upload 项只记进 `removed`(引用 diff),**真删由调用方在
92
+ * 定义成功落库之后**调 `removeAttachmentFiles` 执行——顺序反了会「文件已删、
93
+ * 落库失败」两头空(评审 P1#3)。
94
+ * ③ link 型(工作区已有文件)只记引用,不复制、不搬。
95
+ */
96
+ export declare function moveAttachmentsIn(paths: AssetPaths, taskId: string, next: AttachmentRef[], prev: AttachmentRef[]): ReconcileResult;
97
+ /**
98
+ * 附件落定第二步:真删被移除的文件(**必须在定义成功落库之后**调用)。
99
+ * ref 白名单:任务目录相对路径(attachments/ 前缀)或旧平铺目录的纯文件名;
100
+ * 含 `..` / 绝对路径 / 反斜杠的一律跳过(评审 P0#1:真删循环必须有与 locateUploaded 对称的防御)。
101
+ * @returns 删除失败的展示名(进 errors)。
102
+ */
103
+ export declare function removeAttachmentFiles(paths: AssetPaths, taskId: string, removedRefs: string[]): string[];
104
+ /**
105
+ * 便捷组合:搬移 + 真删一步到位(冒烟与不关心时序的调用方用;
106
+ * webServer 保存链路**必须**分两步走,见 moveAttachmentsIn 注释)。
107
+ */
108
+ export declare function reconcileAttachments(paths: AssetPaths, taskId: string, next: AttachmentRef[], prev: AttachmentRef[]): ReconcileResult;
109
+ /**
110
+ * 附件绝对路径(执行期存在性校验 Loop A / Loop B、派发注入共用)。
111
+ *
112
+ * ref 的基准是**任务目录**:upload 型由 `moveAttachmentsIn` 落盘到 `<任务目录>/attachments/<文件名>`
113
+ * 并把 ref 记成 `attachments/<文件名>` ⇒ **直接拼接**即可。
114
+ *
115
+ * ⚠️ 2026-09-30 真机修:此前把 `attachments/` 前缀**剥掉**再拼(拼成 `<任务目录>/<文件名>`,少一层),
116
+ * 而写入侧与「已在任务目录」判断(`moveAttachmentsIn`)用的都是**带 `attachments/` 的**基准 ⇒
117
+ * **任何带上传附件的任务恒被判「附件不存在」**:Loop A 不建实例行(`scheduler` 校验后 continue 在
118
+ * `ensureInstance` 之前)、Loop B 不发动(`reconcile` 兜底同款)⇒ 执行记录里一条都没有、任务永不执行。
119
+ */
120
+ export declare function attachmentAbsPath(paths: AssetPaths, taskId: string, ref: string): string;
121
+ /** 清上传临时区:删掉 mtime 早于 N 天前的文件(跨天调用一次即可)。 */
122
+ export declare function purgeTmp(paths: AssetPaths, days: number): number;
123
+ /** 删除任务的整个目录(附件 + 版本 + 快照一起)。实例 / 事件保留在库里做审计。 */
124
+ export declare function deleteTaskAssets(paths: AssetPaths, taskId: string): boolean;
@@ -0,0 +1,380 @@
1
+ // 任务文件资产(2026-09-30,design/data-model.md §五):提示词版本 / 整份配置快照 / 附加文件。
2
+ //
3
+ // **这一层只碰文件系统,不碰数据库**:任务定义的真源是 `tasksInline`(state.db meta 表),
4
+ // 版本与快照是「给人兜底」的副本,附件是落盘实体。目录名一律 = 任务 UUID(title/code 可改,
5
+ // 不能用来命名目录)。
6
+ //
7
+ // 布局:
8
+ // <数据根>/tasks/<uuid>/prompt-versions/<时间戳>.md 一个版本一个文件(内容即提示词全文)
9
+ // <数据根>/tasks/<uuid>/prompt-versions/<时间戳>.note 版本备注(填了才有)
10
+ // <数据根>/tasks/<uuid>/snapshots/<时间戳>.json 整份配置快照
11
+ // <数据根>/tasks/<uuid>/attachments/<原始文件名> upload 型附件
12
+ // <数据根>/task-attachments-tmp/ 上传临时区(每天清 N 天前)
13
+ // <数据根>/task-attachments/ 旧平铺目录(迁移用,只出不进)
14
+ //
15
+ // 文件名 = 纯时间戳(yyyyMMddTHHmmssSSS,本地墙上时间)⇒ 字典序 = 时间序,
16
+ // 读目录即得版本列表,不需要序号维护、不需要索引文件。
17
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, } from 'node:fs';
18
+ import { isSafeAttachmentRef } from './attachment-allowlist.js';
19
+ import { basename, dirname, extname, join } from 'node:path';
20
+ /** 任务目录根名。 */
21
+ export const TASKS_DIR_NAME = 'tasks';
22
+ /** 上传临时区(新上传先落这里,保存时才搬进任务目录)。 */
23
+ export const TMP_DIR_NAME = 'task-attachments-tmp';
24
+ /** 旧平铺目录(历史上传落在插件数据根下,迁移期只读不写)。 */
25
+ export const LEGACY_DIR_NAME = 'task-attachments';
26
+ export function assetPaths(statePath) {
27
+ const dataRoot = dirname(statePath);
28
+ return {
29
+ dataRoot,
30
+ tasksRoot: join(dataRoot, TASKS_DIR_NAME),
31
+ tmpDir: join(dataRoot, TMP_DIR_NAME),
32
+ legacyDir: join(dataRoot, LEGACY_DIR_NAME),
33
+ };
34
+ }
35
+ /** 任务目录(id 必须是 UUID,防路径穿越——校验由调用方 `isUuid` 保证)。 */
36
+ export function taskDirOf(paths, taskId) {
37
+ return join(paths.tasksRoot, taskId);
38
+ }
39
+ // ─────────────────────── 文件名与时间戳 ───────────────────────
40
+ /**
41
+ * 落盘文件名清洗:**保留原始文件名(含中文与空格)**,只去掉路径分隔符、系统非法字符与控制字符。
42
+ * 用户要自己看得懂、自己管理;改成 UID 就没法管了。
43
+ */
44
+ export function safeFileName(name) {
45
+ const base = basename(name.replace(/\\/g, '/')).trim();
46
+ const cleaned = base.replace(/[\u0000-\u001f/\\:*?"<>|]/g, '_').replace(/^\.+/, '').trim();
47
+ const cut = cleaned.length > 120 ? cleaned.slice(0, 120) : cleaned;
48
+ return cut === '' ? 'file' : cut;
49
+ }
50
+ /** 时间戳文件名(本地墙上时间,毫秒):字典序 = 时间序。 */
51
+ export function stampOf(date) {
52
+ const p = (n, width = 2) => String(n).padStart(width, '0');
53
+ return `${date.getFullYear()}${p(date.getMonth() + 1)}${p(date.getDate())}`
54
+ + `T${p(date.getHours())}${p(date.getMinutes())}${p(date.getSeconds())}${p(date.getMilliseconds(), 3)}`;
55
+ }
56
+ /** 时间戳文件名 → ISO 串(按本地时间解析;解不出返回原名,绝不编造时间)。 */
57
+ export function stampToIso(stamp) {
58
+ const m = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(\d{3})$/.exec(stamp);
59
+ if (m === null)
60
+ return stamp;
61
+ const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4]), Number(m[5]), Number(m[6]), Number(m[7]));
62
+ return Number.isNaN(d.getTime()) ? stamp : d.toISOString();
63
+ }
64
+ /** 同名冲突:在扩展名前加「 2」「 3」……(不覆盖已有文件)。 */
65
+ function uniqueTarget(dir, fileName) {
66
+ if (!existsSync(join(dir, fileName)))
67
+ return fileName;
68
+ const dot = extname(fileName);
69
+ const stem = dot === '' ? fileName : fileName.slice(0, -dot.length);
70
+ for (let i = 2; i < 1000; i++) {
71
+ const candidate = `${stem} ${i}${dot}`;
72
+ if (!existsSync(join(dir, candidate)))
73
+ return candidate;
74
+ }
75
+ return `${stem} ${Date.now()}${dot}`;
76
+ }
77
+ /** 读目录里的文件名(只取普通文件),按名字排序 ⇒ 时间序。 */
78
+ function listFiles(dir, ext) {
79
+ if (!existsSync(dir))
80
+ return [];
81
+ return readdirSync(dir)
82
+ .filter(name => name.endsWith(ext) && !name.startsWith('.'))
83
+ .filter(name => {
84
+ try {
85
+ return statSync(join(dir, name)).isFile();
86
+ }
87
+ catch {
88
+ return false;
89
+ }
90
+ })
91
+ .sort();
92
+ }
93
+ /** 版本列表:新的在前。 */
94
+ export function listVersions(paths, taskId) {
95
+ const dir = join(taskDirOf(paths, taskId), 'prompt-versions');
96
+ return listFiles(dir, '.md').reverse().map(file => {
97
+ const stamp = file.slice(0, -3);
98
+ const noteFile = join(dir, `${stamp}.note`);
99
+ let note = '';
100
+ if (existsSync(noteFile)) {
101
+ try {
102
+ note = readFileSync(noteFile, 'utf8');
103
+ }
104
+ catch {
105
+ note = '';
106
+ }
107
+ }
108
+ return { file, ts: stampToIso(stamp), note };
109
+ });
110
+ }
111
+ /** 最新版本的内容(用于「变了才留版」的比较基准)。 */
112
+ export function latestVersionContent(paths, taskId) {
113
+ const first = listVersions(paths, taskId)[0];
114
+ if (first === undefined)
115
+ return null;
116
+ try {
117
+ return readFileSync(join(taskDirOf(paths, taskId), 'prompt-versions', first.file), 'utf8');
118
+ }
119
+ catch {
120
+ return null;
121
+ }
122
+ }
123
+ /**
124
+ * 留一版提示词:**与最新版本不同才写**(去首尾空白比较 ⇒ 只按了个回车不算改动)。
125
+ * 目录里没有版本(新建首次保存)⇒ 无条件写。
126
+ * @returns `created=false` 表示内容没变、没留新版。
127
+ */
128
+ export function saveVersion(paths, taskId, content, note) {
129
+ const dir = join(taskDirOf(paths, taskId), 'prompt-versions');
130
+ mkdirSync(dir, { recursive: true });
131
+ const latest = latestVersionContent(paths, taskId);
132
+ if (latest !== null && latest.trim() === content.trim())
133
+ return { created: false, file: '' };
134
+ const stamp = stampOf(new Date());
135
+ writeFileSync(join(dir, `${stamp}.md`), content, 'utf8');
136
+ if (note.trim() !== '')
137
+ writeFileSync(join(dir, `${stamp}.note`), note.trim(), 'utf8');
138
+ return { created: true, file: `${stamp}.md` };
139
+ }
140
+ /** 读某个版本的提示词全文(文件不存在 ⇒ null,绝不返回假内容)。 */
141
+ export function readVersion(paths, taskId, file) {
142
+ const target = join(taskDirOf(paths, taskId), 'prompt-versions', basename(file));
143
+ if (!target.endsWith('.md') || !existsSync(target))
144
+ return null;
145
+ try {
146
+ return readFileSync(target, 'utf8');
147
+ }
148
+ catch {
149
+ return null;
150
+ }
151
+ }
152
+ /** 删除一个版本(用户在版本面板自己删;系统**从不**自动删)。 */
153
+ export function deleteVersion(paths, taskId, file) {
154
+ const dir = join(taskDirOf(paths, taskId), 'prompt-versions');
155
+ const stamp = basename(file).endsWith('.md') ? basename(file).slice(0, -3) : basename(file);
156
+ let removed = false;
157
+ for (const name of [`${stamp}.md`, `${stamp}.note`]) {
158
+ const target = join(dir, name);
159
+ if (!existsSync(target))
160
+ continue;
161
+ try {
162
+ rmSync(target);
163
+ removed = true;
164
+ }
165
+ catch { /* 单个删不掉不致命 */ }
166
+ }
167
+ return removed;
168
+ }
169
+ /** 快照列表:新的在前。 */
170
+ export function listSnapshots(paths, taskId) {
171
+ const dir = join(taskDirOf(paths, taskId), 'snapshots');
172
+ return listFiles(dir, '.json').reverse().map(file => ({ file, ts: stampToIso(file.slice(0, -5)) }));
173
+ }
174
+ /**
175
+ * 留一份整份配置快照:**与最新快照不同才写**(连点保存不刷屏)。
176
+ * @param definition 任务定义对象(含 id;快照就是它的原文)。
177
+ */
178
+ export function saveSnapshot(paths, taskId, definition) {
179
+ const dir = join(taskDirOf(paths, taskId), 'snapshots');
180
+ const text = JSON.stringify(definition, null, 2);
181
+ const latest = listSnapshots(paths, taskId)[0];
182
+ if (latest !== undefined) {
183
+ try {
184
+ if (readFileSync(join(dir, latest.file), 'utf8') === text)
185
+ return { created: false, file: '' };
186
+ }
187
+ catch { /* 读不出就当不同,照写 */ }
188
+ }
189
+ mkdirSync(dir, { recursive: true });
190
+ const stamp = stampOf(new Date());
191
+ writeFileSync(join(dir, `${stamp}.json`), text, 'utf8');
192
+ return { created: true, file: `${stamp}.json` };
193
+ }
194
+ /** 读快照内容(解析失败 ⇒ null)。 */
195
+ export function readSnapshot(paths, taskId, file) {
196
+ const target = join(taskDirOf(paths, taskId), 'snapshots', basename(file));
197
+ if (!target.endsWith('.json') || !existsSync(target))
198
+ return null;
199
+ try {
200
+ return JSON.parse(readFileSync(target, 'utf8'));
201
+ }
202
+ catch {
203
+ return null;
204
+ }
205
+ }
206
+ /** 删除一个快照。 */
207
+ export function deleteSnapshot(paths, taskId, file) {
208
+ const target = join(taskDirOf(paths, taskId), 'snapshots', basename(file));
209
+ if (!target.endsWith('.json') || !existsSync(target))
210
+ return false;
211
+ try {
212
+ rmSync(target);
213
+ return true;
214
+ }
215
+ catch {
216
+ return false;
217
+ }
218
+ }
219
+ const ATTACHMENT_PREFIX = 'attachments/';
220
+ /**
221
+ * 附件落定第一步(保存时调用):**只搬移、不真删**。
222
+ * ① upload 型:临时区(或旧平铺目录)的文件 → 搬进 `tasks/<id>/attachments/<原始名>`,
223
+ * ref 改写为**相对任务目录**的路径;已在任务目录里的不动。
224
+ * ② 上次有、这次没有的 upload 项只记进 `removed`(引用 diff),**真删由调用方在
225
+ * 定义成功落库之后**调 `removeAttachmentFiles` 执行——顺序反了会「文件已删、
226
+ * 落库失败」两头空(评审 P1#3)。
227
+ * ③ link 型(工作区已有文件)只记引用,不复制、不搬。
228
+ */
229
+ export function moveAttachmentsIn(paths, taskId, next, prev) {
230
+ const dir = join(taskDirOf(paths, taskId), 'attachments');
231
+ const missing = [];
232
+ const removed = [];
233
+ const errors = [];
234
+ const out = [];
235
+ for (const item of next) {
236
+ if (item.kind !== 'upload') {
237
+ out.push(item);
238
+ continue;
239
+ }
240
+ // 已在任务目录(ref 形如 attachments/xxx 且文件在)⇒ 原地不动。
241
+ if (item.ref.startsWith(ATTACHMENT_PREFIX) && existsSync(join(dir, item.ref.slice(ATTACHMENT_PREFIX.length)))) {
242
+ out.push(item);
243
+ continue;
244
+ }
245
+ const source = locateUploaded(paths, item.ref);
246
+ if (source === null) {
247
+ // 找不到:照常记进定义,由执行期兜住(缺失 ⇒ 不执行 + 记日志),并回报给 UI 提示重传。
248
+ missing.push(item.name);
249
+ out.push(item);
250
+ continue;
251
+ }
252
+ try {
253
+ mkdirSync(dir, { recursive: true });
254
+ const target = uniqueTarget(dir, safeFileName(item.name));
255
+ copyFileSync(source, join(dir, target));
256
+ out.push({ ...item, ref: `${ATTACHMENT_PREFIX}${target}` });
257
+ }
258
+ catch {
259
+ errors.push(item.name);
260
+ out.push(item);
261
+ }
262
+ }
263
+ // 被移除的 upload 项(上次有、这次没有)⇒ 只记名单,真删延后(见函数注释)。
264
+ for (const item of prev) {
265
+ if (item.kind !== 'upload')
266
+ continue;
267
+ if (next.some(candidate => candidate.ref === item.ref))
268
+ continue;
269
+ removed.push(item.ref);
270
+ }
271
+ return { attachments: out, missing, removed, errors };
272
+ }
273
+ /**
274
+ * 附件落定第二步:真删被移除的文件(**必须在定义成功落库之后**调用)。
275
+ * ref 白名单:任务目录相对路径(attachments/ 前缀)或旧平铺目录的纯文件名;
276
+ * 含 `..` / 绝对路径 / 反斜杠的一律跳过(评审 P0#1:真删循环必须有与 locateUploaded 对称的防御)。
277
+ * @returns 删除失败的展示名(进 errors)。
278
+ */
279
+ export function removeAttachmentFiles(paths, taskId, removedRefs) {
280
+ const dir = join(taskDirOf(paths, taskId), 'attachments');
281
+ const failed = [];
282
+ for (const ref of removedRefs) {
283
+ if (!isSafeAttachmentRef(ref))
284
+ continue;
285
+ const target = ref.startsWith(ATTACHMENT_PREFIX)
286
+ ? join(dir, ref.slice(ATTACHMENT_PREFIX.length))
287
+ : join(paths.legacyDir, ref);
288
+ if (!existsSync(target))
289
+ continue;
290
+ try {
291
+ rmSync(target);
292
+ }
293
+ catch {
294
+ failed.push(ref);
295
+ }
296
+ }
297
+ return failed;
298
+ }
299
+ /**
300
+ * 便捷组合:搬移 + 真删一步到位(冒烟与不关心时序的调用方用;
301
+ * webServer 保存链路**必须**分两步走,见 moveAttachmentsIn 注释)。
302
+ */
303
+ export function reconcileAttachments(paths, taskId, next, prev) {
304
+ const moved = moveAttachmentsIn(paths, taskId, next, prev);
305
+ const removeFailed = removeAttachmentFiles(paths, taskId, moved.removed);
306
+ const removedNames = [];
307
+ for (const ref of moved.removed) {
308
+ const hit = prev.find(item => item.ref === ref);
309
+ removedNames.push(hit !== undefined ? hit.name : ref);
310
+ }
311
+ return { ...moved, removed: removedNames, errors: [...moved.errors, ...removeFailed] };
312
+ }
313
+ /** 按 ref 定位**待搬移**的上传文件:临时区 → 旧平铺目录。找不到返回 null。 */
314
+ function locateUploaded(paths, ref) {
315
+ if (!isSafeAttachmentRef(ref))
316
+ return null;
317
+ // ⚠️ 这里**不**放「任务目录」候选:调用方 `moveAttachmentsIn` 在调本函数**之前**已按
318
+ // `attachmentAbsPath` 判过「已在任务目录就原地不动」,走到这里就说明它不在任务目录。
319
+ // (2026-09-30 修:此前用 `join(paths.tasksRoot, ref)` 充当任务目录候选 —— 那个基准也是错的,
320
+ // 正确基准是 `tasks/<任务id>/`,缺任务 id 根本拼不出来;删掉这个永不命中的假候选。)
321
+ const candidates = [join(paths.tmpDir, ref), join(paths.legacyDir, ref)];
322
+ for (const file of candidates) {
323
+ if (!existsSync(file))
324
+ continue;
325
+ try {
326
+ if (statSync(file).isFile())
327
+ return file;
328
+ }
329
+ catch { /* 继续找下一个 */ }
330
+ }
331
+ return null;
332
+ }
333
+ /**
334
+ * 附件绝对路径(执行期存在性校验 Loop A / Loop B、派发注入共用)。
335
+ *
336
+ * ref 的基准是**任务目录**:upload 型由 `moveAttachmentsIn` 落盘到 `<任务目录>/attachments/<文件名>`
337
+ * 并把 ref 记成 `attachments/<文件名>` ⇒ **直接拼接**即可。
338
+ *
339
+ * ⚠️ 2026-09-30 真机修:此前把 `attachments/` 前缀**剥掉**再拼(拼成 `<任务目录>/<文件名>`,少一层),
340
+ * 而写入侧与「已在任务目录」判断(`moveAttachmentsIn`)用的都是**带 `attachments/` 的**基准 ⇒
341
+ * **任何带上传附件的任务恒被判「附件不存在」**:Loop A 不建实例行(`scheduler` 校验后 continue 在
342
+ * `ensureInstance` 之前)、Loop B 不发动(`reconcile` 兜底同款)⇒ 执行记录里一条都没有、任务永不执行。
343
+ */
344
+ export function attachmentAbsPath(paths, taskId, ref) {
345
+ return join(taskDirOf(paths, taskId), ref);
346
+ }
347
+ // ─────────────────────── 清理与删除 ───────────────────────
348
+ /** 清上传临时区:删掉 mtime 早于 N 天前的文件(跨天调用一次即可)。 */
349
+ export function purgeTmp(paths, days) {
350
+ const dir = paths.tmpDir;
351
+ if (!existsSync(dir))
352
+ return 0;
353
+ const cutoff = Date.now() - days * 86_400_000;
354
+ let removed = 0;
355
+ for (const name of readdirSync(dir)) {
356
+ const target = join(dir, name);
357
+ try {
358
+ const st = statSync(target);
359
+ if (!st.isFile() || st.mtimeMs >= cutoff)
360
+ continue;
361
+ rmSync(target);
362
+ removed++;
363
+ }
364
+ catch { /* 单个删不掉不影响整体 */ }
365
+ }
366
+ return removed;
367
+ }
368
+ /** 删除任务的整个目录(附件 + 版本 + 快照一起)。实例 / 事件保留在库里做审计。 */
369
+ export function deleteTaskAssets(paths, taskId) {
370
+ const dir = taskDirOf(paths, taskId);
371
+ if (!existsSync(dir))
372
+ return false;
373
+ try {
374
+ rmSync(dir, { recursive: true, force: true });
375
+ return true;
376
+ }
377
+ catch {
378
+ return false;
379
+ }
380
+ }
@@ -0,0 +1,27 @@
1
+ /** 排序只需要这几个字段(`TaskOverviewRow` 结构兼容)。 */
2
+ export interface SortableRow {
3
+ id: string;
4
+ enabled: boolean;
5
+ running: boolean;
6
+ nextSlotAt: string | null;
7
+ lastScheduledAt: string | null;
8
+ runningSince: string | null;
9
+ }
10
+ /** 分组:运行中 0 → 已启用 1 → 无刻度 2 → 已关闭 3。 */
11
+ export declare const groupOf: (row: SortableRow) => number;
12
+ /**
13
+ * 组 1 的组内键:**按下次执行升序**。
14
+ * ⚠️ 决策 54:原来的「到点钳位哨兵 -1」(把刚跨过刻度的卡强插到组内最前)**已整删** ——
15
+ * 服务端会**冻结**「已到点但还没处理」的刻度,于是这类行的 `nextSlotAt` 本身就是**过去时刻**,
16
+ * 在这里**自然排最前**,不需要任何哨兵。
17
+ */
18
+ export declare const sortKeyOf: (row: SortableRow) => number;
19
+ /** 排序(时间轴:马上要跑的最上,关闭的沉底)。 */
20
+ export declare function sortRows<T extends SortableRow>(rows: readonly T[]): T[];
21
+ /**
22
+ * **派发延迟预算**(毫秒)= 巡检间隔 + 2×轮询,夹在 30s ~ 10min。
23
+ * 2026-09-30(决策 54):它原本是「到点钳位」的时长;钳位已删,本函数保留为**同一口径的唯一出处** ——
24
+ * 客户端「到点未派发」的 loading 上界(`client/task-list.tsx` 的 `dueLoadingMs`)与它同源,
25
+ * 免得将来两处各写一个魔数悄悄漂移(执行后评审点过这一条)。
26
+ */
27
+ export declare function pinMsFor(tickMs: number, pollMs: number): number;
@@ -0,0 +1,59 @@
1
+ // task-sort.ts — 主界面卡片的排序(+ 派发延迟预算的唯一出处)。
2
+ //
3
+ // **为什么放 `src/` 而不是 `src/client/`**:这里既是客户端排序的**唯一实现**,又要能被冒烟
4
+ // (Node 脚本,直接引 dist 产物)**真断言**——放 client 侧只会被打进 client bundle、冒烟只能 grep 字符串。
5
+ // 与 `attachment-allowlist.ts` 同款:宿主侧 tsc 直编、客户端 alwaysBundle 内联,两边共用一份。
6
+ //
7
+ // **排序规则(用户 2026-09-30 口径)**:还要执行的排最前(按下次执行升序)→ 没有下次执行的排后 →
8
+ // 停用的(锁定)沉底;「正在跑」永远在最上。
9
+ //
10
+ // **⚠️ 历史:这里原来还有一套「到点钳位」**(客户端本地计时器:记住上一版 `nextSlotAt`,刚跨过刻度就
11
+ // 在组内钉到最前一段),用来掩盖「刻度懒前移 ⇒ 键变大 ⇒ 卡片掉下去,等 `running` 翻转又跳回来」的抖动。
12
+ // **决策 54 已把整套钳位删除**,因为那套**本地派生状态**在轮询卡住时永不解开(真机「卡片 5 分钟不动、
13
+ // 倒计时照跳」的根因)。抖动改由**服务端**根治:`runtime-index.overview` 给「刻度过期」的前移加了
14
+ // **「这一槽处理了吗」闸门** —— 未处理且仍在 `window` 内就**冻结不前移**,于是「到点还没跑」的行
15
+ // `nextSlotAt` 本身就是**过去时刻**,在本文件「按下次执行升序」里**自然排最前**,不需要任何哨兵。
16
+ // (详见 `docs/design/decisions.md` 决策 54、`docs/worklog/main-panel.md` §15。)
17
+ /** 分组:运行中 0 → 已启用 1 → 无刻度 2 → 已关闭 3。 */
18
+ export const groupOf = (row) => {
19
+ if (row.running)
20
+ return 0;
21
+ if (!row.enabled)
22
+ return 3;
23
+ return row.nextSlotAt === null ? 2 : 1;
24
+ };
25
+ /**
26
+ * 组 1 的组内键:**按下次执行升序**。
27
+ * ⚠️ 决策 54:原来的「到点钳位哨兵 -1」(把刚跨过刻度的卡强插到组内最前)**已整删** ——
28
+ * 服务端会**冻结**「已到点但还没处理」的刻度,于是这类行的 `nextSlotAt` 本身就是**过去时刻**,
29
+ * 在这里**自然排最前**,不需要任何哨兵。
30
+ */
31
+ export const sortKeyOf = (row) => {
32
+ if (row.nextSlotAt === null)
33
+ return Number.POSITIVE_INFINITY;
34
+ return Date.parse(row.nextSlotAt);
35
+ };
36
+ /** 排序(时间轴:马上要跑的最上,关闭的沉底)。 */
37
+ export function sortRows(rows) {
38
+ return [...rows].sort((a, b) => {
39
+ const ga = groupOf(a);
40
+ const gb = groupOf(b);
41
+ if (ga !== gb)
42
+ return ga - gb;
43
+ if (ga === 1)
44
+ return sortKeyOf(a) - sortKeyOf(b);
45
+ const ta = Date.parse(a.lastScheduledAt ?? a.runningSince ?? '') || 0;
46
+ const tb = Date.parse(b.lastScheduledAt ?? b.runningSince ?? '') || 0;
47
+ return tb - ta;
48
+ });
49
+ }
50
+ /**
51
+ * **派发延迟预算**(毫秒)= 巡检间隔 + 2×轮询,夹在 30s ~ 10min。
52
+ * 2026-09-30(决策 54):它原本是「到点钳位」的时长;钳位已删,本函数保留为**同一口径的唯一出处** ——
53
+ * 客户端「到点未派发」的 loading 上界(`client/task-list.tsx` 的 `dueLoadingMs`)与它同源,
54
+ * 免得将来两处各写一个魔数悄悄漂移(执行后评审点过这一条)。
55
+ */
56
+ export function pinMsFor(tickMs, pollMs) {
57
+ const base = Number.isFinite(tickMs) && tickMs > 0 ? tickMs : 60_000;
58
+ return Math.min(10 * 60_000, Math.max(30_000, base + 2 * pollMs));
59
+ }