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.
- package/README.md +95 -31
- package/README.zh.md +113 -0
- package/assets/icon-scheduler.svg +1 -0
- package/cordis.patch.yml +9 -0
- package/dist/attachment-allowlist.d.ts +24 -0
- package/dist/attachment-allowlist.js +41 -0
- package/dist/calendar-plan.d.ts +55 -0
- package/dist/calendar-plan.js +104 -0
- package/dist/client.js +72655 -0
- package/dist/client.js.map +1 -0
- package/dist/config.d.ts +79 -0
- package/dist/config.js +80 -0
- package/dist/deps.d.ts +25 -0
- package/dist/deps.js +54 -0
- package/dist/dispatch.d.ts +133 -0
- package/dist/dispatch.js +509 -0
- package/dist/event-bus.d.ts +16 -0
- package/dist/event-bus.js +67 -0
- package/dist/event-catalog.d.ts +42 -0
- package/dist/event-catalog.js +60 -0
- package/dist/host.d.ts +299 -0
- package/dist/host.js +1 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +0 -0
- package/dist/receipt.d.ts +38 -0
- package/dist/receipt.js +236 -0
- package/dist/reconcile.d.ts +105 -0
- package/dist/reconcile.js +641 -0
- package/dist/runtime-index.d.ts +102 -0
- package/dist/runtime-index.js +340 -0
- package/dist/schedule-next.d.ts +44 -0
- package/dist/schedule-next.js +216 -0
- package/dist/scheduler.d.ts +82 -0
- package/dist/scheduler.js +639 -0
- package/dist/store.d.ts +461 -0
- package/dist/store.js +845 -0
- package/dist/submit.d.ts +1 -0
- package/dist/submit.js +60 -0
- package/dist/task-assets.d.ts +124 -0
- package/dist/task-assets.js +380 -0
- package/dist/task-sort.d.ts +27 -0
- package/dist/task-sort.js +59 -0
- package/dist/tasks.d.ts +191 -0
- package/dist/tasks.js +527 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +67 -6
- package/index.js +0 -7
package/dist/store.js
ADDED
|
@@ -0,0 +1,845 @@
|
|
|
1
|
+
// SQLite 状态库:两表 DDL 照抄 data-model.md;状态机 7 态载体(state-machine.md)。
|
|
2
|
+
// Node >= 22.5 内置 node:sqlite(宿主 engines ^22.19.0 || >=24.0.0),零原生依赖。
|
|
3
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { mkdirSync } from 'node:fs';
|
|
6
|
+
import { dirname } from 'node:path';
|
|
7
|
+
import { parseResolvedDeps } from './deps.js';
|
|
8
|
+
export const TABLE_FILTER_OPS = ['=', '!=', '<', '>', '<=', '>=', 'LIKE'];
|
|
9
|
+
export const PERMISSION_MODES = ['default', 'readOnly', 'workspace', 'full'];
|
|
10
|
+
export const TERMINAL_STATUSES = ['succeeded', 'failed', 'skipped'];
|
|
11
|
+
const NON_TERMINAL_STATUSES = ['pending', 'dispatched', 'running', 'unknown'];
|
|
12
|
+
/** 解析快照里的附件清单:形状不对的条目丢弃(不猜)。 */
|
|
13
|
+
function parseSnapshotAttachments(raw) {
|
|
14
|
+
if (!Array.isArray(raw))
|
|
15
|
+
return undefined;
|
|
16
|
+
const out = [];
|
|
17
|
+
for (const item of raw) {
|
|
18
|
+
if (typeof item !== 'object' || item === null)
|
|
19
|
+
continue;
|
|
20
|
+
const a = item;
|
|
21
|
+
if (typeof a.name !== 'string' || typeof a.ref !== 'string')
|
|
22
|
+
continue;
|
|
23
|
+
if (a.kind !== 'link' && a.kind !== 'upload')
|
|
24
|
+
continue;
|
|
25
|
+
out.push({
|
|
26
|
+
name: a.name,
|
|
27
|
+
kind: a.kind,
|
|
28
|
+
ref: a.ref,
|
|
29
|
+
...(typeof a.workspace === 'string' ? { workspace: a.workspace } : {}),
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
/** 解析实例行的快照 JSON;空 / 坏 JSON / 形状不对返回 undefined(调用方走兜底)。 */
|
|
35
|
+
export function parseInstanceSnapshot(raw) {
|
|
36
|
+
if (raw === null || raw === '')
|
|
37
|
+
return undefined;
|
|
38
|
+
try {
|
|
39
|
+
const value = JSON.parse(raw);
|
|
40
|
+
if (typeof value !== 'object' || value === null)
|
|
41
|
+
return undefined;
|
|
42
|
+
const s = value;
|
|
43
|
+
if (typeof s.title !== 'string' || typeof s.prompt !== 'string' || typeof s.workspacePath !== 'string')
|
|
44
|
+
return undefined;
|
|
45
|
+
if (!Array.isArray(s.validStatuses))
|
|
46
|
+
return undefined;
|
|
47
|
+
const resolvedDeps = parseResolvedDeps(s.resolvedDeps);
|
|
48
|
+
// 附件解析一次;`[]` 也保留字段——「附件从有到无」的快照仍带空清单,Loop B 才能如实校验(评审 P2#14)。
|
|
49
|
+
const parsedAttachments = parseSnapshotAttachments(s.attachments);
|
|
50
|
+
return {
|
|
51
|
+
title: s.title,
|
|
52
|
+
prompt: s.prompt,
|
|
53
|
+
manual: typeof s.manual === 'string' ? s.manual : null,
|
|
54
|
+
workspacePath: s.workspacePath,
|
|
55
|
+
provider: typeof s.provider === 'string' ? s.provider : '',
|
|
56
|
+
model: typeof s.model === 'string' ? s.model : '',
|
|
57
|
+
validStatuses: s.validStatuses.filter((x) => typeof x === 'string'),
|
|
58
|
+
maxAttempts: typeof s.maxAttempts === 'number' && Number.isInteger(s.maxAttempts) && s.maxAttempts >= 1 ? s.maxAttempts : 1,
|
|
59
|
+
window: typeof s.window === 'string' ? s.window : 'PT0S',
|
|
60
|
+
// goal / agentTeam / permission(决策 48 / 49 / 50):JSON 里没有 ⇒ undefined = 各自缺省
|
|
61
|
+
// (goal 开 / team 关 / permission 会话默认);非法档位一律丢回缺省,不猜。
|
|
62
|
+
...(typeof s.goal === 'boolean' ? { goal: s.goal } : {}),
|
|
63
|
+
...(typeof s.agentTeam === 'boolean' ? { agentTeam: s.agentTeam } : {}),
|
|
64
|
+
...(PERMISSION_MODES.includes(s.permission) ? { permission: s.permission } : {}),
|
|
65
|
+
...(resolvedDeps === undefined ? {} : { resolvedDeps }),
|
|
66
|
+
...(parsedAttachments === undefined ? {} : { attachments: parsedAttachments }),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const DDL = `
|
|
74
|
+
-- 执行记录表:一行 = **一次执行(一个计划刻度)**。
|
|
75
|
+
-- (决策 25 修订版:任务 id 直接写在用户的任务定义 JSON 里,**不再有 task_defs 登记表**——
|
|
76
|
+
-- 按位置或内容指纹去对应,都会在「删第一条 / 调顺序」时串号;id 跟着那条定义走才是对的。)
|
|
77
|
+
-- id = UUID 不透明主键(决策 25:自增在客户端 / 重装环境下不可靠);
|
|
78
|
+
-- (task_id, scheduled_at) 唯一 = 防重闸门 ⇒ tick 幂等,同一刻度插不进第二条。
|
|
79
|
+
CREATE TABLE IF NOT EXISTS task_instances (
|
|
80
|
+
id TEXT PRIMARY KEY,
|
|
81
|
+
task_id TEXT NOT NULL,
|
|
82
|
+
logical_date TEXT NOT NULL,
|
|
83
|
+
scheduled_at TEXT NOT NULL,
|
|
84
|
+
status TEXT NOT NULL CHECK (status IN
|
|
85
|
+
('pending','dispatched','running','succeeded','failed','skipped','unknown')),
|
|
86
|
+
attempt INTEGER NOT NULL DEFAULT 0,
|
|
87
|
+
-- 触发来源(2026-10-03):'scheduled' 自动调度 / 'manual' 手动立即执行;旧库补列为 NULL。
|
|
88
|
+
run_type TEXT,
|
|
89
|
+
session_id TEXT,
|
|
90
|
+
lease_until TEXT,
|
|
91
|
+
dispatched_at TEXT,
|
|
92
|
+
finished_at TEXT,
|
|
93
|
+
outputs TEXT,
|
|
94
|
+
token_in INTEGER,
|
|
95
|
+
token_out INTEGER,
|
|
96
|
+
token_in_cache INTEGER,
|
|
97
|
+
snapshot TEXT,
|
|
98
|
+
updated_at TEXT NOT NULL
|
|
99
|
+
);
|
|
100
|
+
CREATE TABLE IF NOT EXISTS task_events (
|
|
101
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
102
|
+
instance_id TEXT NOT NULL,
|
|
103
|
+
ts TEXT NOT NULL,
|
|
104
|
+
kind TEXT NOT NULL,
|
|
105
|
+
detail TEXT
|
|
106
|
+
);
|
|
107
|
+
CREATE INDEX IF NOT EXISTS idx_events_instance ON task_events(instance_id, seq);
|
|
108
|
+
-- 诊断日志表(决策 31/32):只收「未推进到执行那一步」的诊断——错过刻度 / 启动汇总 /
|
|
109
|
+
-- 预条件失败 / 依赖卡顿 / 崩溃残留 pending。与 task_instances 分离,可定时清除。
|
|
110
|
+
CREATE TABLE IF NOT EXISTS task_log (
|
|
111
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
112
|
+
ts TEXT NOT NULL,
|
|
113
|
+
task_id TEXT,
|
|
114
|
+
scheduled_at TEXT,
|
|
115
|
+
level TEXT NOT NULL,
|
|
116
|
+
kind TEXT NOT NULL,
|
|
117
|
+
message TEXT NOT NULL
|
|
118
|
+
);
|
|
119
|
+
CREATE INDEX IF NOT EXISTS idx_task_log_ts ON task_log(ts);
|
|
120
|
+
-- 元数据表:跨重启 / 重装必须存活的插件级键值(内嵌任务表 tasksInline 等)。
|
|
121
|
+
-- state.db 在宿主数据根(挂载卷)⇒ 容器重建、插件重装都不丢;entry config 做不到这点。
|
|
122
|
+
CREATE TABLE IF NOT EXISTS meta (
|
|
123
|
+
key TEXT PRIMARY KEY,
|
|
124
|
+
value TEXT NOT NULL
|
|
125
|
+
);
|
|
126
|
+
-- 操作审计表(2026-09-30):新增 / 修改 / 删除任务、版本留档、版本找回、附件增删
|
|
127
|
+
-- 全部留痕 ⇒ 事后能回答「这个任务什么时候被改成什么样」。
|
|
128
|
+
-- 与 task_log(诊断,30 天清)分开:**默认不清**(design/data-model.md §六)。
|
|
129
|
+
CREATE TABLE IF NOT EXISTS task_audit (
|
|
130
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
131
|
+
ts TEXT NOT NULL,
|
|
132
|
+
task_id TEXT,
|
|
133
|
+
action TEXT NOT NULL,
|
|
134
|
+
detail TEXT
|
|
135
|
+
);
|
|
136
|
+
CREATE INDEX IF NOT EXISTS idx_audit_task ON task_audit(task_id, seq);
|
|
137
|
+
|
|
138
|
+
-- ⚠️ **插件整体日志表**(2026-10-07):与上面所有 task_* 表**维度不同** ——
|
|
139
|
+
-- - task_* 回答「某个任务 / 某次执行怎么了」,都挂 task_id / instance_id;
|
|
140
|
+
-- - 本表回答「插件这个进程怎么了」:启动 / 停止、宿主能力缺失、HTTP 通道异常、
|
|
141
|
+
-- 推送连接生命周期、主线程被同步重活占住、慢请求……**这些不挂任何任务**。
|
|
142
|
+
-- 此前曾把这些塞进 task_log(kind='diag'),属**用错表**:task_log 的契约是
|
|
143
|
+
-- 「只收未推进到执行那一步的任务诊断」,kind 是一组明确枚举(design/data-model.md §二)。
|
|
144
|
+
-- 塞进去既越界、又成为「按任务看日志」视图里的孤儿行。**独立成表**(design/data-model.md §六)。
|
|
145
|
+
-- 保留策略同 task_log:logRetentionDays(默认 30 天),tick 内跨天清。
|
|
146
|
+
-- ⚠️ 本段在 TS 模板字符串内 ⇒ 注释里**不许出现反引号**(会提前终止字符串)。
|
|
147
|
+
CREATE TABLE IF NOT EXISTS plugin_log (
|
|
148
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
149
|
+
ts TEXT NOT NULL,
|
|
150
|
+
level TEXT NOT NULL, -- info | warn | error
|
|
151
|
+
kind TEXT NOT NULL, -- startup | shutdown | degraded | block | slow_request | route_error | stream_*
|
|
152
|
+
message TEXT NOT NULL
|
|
153
|
+
);
|
|
154
|
+
CREATE INDEX IF NOT EXISTS idx_plugin_log_ts ON plugin_log(ts);
|
|
155
|
+
`;
|
|
156
|
+
const nowIso = () => new Date().toISOString();
|
|
157
|
+
// ── 按任务 / 工作区过滤 + 游标分页(任务卡片三面板 + 未来总查询页共用,design/features/task-expand-panels.md §四)──
|
|
158
|
+
// cursor 用 base64(JSON) 编码「排序键末行」:小面板只传 limit 取最新 N;未来总页面带 cursor 即翻页。一套实现两种用法。
|
|
159
|
+
function encodeCursor(values) {
|
|
160
|
+
return Buffer.from(JSON.stringify(values)).toString('base64');
|
|
161
|
+
}
|
|
162
|
+
function decodeCursor(raw) {
|
|
163
|
+
try {
|
|
164
|
+
const value = JSON.parse(Buffer.from(raw, 'base64').toString('utf8'));
|
|
165
|
+
return Array.isArray(value) ? value : null;
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
export class TaskStore {
|
|
172
|
+
db;
|
|
173
|
+
/** 事务嵌套深度(`transaction` 用):> 0 = 已在事务里 ⇒ 内层并入外层,不再 BEGIN。 */
|
|
174
|
+
txDepth = 0;
|
|
175
|
+
/**
|
|
176
|
+
* 迁移时因「同任务同刻度重复」被合并掉的行数。
|
|
177
|
+
* > 0 说明历史数据里存在重复(正常不该有),宿主会打告警——**绝不静默删数据**。
|
|
178
|
+
*/
|
|
179
|
+
dupRowsRemoved = 0;
|
|
180
|
+
constructor(statePath) {
|
|
181
|
+
mkdirSync(dirname(statePath), { recursive: true });
|
|
182
|
+
this.db = new DatabaseSync(statePath);
|
|
183
|
+
this.db.exec('PRAGMA journal_mode = WAL;');
|
|
184
|
+
// 等锁 5s 再抛(2026-09-30):`transaction()` 用 `BEGIN IMMEDIATE` 立刻取写锁,而 CLI 回执通道
|
|
185
|
+
// (submit.ts 的 busy_timeout)可能同时在写同一库 ⇒ 没有这道兜底会偶发 SQLITE_BUSY 打断整个 tick。
|
|
186
|
+
this.db.exec('PRAGMA busy_timeout = 5000;');
|
|
187
|
+
this.db.exec(DDL);
|
|
188
|
+
this.migrate();
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* 旧库迁移(决策 25 + 决策 32):
|
|
192
|
+
* - 首次:去重(同一 task + 同一刻度只留 rowid 最小那条)后补建 `(task_id, scheduled_at)`
|
|
193
|
+
* 唯一索引作为防重闸门;
|
|
194
|
+
* - 已迁移过的库:仅补决策 32 冗余字段(outputs / tokens 列),不重复去重。
|
|
195
|
+
*/
|
|
196
|
+
migrate() {
|
|
197
|
+
const exists = this.db
|
|
198
|
+
.prepare(`SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'idx_instances_slot'`)
|
|
199
|
+
.get();
|
|
200
|
+
if (exists !== undefined) {
|
|
201
|
+
this.ensureInstanceColumns();
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
// 先数一数真有重复没有:正常库应该是 0,非 0 说明历史数据脏,交给宿主告警。
|
|
205
|
+
const dup = this.db
|
|
206
|
+
.prepare(`SELECT COUNT(*) AS n FROM (
|
|
207
|
+
SELECT 1 FROM task_instances GROUP BY task_id, scheduled_at HAVING COUNT(*) > 1)`)
|
|
208
|
+
.get();
|
|
209
|
+
this.dupRowsRemoved = Number(dup.n);
|
|
210
|
+
this.db.exec(`DELETE FROM task_instances WHERE rowid NOT IN
|
|
211
|
+
(SELECT MIN(rowid) FROM task_instances GROUP BY task_id, scheduled_at)`);
|
|
212
|
+
this.db.exec('CREATE UNIQUE INDEX idx_instances_slot ON task_instances(task_id, scheduled_at)');
|
|
213
|
+
this.ensureInstanceColumns();
|
|
214
|
+
}
|
|
215
|
+
/** 决策 32(修订):兼容旧库(无 outputs / token 三拆列)。 */
|
|
216
|
+
ensureInstanceColumns() {
|
|
217
|
+
const cols = new Set(this.db.prepare('PRAGMA table_info(task_instances)').all().map(c => c.name));
|
|
218
|
+
if (!cols.has('outputs'))
|
|
219
|
+
this.db.exec('ALTER TABLE task_instances ADD COLUMN outputs TEXT');
|
|
220
|
+
// 旧版决策 32 曾用单个 tokens 列;拆成三列后卸下旧列(不支持 DROP COLUMN 的旧 SQLite 静默跳过)。
|
|
221
|
+
if (cols.has('tokens')) {
|
|
222
|
+
try {
|
|
223
|
+
this.db.exec('ALTER TABLE task_instances DROP COLUMN tokens');
|
|
224
|
+
}
|
|
225
|
+
catch { /* 旧引擎不支持 DROP COLUMN,留作死列无害 */ }
|
|
226
|
+
}
|
|
227
|
+
if (!cols.has('token_in'))
|
|
228
|
+
this.db.exec('ALTER TABLE task_instances ADD COLUMN token_in INTEGER');
|
|
229
|
+
if (!cols.has('token_out'))
|
|
230
|
+
this.db.exec('ALTER TABLE task_instances ADD COLUMN token_out INTEGER');
|
|
231
|
+
if (!cols.has('token_in_cache'))
|
|
232
|
+
this.db.exec('ALTER TABLE task_instances ADD COLUMN token_in_cache INTEGER');
|
|
233
|
+
// 决策 41:派发快照列。旧行为 NULL ⇒ 对账走 legacyTask 回退(读一次任务表当场补快照,不静默)。
|
|
234
|
+
if (!cols.has('snapshot'))
|
|
235
|
+
this.db.exec('ALTER TABLE task_instances ADD COLUMN snapshot TEXT');
|
|
236
|
+
// 2026-10-03:触发来源列(自动调度 / 手动「立即执行」)。旧库补列;旧行留 NULL(不回填、不猜)。
|
|
237
|
+
if (!cols.has('run_type'))
|
|
238
|
+
this.db.exec('ALTER TABLE task_instances ADD COLUMN run_type TEXT');
|
|
239
|
+
}
|
|
240
|
+
close() {
|
|
241
|
+
this.db.close();
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* 把多次写库合成一个**原子单元**(2026-09-30 复核 P2):`ensureSkipped` → `appendEvent`(→ `appendLog`)
|
|
245
|
+
* 原先是两/三步独立写,中途被杀会留下**「执行记录里有这条未执行、展开却看不到为什么」**的半截记录。
|
|
246
|
+
*
|
|
247
|
+
* - `node:sqlite` 的 `DatabaseSync` 没有事务包装,但 `exec('BEGIN'/'COMMIT'/'ROLLBACK')` 与
|
|
248
|
+
* `prepare(...).run()` 走**同一条连接** ⇒ 天然同事务。用 `BEGIN IMMEDIATE` 立刻取写锁,
|
|
249
|
+
* 避免「先读后写」在事务中途升级锁失败(配合构造里的 `busy_timeout = 5000`)。
|
|
250
|
+
* - **嵌套守卫**:SQLite 不支持嵌套 BEGIN(会抛 `cannot start a transaction within a transaction`)
|
|
251
|
+
* ⇒ `txDepth > 0` 时内层直接并入外层,原子性由**最外层**统一提交 / 回滚。
|
|
252
|
+
* - 异常一律 `ROLLBACK` 后**原样抛出**;`fn` 内返回 `false` 也算**正常结束** ⇒ 走 COMMIT,
|
|
253
|
+
* 否则连接会留在事务里、后续写被隐式吞进这个永远不提交的事务。
|
|
254
|
+
*/
|
|
255
|
+
transaction(fn) {
|
|
256
|
+
if (this.txDepth > 0)
|
|
257
|
+
return fn(); // 已在外层事务里 ⇒ 并入,不再 BEGIN
|
|
258
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
259
|
+
this.txDepth++;
|
|
260
|
+
try {
|
|
261
|
+
const result = fn();
|
|
262
|
+
this.db.exec('COMMIT');
|
|
263
|
+
return result;
|
|
264
|
+
}
|
|
265
|
+
catch (error) {
|
|
266
|
+
try {
|
|
267
|
+
this.db.exec('ROLLBACK');
|
|
268
|
+
}
|
|
269
|
+
catch { /* 已自动回滚 / 连接已坏:保留原始错误 */ }
|
|
270
|
+
throw error;
|
|
271
|
+
}
|
|
272
|
+
finally {
|
|
273
|
+
this.txDepth--;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
appendEvent(instanceId, kind, detail) {
|
|
277
|
+
this.db
|
|
278
|
+
.prepare('INSERT INTO task_events (instance_id, ts, kind, detail) VALUES (?, ?, ?, ?)')
|
|
279
|
+
.run(instanceId, nowIso(), kind, detail === undefined ? null : JSON.stringify(detail));
|
|
280
|
+
}
|
|
281
|
+
/** 实例某类事件的最新一条(回执对账 / 追问判定用)。 */
|
|
282
|
+
latestEvent(instanceId, kind) {
|
|
283
|
+
return this.db
|
|
284
|
+
.prepare('SELECT ts, detail FROM task_events WHERE instance_id = ? AND kind = ? ORDER BY seq DESC LIMIT 1')
|
|
285
|
+
.get(instanceId, kind);
|
|
286
|
+
}
|
|
287
|
+
/** 实例某类事件计数(追问次数上限用)。 */
|
|
288
|
+
countEvents(instanceId, kind) {
|
|
289
|
+
const row = this.db
|
|
290
|
+
.prepare('SELECT COUNT(*) AS n FROM task_events WHERE instance_id = ? AND kind = ?')
|
|
291
|
+
.get(instanceId, kind);
|
|
292
|
+
return Number(row.n);
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* 调试面板快照(临时调试通道):全部实例 + 最近 N 条事件。
|
|
296
|
+
* 事件取 seq 倒序再反转 = 升序输出(旧→新,日志阅读顺序);detail 截断 200 字符防快照膨胀。
|
|
297
|
+
*/
|
|
298
|
+
snapshot(limitEvents = 40) {
|
|
299
|
+
const instances = this.db
|
|
300
|
+
.prepare('SELECT * FROM task_instances ORDER BY updated_at DESC')
|
|
301
|
+
.all();
|
|
302
|
+
const rows = this.db
|
|
303
|
+
.prepare('SELECT seq, instance_id, ts, kind, detail FROM task_events ORDER BY seq DESC LIMIT ?')
|
|
304
|
+
.all(limitEvents);
|
|
305
|
+
const events = rows
|
|
306
|
+
.reverse()
|
|
307
|
+
.map(row => ({
|
|
308
|
+
...row,
|
|
309
|
+
detail: row.detail !== null && row.detail.length > 200 ? `${row.detail.slice(0, 200)}…` : row.detail,
|
|
310
|
+
}));
|
|
311
|
+
return { instances, events };
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* 主界面运行态初始化(2026-09-30):**一条聚合 SQL** 取每任务最近一条**终态**实例。
|
|
315
|
+
* 只认终态(succeeded/failed/skipped/unknown)——「上次执行」= 最近一次**出了结果**的执行,
|
|
316
|
+
* 刚落库还在跑的不算(用户 2026-09-30:运行中不得覆盖上次执行)。
|
|
317
|
+
* SQLite 文档化行为:查询含 min/max 聚合时,其余裸列取**该聚合所在行**的值。
|
|
318
|
+
* 走 `idx_instances_slot(task_id, scheduled_at)`,绝不做「每任务一次查询」。
|
|
319
|
+
*/
|
|
320
|
+
lastRunByTask() {
|
|
321
|
+
const rows = this.db
|
|
322
|
+
.prepare(`SELECT task_id, MAX(scheduled_at) AS scheduled_at, status, finished_at
|
|
323
|
+
FROM task_instances
|
|
324
|
+
WHERE status NOT IN ('dispatched','running')
|
|
325
|
+
GROUP BY task_id`)
|
|
326
|
+
.all();
|
|
327
|
+
const out = new Map();
|
|
328
|
+
for (const row of rows) {
|
|
329
|
+
out.set(row.task_id, { status: row.status, scheduledAt: row.scheduled_at, finishedAt: row.finished_at });
|
|
330
|
+
}
|
|
331
|
+
return out;
|
|
332
|
+
}
|
|
333
|
+
/** 在飞行(dispatched / running)的实例 ⇒ 主界面「运行中」。同样一条聚合 SQL。 */
|
|
334
|
+
inFlightByTask() {
|
|
335
|
+
const rows = this.db
|
|
336
|
+
.prepare(`SELECT task_id, MIN(scheduled_at) AS scheduled_at FROM task_instances
|
|
337
|
+
WHERE status IN ('dispatched','running') GROUP BY task_id`)
|
|
338
|
+
.all();
|
|
339
|
+
const out = new Map();
|
|
340
|
+
for (const row of rows)
|
|
341
|
+
out.set(row.task_id, row.scheduled_at);
|
|
342
|
+
return out;
|
|
343
|
+
}
|
|
344
|
+
/** 晚于某时刻的最新回执事件(决策 19:回执对账按次取新,防止上一轮 attempt 的旧回执冒充)。 */
|
|
345
|
+
latestReceipt(instanceId, afterIso) {
|
|
346
|
+
return this.db
|
|
347
|
+
.prepare(`SELECT ts, detail FROM task_events
|
|
348
|
+
WHERE instance_id = ? AND kind = 'receipt' AND (? IS NULL OR ts > ?)
|
|
349
|
+
ORDER BY seq DESC LIMIT 1`)
|
|
350
|
+
.get(instanceId, afterIso ?? null, afterIso ?? null);
|
|
351
|
+
}
|
|
352
|
+
/** 启动扫描(state-machine §3 机制 #5):已派发而未定态的实例置 unknown。 */
|
|
353
|
+
startupScan() {
|
|
354
|
+
const result = this.db
|
|
355
|
+
.prepare(`UPDATE task_instances SET status = 'unknown', updated_at = ?
|
|
356
|
+
WHERE status IN ('dispatched','running')`)
|
|
357
|
+
.run(nowIso());
|
|
358
|
+
return Number(result.changes);
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* 幂等建一条实例(决策 31:懒建行,调用方先生成 id 并判定预条件通过后才调用)。
|
|
362
|
+
* **身份 = 任务 + 计划刻度**——去重走 `UNIQUE(task_id, scheduled_at)`,
|
|
363
|
+
* 同一刻度重复 INSERT 一律 DO NOTHING ⇒ tick 幂等。状态由调用方给定(现仅 'dispatched')。
|
|
364
|
+
* `snapshot`(决策 41):派发快照,Loop A 落库时一并固化;缺省(旧测试 / 手动 SQL)为 NULL。
|
|
365
|
+
*/
|
|
366
|
+
ensureInstance(id, taskId, logicalDate, scheduledAt, status, snapshot,
|
|
367
|
+
/** 触发来源(2026-10-03):缺省 'scheduled'(自动调度不传);手动「立即执行」传 'manual'。 */
|
|
368
|
+
runType = 'scheduled') {
|
|
369
|
+
const result = this.db
|
|
370
|
+
.prepare(`INSERT OR IGNORE INTO task_instances
|
|
371
|
+
(id, task_id, logical_date, scheduled_at, status, attempt, snapshot, run_type, updated_at)
|
|
372
|
+
VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?)`)
|
|
373
|
+
.run(id, taskId, logicalDate, scheduledAt, status, snapshot === undefined ? null : JSON.stringify(snapshot), runType, nowIso());
|
|
374
|
+
return Number(result.changes) > 0;
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* 补记一条「未执行」记录(决策 54):某个该跑的刻度**最终没跑**时,用它留一条痕迹
|
|
378
|
+
* (用户:不能只在日志里,执行记录里必须看得见)。
|
|
379
|
+
*
|
|
380
|
+
* 身份同样是「任务 + 计划刻度」(唯一索引保证同一槽只写一条、天然幂等),但状态固定 `skipped`:
|
|
381
|
+
* - `skipped` 属**终态** ⇒ Loop B `sweep` 只遍历 pending/dispatched/running/unknown,**永不重试**;
|
|
382
|
+
* `startupScan` 只改 dispatched/running,也碰不到它。
|
|
383
|
+
* - **绝不能用 `unknown`/`pending`**:那会被重试逻辑真的拉起来执行,或悄悄变成 failed。
|
|
384
|
+
*
|
|
385
|
+
* ⚠️ `scheduledAt` 必须是被漏掉那一槽的**真实时刻**:若用「当前槽」会撞当前槽真实执行行的唯一键,
|
|
386
|
+
* `INSERT OR IGNORE` 静默丢弃 ⇒ **任务永久不再执行**。
|
|
387
|
+
* 原因(为什么没跑)走 `appendEvent` / `appendLog`——本表没有 message 列。
|
|
388
|
+
*/
|
|
389
|
+
ensureSkipped(id, taskId, logicalDate, scheduledAt) {
|
|
390
|
+
const now = nowIso();
|
|
391
|
+
const result = this.db
|
|
392
|
+
.prepare(`INSERT OR IGNORE INTO task_instances
|
|
393
|
+
(id, task_id, logical_date, scheduled_at, status, attempt, finished_at, updated_at)
|
|
394
|
+
VALUES (?, ?, ?, ?, 'skipped', 0, ?, ?)`)
|
|
395
|
+
.run(id, taskId, logicalDate, scheduledAt, now, now);
|
|
396
|
+
return Number(result.changes) > 0;
|
|
397
|
+
}
|
|
398
|
+
/** 旧实例补快照(决策 41 legacy 回退:首次被 Loop B 触到时按任务定义当场合成并固化)。 */
|
|
399
|
+
setSnapshot(id, snapshot) {
|
|
400
|
+
this.db
|
|
401
|
+
.prepare('UPDATE task_instances SET snapshot = ?, updated_at = ? WHERE id = ?')
|
|
402
|
+
.run(JSON.stringify(snapshot), nowIso(), id);
|
|
403
|
+
}
|
|
404
|
+
/** 删除一条实例(决策 31.6:窗口外残留 pending 直接删,视为未执行)。 */
|
|
405
|
+
deleteInstance(id) {
|
|
406
|
+
this.db.prepare('DELETE FROM task_instances WHERE id = ?').run(id);
|
|
407
|
+
}
|
|
408
|
+
/** 写入一条诊断日志(决策 31/32:未推进到执行那一步的诊断进 task_log,不污染 task_instances)。 */
|
|
409
|
+
appendLog(entry) {
|
|
410
|
+
this.db
|
|
411
|
+
.prepare('INSERT INTO task_log (ts, task_id, scheduled_at, level, kind, message) VALUES (?, ?, ?, ?, ?, ?)')
|
|
412
|
+
.run(nowIso(), entry.taskId ?? null, entry.scheduledAt ?? null, entry.level, entry.kind, entry.message);
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* **插件整体日志**(2026-10-07):进程级的运行 / 异常 / 性能观测进 `plugin_log`,
|
|
416
|
+
* **不进 `task_log`**(那张表的契约是「未推进到执行那一步的任务诊断」,见建表注释与
|
|
417
|
+
* design/data-model.md §二)。用途:事后回答「插件这个进程当时到底怎么了」。
|
|
418
|
+
*
|
|
419
|
+
* ⚠️ 下游**不要直接调这个方法**——请用下面三个语义化的 `logInfo` / `logWarn` / `logError`,
|
|
420
|
+
* 它们把 `level` 钉在方法名里,调用点只剩 `kind` 与文案,不会各处重复样板、也不会写错 level。
|
|
421
|
+
*/
|
|
422
|
+
appendPluginLog(entry) {
|
|
423
|
+
this.db
|
|
424
|
+
.prepare('INSERT INTO plugin_log (ts, level, kind, message) VALUES (?, ?, ?, ?)')
|
|
425
|
+
.run(nowIso(), entry.level, entry.kind, entry.message);
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* ⚠️ **记日志绝不能影响主流程**(2026-10-07 审计 🔴):这三个方法是**唯一推荐给下游**的入口,
|
|
429
|
+
* 必须自带异常隔离。否则:
|
|
430
|
+
* - `startup` 写入抛错 ⇒ 插件半初始化(定时器已种、dispose 未注册);
|
|
431
|
+
* - `shutdown` 写入抛错 ⇒ **后面的清理动作(退订 / 停定时器 / 关连接 / close 库)全都不执行**。
|
|
432
|
+
* 一条日志没记上,远比整个插件生命周期被卡住划算。
|
|
433
|
+
*/
|
|
434
|
+
logInfo(kind, message) {
|
|
435
|
+
this.safelyAppend('info', kind, message);
|
|
436
|
+
}
|
|
437
|
+
/** 记一条 warn 级插件日志(降级 / 观测告警:功能还在,但不如预期)。 */
|
|
438
|
+
logWarn(kind, message) {
|
|
439
|
+
this.safelyAppend('warn', kind, message);
|
|
440
|
+
}
|
|
441
|
+
/** 记一条 error 级插件日志(真的出错了)。 */
|
|
442
|
+
logError(kind, message) {
|
|
443
|
+
this.safelyAppend('error', kind, message);
|
|
444
|
+
}
|
|
445
|
+
/** 落库并吞掉一切异常(见 `logInfo` 的注释)。 */
|
|
446
|
+
safelyAppend(level, kind, message) {
|
|
447
|
+
try {
|
|
448
|
+
this.appendPluginLog({ level, kind, message });
|
|
449
|
+
}
|
|
450
|
+
catch {
|
|
451
|
+
// 库不可写时**只能**放弃这条日志:还有宿主 logger 那条路,且主流程必须继续。
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
/** 按保留期清除 `plugin_log`(与 `task_log` 同策略,默认 30 天)。返回删除条数。 */
|
|
455
|
+
purgePluginLog(retentionDays) {
|
|
456
|
+
const cutoff = new Date(Date.now() - retentionDays * 86_400_000).toISOString();
|
|
457
|
+
const result = this.db.prepare('DELETE FROM plugin_log WHERE ts < ?').run(cutoff);
|
|
458
|
+
return Number(result.changes);
|
|
459
|
+
}
|
|
460
|
+
/** 按保留期清除 task_log(决策 32:独立表,可定时清)。返回删除条数。 */
|
|
461
|
+
purgeLog(retentionDays) {
|
|
462
|
+
const cutoff = new Date(Date.now() - retentionDays * 86_400_000).toISOString();
|
|
463
|
+
const result = this.db.prepare('DELETE FROM task_log WHERE ts < ?').run(cutoff);
|
|
464
|
+
return Number(result.changes);
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* 操作审计留痕(2026-09-30):新增 / 修改 / 删除任务、版本留档 / 找回、附件增删都记一笔。
|
|
468
|
+
* 与 `task_log`(诊断)分开:审计**默认不清**,要能回答「这任务什么时候被改成什么样」。
|
|
469
|
+
*/
|
|
470
|
+
appendAudit(entry) {
|
|
471
|
+
this.db
|
|
472
|
+
.prepare('INSERT INTO task_audit (ts, task_id, action, detail) VALUES (?, ?, ?, ?)')
|
|
473
|
+
.run(nowIso(), entry.taskId ?? null, entry.action, entry.detail === undefined ? null : JSON.stringify(entry.detail));
|
|
474
|
+
}
|
|
475
|
+
/** 审计流水(某任务的最近 N 条,新的在前)。 */
|
|
476
|
+
listAudit(taskId, limit = 100) {
|
|
477
|
+
return this.db
|
|
478
|
+
.prepare('SELECT ts, action, detail FROM task_audit WHERE task_id = ? ORDER BY seq DESC LIMIT ?')
|
|
479
|
+
.all(taskId, limit);
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* ⚠️ **执行记录永久保留,没有清除方法**(用户 2026-10-08 拍板):历史是用来查的、不该被清,
|
|
483
|
+
* 原 `purgeHistory` 与 `historyRetentionDays` 配置一并删除。
|
|
484
|
+
*/
|
|
485
|
+
/** 完成瞬间写回产出与 token 三拆列(决策 32 修订:总表冗余,task_events 仍为真源)。 */
|
|
486
|
+
recordCompletion(id, outputs, tokenIn, tokenOut, tokenInCache) {
|
|
487
|
+
this.db
|
|
488
|
+
.prepare('UPDATE task_instances SET outputs = ?, token_in = ?, token_out = ?, token_in_cache = ?, updated_at = ? WHERE id = ?')
|
|
489
|
+
.run(outputs, tokenIn, tokenOut, tokenInCache, nowIso(), id);
|
|
490
|
+
}
|
|
491
|
+
/** 按「任务 + 刻度」查实例(手动排查 / 备用回执通道用,不依赖 id 形态)。 */
|
|
492
|
+
findBySlot(taskId, scheduledAt) {
|
|
493
|
+
return this.db
|
|
494
|
+
.prepare('SELECT * FROM task_instances WHERE task_id = ? AND scheduled_at = ?')
|
|
495
|
+
.get(taskId, scheduledAt);
|
|
496
|
+
}
|
|
497
|
+
get(id) {
|
|
498
|
+
return this.db.prepare('SELECT * FROM task_instances WHERE id = ?').get(id);
|
|
499
|
+
}
|
|
500
|
+
/** 读 meta 键值(无行返回 undefined——「从未写过」与「写过空串」借此区分)。 */
|
|
501
|
+
getMeta(key) {
|
|
502
|
+
const row = this.db.prepare('SELECT value FROM meta WHERE key = ?').get(key);
|
|
503
|
+
return row?.value;
|
|
504
|
+
}
|
|
505
|
+
/** 写 meta 键值(upsert)。任务表 tasksInline 的持久化主通道走这里。 */
|
|
506
|
+
setMeta(key, value) {
|
|
507
|
+
this.db
|
|
508
|
+
.prepare('INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
|
|
509
|
+
.run(key, value);
|
|
510
|
+
}
|
|
511
|
+
/** 调试 / 设置页查询允许的表名(SQLite 表名无法参数化,白名单防注入)。 */
|
|
512
|
+
static DUMP_TABLES = ['task_instances', 'task_events', 'task_log', 'task_audit', 'plugin_log', 'meta'];
|
|
513
|
+
/** 各表的「最新在前」默认排序列(设置页 Block 3 从简:每表按各自最新字段 DESC,不做排序选择器)。 */
|
|
514
|
+
static defaultOrder(name) {
|
|
515
|
+
switch (name) {
|
|
516
|
+
case 'task_events': return 'seq DESC';
|
|
517
|
+
case 'task_instances': return 'scheduled_at DESC, id DESC';
|
|
518
|
+
case 'task_log': return 'ts DESC, seq DESC';
|
|
519
|
+
case 'task_audit': return 'seq DESC';
|
|
520
|
+
case 'plugin_log': return 'ts DESC, seq DESC';
|
|
521
|
+
default: return 'key'; // meta:按 key 升序(DESC 无意义,保持原行为)
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
/** 表列缓存(白名单校验过滤列名,避免每请求打 PRAGMA)。 */
|
|
525
|
+
columnCache = new Map();
|
|
526
|
+
/** 取表列(建表顺序),带缓存。 */
|
|
527
|
+
columnsOf(name) {
|
|
528
|
+
let cols = this.columnCache.get(name);
|
|
529
|
+
if (cols === undefined) {
|
|
530
|
+
cols = this.db.prepare(`PRAGMA table_info(${name})`).all().map(c => c.name);
|
|
531
|
+
this.columnCache.set(name, cols);
|
|
532
|
+
}
|
|
533
|
+
return cols;
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* 单表通用查询(设置页 Block 3 / Block 2 共用):按白名单表 + 占位绑定过滤 + 默认最新字段 DESC + LIMIT。
|
|
537
|
+
* 不做服务端分页(用户 2026-10-08:top-N 自过滤、客户端自行筛选)。表名 / 列名 / 运算符全白名单,无注入面。
|
|
538
|
+
* @param name 表名(必须命中 `DUMP_TABLES`)。
|
|
539
|
+
* @param filters 过滤条件(列名须为该表真实列,运算符须为 `TABLE_FILTER_OPS`)。
|
|
540
|
+
* @param limit 最多返回行数(钳制 1..500)。
|
|
541
|
+
*/
|
|
542
|
+
queryTable(name, filters, limit) {
|
|
543
|
+
if (!TaskStore.DUMP_TABLES.includes(name))
|
|
544
|
+
throw new Error(`queryTable: unknown table ${name}`);
|
|
545
|
+
const columns = this.columnsOf(name);
|
|
546
|
+
const colSet = new Set(columns);
|
|
547
|
+
const where = [];
|
|
548
|
+
const params = [];
|
|
549
|
+
for (const f of filters) {
|
|
550
|
+
if (!colSet.has(f.column))
|
|
551
|
+
throw new Error(`queryTable: unknown column ${f.column}`);
|
|
552
|
+
if (!TABLE_FILTER_OPS.includes(f.op))
|
|
553
|
+
throw new Error(`queryTable: bad op ${f.op}`);
|
|
554
|
+
where.push(`${f.column} ${f.op} ?`);
|
|
555
|
+
params.push(f.value);
|
|
556
|
+
}
|
|
557
|
+
const whereSql = where.length > 0 ? ` WHERE ${where.join(' AND ')}` : '';
|
|
558
|
+
const count = this.db.prepare(`SELECT COUNT(*) AS n FROM ${name}${whereSql}`).get(...params).n;
|
|
559
|
+
const lim = Math.max(1, Math.min(limit, 500));
|
|
560
|
+
const rows = this.db.prepare(`SELECT * FROM ${name}${whereSql} ORDER BY ${TaskStore.defaultOrder(name)} LIMIT ?`).all(...params, lim);
|
|
561
|
+
return { name, count, columns, rows, truncated: count > rows.length };
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* 调试导出:整表原样读出(旧调试页用,保留兼容)。
|
|
565
|
+
* @param name - 表名(必须命中白名单)。
|
|
566
|
+
* @param limit - 最多返回行数;超出时保留「最新」的 limit 条。
|
|
567
|
+
*/
|
|
568
|
+
dumpTable(name, limit) {
|
|
569
|
+
if (!TaskStore.DUMP_TABLES.includes(name)) {
|
|
570
|
+
throw new Error(`dumpTable: unknown table ${name}`);
|
|
571
|
+
}
|
|
572
|
+
const columns = this.columnsOf(name);
|
|
573
|
+
const rows = this.db.prepare(`SELECT * FROM ${name} ORDER BY ${TaskStore.defaultOrder(name)} LIMIT ?`).all(limit);
|
|
574
|
+
return { name, count: this.db.prepare(`SELECT COUNT(*) AS n FROM ${name}`).get().n, columns, rows, truncated: false };
|
|
575
|
+
}
|
|
576
|
+
getBySession(sessionId) {
|
|
577
|
+
return this.db
|
|
578
|
+
.prepare('SELECT * FROM task_instances WHERE session_id = ? ORDER BY updated_at DESC')
|
|
579
|
+
.get(sessionId);
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* 计划重排(决策 20):计划时刻在「从未执行」前跟随配置 live 更新——仅 status=pending
|
|
583
|
+
* 且 attempt=0 可改,CAS 守卫防与派发竞态;执行一旦开始(attempt≥1)即冻结。
|
|
584
|
+
*/
|
|
585
|
+
reschedule(id, scheduledAt) {
|
|
586
|
+
// 目标刻度已被同任务的另一条实例占用(决策 25:一天可能多个刻度)⇒ 不再重排,返回 false。
|
|
587
|
+
// 没有这道判断,UPDATE 会直接撞 UNIQUE(task_id, scheduled_at) 把 tick 打挂。
|
|
588
|
+
const taken = this.db
|
|
589
|
+
.prepare(`SELECT 1 AS x FROM task_instances
|
|
590
|
+
WHERE scheduled_at = ? AND id != ?
|
|
591
|
+
AND task_id = (SELECT task_id FROM task_instances WHERE id = ?)`)
|
|
592
|
+
.get(scheduledAt, id, id);
|
|
593
|
+
if (taken !== undefined)
|
|
594
|
+
return false;
|
|
595
|
+
const result = this.db
|
|
596
|
+
.prepare(`UPDATE task_instances SET scheduled_at = ?, updated_at = ?
|
|
597
|
+
WHERE id = ? AND status = 'pending' AND attempt = 0`)
|
|
598
|
+
.run(scheduledAt, nowIso(), id);
|
|
599
|
+
return Number(result.changes) > 0;
|
|
600
|
+
}
|
|
601
|
+
listByStatus(statuses) {
|
|
602
|
+
const placeholders = statuses.map(() => '?').join(',');
|
|
603
|
+
return this.db
|
|
604
|
+
.prepare(`SELECT * FROM task_instances WHERE status IN (${placeholders}) ORDER BY scheduled_at`)
|
|
605
|
+
.all(...statuses);
|
|
606
|
+
}
|
|
607
|
+
/** 同任务非终态实例(排除某实例自身),用于同任务串行判定(state-machine §8)。 */
|
|
608
|
+
listNonTerminalOfTask(taskId, excludeId) {
|
|
609
|
+
return this.db
|
|
610
|
+
.prepare(`SELECT * FROM task_instances
|
|
611
|
+
WHERE task_id = ? AND status IN (${NON_TERMINAL_STATUSES.map(() => '?').join(',')})
|
|
612
|
+
AND id != COALESCE(?, '')
|
|
613
|
+
ORDER BY scheduled_at`)
|
|
614
|
+
.all(taskId, ...NON_TERMINAL_STATUSES, excludeId ?? null);
|
|
615
|
+
}
|
|
616
|
+
/** CAS 领取(data-model 关键设计 3):仅 pending 可领取,受影响行数判定成败。 */
|
|
617
|
+
casClaim(id) {
|
|
618
|
+
const now = nowIso();
|
|
619
|
+
const result = this.db
|
|
620
|
+
.prepare(`UPDATE task_instances
|
|
621
|
+
SET status = 'dispatched', dispatched_at = ?, updated_at = ?
|
|
622
|
+
WHERE id = ? AND status = 'pending'`)
|
|
623
|
+
.run(now, now, id);
|
|
624
|
+
if (Number(result.changes) > 0)
|
|
625
|
+
this.appendEvent(id, 'state_change', { to: 'dispatched', reason: 'cas-claim' });
|
|
626
|
+
return Number(result.changes) > 0;
|
|
627
|
+
}
|
|
628
|
+
/** 通用状态转移:无条件写(调用方按状态机自查前置态)并留 state_change 事件。 */
|
|
629
|
+
transition(id, input) {
|
|
630
|
+
const current = this.get(id);
|
|
631
|
+
if (current === undefined)
|
|
632
|
+
throw new Error(`实例不存在: ${id}`);
|
|
633
|
+
const now = nowIso();
|
|
634
|
+
this.db
|
|
635
|
+
.prepare(`UPDATE task_instances
|
|
636
|
+
SET status = ?, attempt = ?, session_id = ?, lease_until = ?,
|
|
637
|
+
dispatched_at = ?, finished_at = ?, updated_at = ?
|
|
638
|
+
WHERE id = ?`)
|
|
639
|
+
.run(input.status, input.attempt ?? current.attempt, input.session_id === undefined ? current.session_id : input.session_id, input.lease_until === undefined ? current.lease_until : input.lease_until, input.dispatched_at === undefined ? current.dispatched_at : input.dispatched_at, input.finished_at === undefined ? current.finished_at : input.finished_at, now, id);
|
|
640
|
+
this.appendEvent(id, 'state_change', { from: current.status, to: input.status, reason: input.detail });
|
|
641
|
+
}
|
|
642
|
+
/** running 心跳续租(state-machine §4):收到该会话任何事件即续租。 */
|
|
643
|
+
renewLease(id, leaseMs) {
|
|
644
|
+
this.db
|
|
645
|
+
.prepare(`UPDATE task_instances SET lease_until = ?, updated_at = ? WHERE id = ?`)
|
|
646
|
+
.run(new Date(Date.now() + leaseMs).toISOString(), nowIso(), id);
|
|
647
|
+
}
|
|
648
|
+
/** 依赖判定 same_period(state-machine §9):同 logical_date 的上游实例;一天多刻度取最新一条(决策 33)。 */
|
|
649
|
+
getSamePeriod(upstreamTaskId, logicalDate) {
|
|
650
|
+
return this.db
|
|
651
|
+
.prepare('SELECT * FROM task_instances WHERE task_id = ? AND logical_date = ? ORDER BY scheduled_at DESC LIMIT 1')
|
|
652
|
+
.get(upstreamTaskId, logicalDate);
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* 某任务的**最近一条**实例(不分状态,按 `scheduled_at` 倒序)——决策 33 判定用:
|
|
656
|
+
* 上游最近一条必须**正好是 `succeeded`** 才放行;在跑 / 失败 / 无记录 ⇒ 阻塞。
|
|
657
|
+
* ⚠️ 排序必须按 `scheduled_at`:原按 `logical_date` 只到「日」,同日多次取哪条不确定。
|
|
658
|
+
*/
|
|
659
|
+
getLatestInstance(taskId) {
|
|
660
|
+
return this.db
|
|
661
|
+
.prepare('SELECT * FROM task_instances WHERE task_id = ? ORDER BY scheduled_at DESC LIMIT 1')
|
|
662
|
+
.get(taskId);
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* 按任务 / 工作区 + 状态 / 时间过滤的执行记录(任务卡片「执行记录」面板 + 未来总查询页共用)。
|
|
666
|
+
* 排序 `scheduled_at DESC, id DESC`;`cursor` 编码末行 `(scheduled_at, id)`,`LIMIT limit+1` 判定是否还有下一页
|
|
667
|
+
* (limit+1 弹出一行 ⇒ 有剩余才给 cursor,恰好取尽时不会多翻一页)。全部条件走占位绑定,无注入面。
|
|
668
|
+
*/
|
|
669
|
+
listInstancesByQuery(q) {
|
|
670
|
+
const { where, params } = this.instanceWhere(q);
|
|
671
|
+
const limit = Math.max(1, Math.min(q.limit ?? 100, 500));
|
|
672
|
+
const sql = `SELECT * FROM task_instances${where === '' ? '' : ` WHERE ${where}`} ORDER BY scheduled_at DESC, id DESC LIMIT ?`;
|
|
673
|
+
const rows = this.db.prepare(sql).all(...params, limit + 1);
|
|
674
|
+
let nextCursor = null;
|
|
675
|
+
if (rows.length > limit) {
|
|
676
|
+
rows.pop();
|
|
677
|
+
const last = rows[rows.length - 1];
|
|
678
|
+
nextCursor = encodeCursor([last.scheduled_at, last.id]);
|
|
679
|
+
}
|
|
680
|
+
// 备注(用户 2026-10-02 第五轮):失败 / 跳过的原因写在 task_events(本表无 message 列)——
|
|
681
|
+
// 对本页 failed/skipped 行各取「最新一条原因类事件」,从 detail JSON 里提 reason / note。
|
|
682
|
+
this.attachNotes(rows);
|
|
683
|
+
return { rows, nextCursor };
|
|
684
|
+
}
|
|
685
|
+
/**
|
|
686
|
+
* 轻量版执行记录(「任务日程」日历页按月取数用,2026-10-05)。
|
|
687
|
+
*
|
|
688
|
+
* 与 `listInstancesByQuery` **同一套过滤条件**(都走 `instanceWhere`),区别只有两点:
|
|
689
|
+
* ① 列清单剔除 `snapshot`(见 `LiteTaskInstance`)—— 日历一次取满整月,不带几 KB 的大列;
|
|
690
|
+
* ② **不分页**,上限 3000,触及即 `truncated: true`(由页面提示收窄过滤,**不静默丢**)。
|
|
691
|
+
* ⚠️ 既有 `listInstancesByQuery` 的 500 上限与游标语义**一个字都没改**(记录页游标分页依赖它)。
|
|
692
|
+
*/
|
|
693
|
+
listInstancesLite(q) {
|
|
694
|
+
const { where, params } = this.instanceWhere(q);
|
|
695
|
+
const limit = Math.max(1, Math.min(q.limit ?? TaskStore.LITE_INSTANCE_LIMIT, TaskStore.LITE_INSTANCE_LIMIT));
|
|
696
|
+
const sql = `SELECT ${TaskStore.LITE_INSTANCE_COLUMNS} FROM task_instances${where === '' ? '' : ` WHERE ${where}`} ORDER BY scheduled_at DESC, id DESC LIMIT ?`;
|
|
697
|
+
const rows = this.db.prepare(sql).all(...params, limit + 1);
|
|
698
|
+
let truncated = false;
|
|
699
|
+
if (rows.length > limit) {
|
|
700
|
+
rows.pop();
|
|
701
|
+
truncated = true;
|
|
702
|
+
}
|
|
703
|
+
this.attachNotes(rows);
|
|
704
|
+
return { rows, truncated };
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* 执行记录过滤条件的**唯一构造**(全量查询与轻量查询共用 ⇒ SQL 条件不写第二遍)。
|
|
708
|
+
* 全部条件走占位绑定,无注入面。
|
|
709
|
+
*/
|
|
710
|
+
instanceWhere(q) {
|
|
711
|
+
const where = [];
|
|
712
|
+
const params = [];
|
|
713
|
+
if (q.taskId !== undefined) {
|
|
714
|
+
where.push('task_id = ?');
|
|
715
|
+
params.push(q.taskId);
|
|
716
|
+
}
|
|
717
|
+
if (q.sessionId !== undefined) {
|
|
718
|
+
where.push('session_id = ?');
|
|
719
|
+
params.push(q.sessionId);
|
|
720
|
+
}
|
|
721
|
+
if (q.taskIds !== undefined && q.taskIds.length > 0) {
|
|
722
|
+
where.push(`task_id IN (${q.taskIds.map(() => '?').join(',')})`);
|
|
723
|
+
params.push(...q.taskIds);
|
|
724
|
+
}
|
|
725
|
+
if (q.statuses !== undefined && q.statuses.length > 0) {
|
|
726
|
+
where.push(`status IN (${q.statuses.map(() => '?').join(',')})`);
|
|
727
|
+
params.push(...q.statuses);
|
|
728
|
+
}
|
|
729
|
+
if (q.fromTs !== undefined) {
|
|
730
|
+
where.push('scheduled_at >= ?');
|
|
731
|
+
params.push(q.fromTs);
|
|
732
|
+
}
|
|
733
|
+
if (q.toTs !== undefined) {
|
|
734
|
+
// 半开区间:`< to`(to = 结束日的**次日 00:00**,由客户端 ui/time-range.rangeToQuery 归一)。
|
|
735
|
+
// 不用 `<=` + 23:59:59.999 的补丁式含尾(见 design/features/task-expand-panels.md §3.11)。
|
|
736
|
+
where.push('scheduled_at < ?');
|
|
737
|
+
params.push(q.toTs);
|
|
738
|
+
}
|
|
739
|
+
const cursor = q.cursor === undefined ? null : decodeCursor(q.cursor);
|
|
740
|
+
if (cursor !== null && cursor.length === 2) {
|
|
741
|
+
// row-value 比较:DESC 序里「排在游标之后」= 键更小(决策 25 身份键做 tiebreaker,同刻度不重不漏)。
|
|
742
|
+
where.push('(scheduled_at, id) < (?, ?)');
|
|
743
|
+
params.push(String(cursor[0]), String(cursor[1]));
|
|
744
|
+
}
|
|
745
|
+
return { where: where.join(' AND '), params };
|
|
746
|
+
}
|
|
747
|
+
/** 轻量查询的**显式列清单**:与 `SELECT *` 的唯一差别是剔除 `snapshot`(见 `LiteTaskInstance`)。 */
|
|
748
|
+
static LITE_INSTANCE_COLUMNS = 'id, task_id, logical_date, scheduled_at, status, attempt, run_type, session_id, lease_until,'
|
|
749
|
+
+ ' dispatched_at, finished_at, outputs, token_in, token_out, token_in_cache, updated_at';
|
|
750
|
+
/** 轻量查询上限:比全量查询的 500 宽(轻量行约 200 B),够日历一次取满一个月的正常量级。 */
|
|
751
|
+
static LITE_INSTANCE_LIMIT = 3000;
|
|
752
|
+
/** 「原因类」事件 kind 白名单(写原因的只有这几类;receipt.note / *.reason)。 */
|
|
753
|
+
static NOTE_EVENT_KINDS = ['task-error', 'expired-once', 'missed-slot', 'receipt', 'no-receipt'];
|
|
754
|
+
/** detail JSON → 人话原因:receipt 取 note,其余取 reason;取不到回退原文。 */
|
|
755
|
+
static noteOfEvent(kind, detail) {
|
|
756
|
+
if (detail === null || detail === '')
|
|
757
|
+
return null;
|
|
758
|
+
try {
|
|
759
|
+
const parsed = JSON.parse(detail);
|
|
760
|
+
const text = kind === 'receipt' ? parsed.note : parsed.reason;
|
|
761
|
+
return typeof text === 'string' && text !== '' ? text : detail;
|
|
762
|
+
}
|
|
763
|
+
catch {
|
|
764
|
+
return detail;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
/**
|
|
768
|
+
* 给分页行就地填 note(只查 failed / skipped 行,一次 IN 查询取每实例最新原因事件)。
|
|
769
|
+
* 入参放宽成最小结构 ⇒ 全量行与轻量行(`LiteTaskInstance`,无 snapshot)都能走这一份实现。
|
|
770
|
+
*/
|
|
771
|
+
attachNotes(rows) {
|
|
772
|
+
const wanted = rows.filter(row => row.status === 'failed' || row.status === 'skipped').map(row => row.id);
|
|
773
|
+
if (wanted.length === 0)
|
|
774
|
+
return;
|
|
775
|
+
const marks = wanted.map(() => '?').join(',');
|
|
776
|
+
const events = this.db
|
|
777
|
+
.prepare(`SELECT instance_id, kind, detail, MAX(seq) AS seq FROM task_events
|
|
778
|
+
WHERE kind IN ('task-error','expired-once','missed-slot','receipt','no-receipt') AND instance_id IN (${marks})
|
|
779
|
+
GROUP BY instance_id`)
|
|
780
|
+
.all(...wanted);
|
|
781
|
+
const byId = new Map(events.map(event => [event.instance_id, TaskStore.noteOfEvent(event.kind, event.detail)]));
|
|
782
|
+
for (const row of rows) {
|
|
783
|
+
if (row.status === 'failed' || row.status === 'skipped')
|
|
784
|
+
row.note = byId.get(row.id) ?? null;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
/**
|
|
788
|
+
* 按任务 / 工作区 + 级别 / 关键字 / 时间过滤的诊断日志(任务卡片「日志」面板 + 未来总查询页共用)。
|
|
789
|
+
* 排序 `ts DESC, seq DESC`;`cursor` 编码末行 `(ts, seq)`。`keyword` 走 `LIKE %kw%`(参数化,不拼 SQL)。
|
|
790
|
+
*/
|
|
791
|
+
listLogsByQuery(q) {
|
|
792
|
+
const where = [];
|
|
793
|
+
const params = [];
|
|
794
|
+
if (q.taskId !== undefined) {
|
|
795
|
+
where.push('task_id = ?');
|
|
796
|
+
params.push(q.taskId);
|
|
797
|
+
}
|
|
798
|
+
if (q.taskIds !== undefined && q.taskIds.length > 0) {
|
|
799
|
+
where.push(`task_id IN (${q.taskIds.map(() => '?').join(',')})`);
|
|
800
|
+
params.push(...q.taskIds);
|
|
801
|
+
}
|
|
802
|
+
if (q.levels !== undefined && q.levels.length > 0) {
|
|
803
|
+
where.push(`level IN (${q.levels.map(() => '?').join(',')})`);
|
|
804
|
+
params.push(...q.levels);
|
|
805
|
+
}
|
|
806
|
+
if (q.keyword !== undefined && q.keyword.trim() !== '') {
|
|
807
|
+
// ⚠️ 必须**同时匹配 kind**(用户 2026-10-03 揪出):日志行显示成 `missed-slot: 上一刻度未执行…`,
|
|
808
|
+
// 前半的 `missed-slot` 是 **kind**、后半中文才是 message。只匹配 message 的话,
|
|
809
|
+
// 搜「上一刻」能搜到、搜「missed」却搜不到 —— 用户看到的就是「明明有这条却过滤不出来」。
|
|
810
|
+
where.push('(message LIKE ? OR kind LIKE ?)');
|
|
811
|
+
const kw = `%${q.keyword.trim()}%`;
|
|
812
|
+
params.push(kw, kw);
|
|
813
|
+
}
|
|
814
|
+
if (q.fromTs !== undefined) {
|
|
815
|
+
where.push('ts >= ?');
|
|
816
|
+
params.push(q.fromTs);
|
|
817
|
+
}
|
|
818
|
+
if (q.toTs !== undefined) {
|
|
819
|
+
// 半开区间:`< to`(与执行记录同口径,见 ui/time-range.rangeToQuery)。
|
|
820
|
+
where.push('ts < ?');
|
|
821
|
+
params.push(q.toTs);
|
|
822
|
+
}
|
|
823
|
+
const cursor = q.cursor === undefined ? null : decodeCursor(q.cursor);
|
|
824
|
+
if (cursor !== null && cursor.length === 2) {
|
|
825
|
+
where.push('(ts, seq) < (?, ?)');
|
|
826
|
+
params.push(String(cursor[0]), Number(cursor[1]));
|
|
827
|
+
}
|
|
828
|
+
const limit = Math.max(1, Math.min(q.limit ?? 100, 500));
|
|
829
|
+
const sql = `SELECT seq, ts, task_id, scheduled_at, level, kind, message FROM task_log${where.length > 0 ? ` WHERE ${where.join(' AND ')}` : ''} ORDER BY ts DESC, seq DESC LIMIT ?`;
|
|
830
|
+
const rows = this.db.prepare(sql).all(...params, limit + 1);
|
|
831
|
+
let nextCursor = null;
|
|
832
|
+
if (rows.length > limit) {
|
|
833
|
+
rows.pop();
|
|
834
|
+
const last = rows[rows.length - 1];
|
|
835
|
+
nextCursor = encodeCursor([last.ts, last.seq]);
|
|
836
|
+
}
|
|
837
|
+
return { rows, nextCursor };
|
|
838
|
+
}
|
|
839
|
+
/** 某实例的事件时间线(执行记录下钻用):seq 升序 = 旧→新,日志阅读顺序。 */
|
|
840
|
+
listEventsByInstance(instanceId) {
|
|
841
|
+
return this.db
|
|
842
|
+
.prepare('SELECT seq, ts, kind, detail FROM task_events WHERE instance_id = ? ORDER BY seq ASC')
|
|
843
|
+
.all(instanceId);
|
|
844
|
+
}
|
|
845
|
+
}
|