dsh-task-dispatch-table 0.0.0 → 0.1.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/README.md +101 -31
- package/README.zh.md +119 -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/tasks.js
ADDED
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
// 任务定义:从任务表目录读 *.json,zod 校验 14 字段(data-model.md 一节),enabled 过滤。
|
|
2
|
+
// 定义存 JSON 文件人改进 Git(决策 6),本模块只读不写。
|
|
3
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { resolve } from 'node:path';
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
// cron-parser 5.x 为 ESM,命名导出 CronExpressionParser。
|
|
8
|
+
import { CronExpressionParser } from 'cron-parser';
|
|
9
|
+
import { isSafeAttachmentRef } from './attachment-allowlist.js';
|
|
10
|
+
// 「cron / once → 时刻」纯核已于 2026-10-05 抽出至 [`./schedule-next.ts`](./schedule-next.ts)
|
|
11
|
+
//(零 node 依赖,客户端 bundle 与服务端**同一份**)。这里 import 供内部使用(firstSlotOnDay 等),
|
|
12
|
+
// 并 re-export 保持既有调用面(runtime-index / scheduler / index)零改动。
|
|
13
|
+
import { filterSlotsBySchedule, logicalDateOf, nextSlotAfter, onceScheduledAt, scheduledSlotsFor, } from './schedule-next.js';
|
|
14
|
+
export { filterSlotsBySchedule, logicalDateOf, nextSlotAfter, onceScheduledAt, scheduledSlotsFor, } from './schedule-next.js';
|
|
15
|
+
/** ISO 8601 时长(如 PT4H),只支持 H/M/S 组合——窗口与新鲜度够用。 */
|
|
16
|
+
const isoDuration = z.string().regex(/^PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?$/, 'ISO 8601 时长,如 PT4H');
|
|
17
|
+
export const dependencySemantics = ['same_period', 'latest_success'];
|
|
18
|
+
/**
|
|
19
|
+
* 任务定义输入 schema(决策 25/30):身份三字段分离——
|
|
20
|
+
* `id` = **机器身份**,录入瞬间由系统生成 UUID 并**写回这段 JSON**(inline 回写 settings、
|
|
21
|
+
* 目录模式回写该文件),人不手写、不参与展示;`title` = 任务名称(人读、随时可改);
|
|
22
|
+
* `code` = 任务编号(**可选**、用户自编,仅便于查询与管理,**只做记录、不参与任何唯一性判断**
|
|
23
|
+
* ——空格、重名、格式差异都不影响身份判定,因为判断只走 id + 计划刻度)。
|
|
24
|
+
*/
|
|
25
|
+
export const taskDefinitionSchema = z.object({
|
|
26
|
+
/** 系统生成并**写回 JSON**;用户显式写了则以用户写的为准(兼容既有定义 / 手写 JSON 的老手)。 */
|
|
27
|
+
id: z.string().min(1).optional(),
|
|
28
|
+
/** 用户可读名称:任意文本,可改,不影响身份与历史记录。缺省回退到 id。 */
|
|
29
|
+
title: z.string().min(1).optional(),
|
|
30
|
+
/** 任务编号(决策 30):可选、纯记录与查询用,不参与唯一性判断;存前 trim,空白视为未填。 */
|
|
31
|
+
code: z.string().optional(),
|
|
32
|
+
enabled: z.boolean(),
|
|
33
|
+
/**
|
|
34
|
+
* 创建时间(2026-09-30,主界面卡片「创建于 X」):**首次保存时由服务端写入,此后不再改**。
|
|
35
|
+
* 可选 —— 老定义 / 手写 JSON 没有它,卡片就不显示这一行(不编造时间)。
|
|
36
|
+
*/
|
|
37
|
+
createdAt: z.string().optional(),
|
|
38
|
+
schedule: z.object({
|
|
39
|
+
cron: z.string().min(1).optional(),
|
|
40
|
+
timezone: z.string().optional(),
|
|
41
|
+
window: isoDuration,
|
|
42
|
+
once: z.string().optional(),
|
|
43
|
+
/** 周期任务的锚点(YYYY-MM-DDTHH:mm):首跑下界,也是「每 N 周」取模的参考周(该周 = 第 0 周)。缺省无锚点。 */
|
|
44
|
+
start: z.string().optional(),
|
|
45
|
+
/** 每周档的重复步长(周):1 = 每周;≥2 = 每 N 周。cron 无隔周位 ⇒ 用锚点 + 取模实现(决策)。 */
|
|
46
|
+
everyNWeeks: z.number().int().min(1).optional(),
|
|
47
|
+
/**
|
|
48
|
+
* 结构化排期的**编辑态快照**(2026-09-30 双写,data-model §5.4):表单控件原样留档,
|
|
49
|
+
* 供「编辑现有任务」反解。**执行永不读它**——排期真源恒为 `cron` / `once`。
|
|
50
|
+
*/
|
|
51
|
+
ui: z.record(z.string(), z.unknown()).optional(),
|
|
52
|
+
}),
|
|
53
|
+
target: z.object({
|
|
54
|
+
// 工作区(非工作目录,决策 22):按 registry 的 title 精确匹配、id 兜底;目录由工作区 path 派生。
|
|
55
|
+
workspace: z.string().min(1),
|
|
56
|
+
/** 派发模型(决策 22 漏斗第①层):provider 与 model 成对;都留空则漏到插件配置 / 宿主默认。 */
|
|
57
|
+
provider: z.string().optional(),
|
|
58
|
+
model: z.string().optional(),
|
|
59
|
+
manual: z.string().optional(),
|
|
60
|
+
prompt: z.string().min(1),
|
|
61
|
+
/** 以 dsh 内置 /goal 开始执行(多轮续跑,决策 48):缺省 true(用户拍板「默认都是多轮会话」)。 */
|
|
62
|
+
goal: z.boolean().optional(),
|
|
63
|
+
/** 多 Agent 协作(决策 49):缺省 false;开启时派发消息注入官方 Agent Teams 执行指令(缺宿主组件降级单轮)。 */
|
|
64
|
+
agentTeam: z.boolean().optional(),
|
|
65
|
+
/** Agent 权限档位(决策 50):缺省 'default' = 会话默认。宿主 0.2.0-rc.2 无按任务下发权限的
|
|
66
|
+
* 参数(AgentOptions 只有 provider/model/reasoningEffort/maxTokens)⇒ 经派发消息约束指令执行。 */
|
|
67
|
+
permission: z.enum(['default', 'readOnly', 'workspace', 'full']).optional(),
|
|
68
|
+
}),
|
|
69
|
+
// 回执机制(决策 19):不再有契约文件与 path——agent 经 submit.mjs 直写状态库,
|
|
70
|
+
// 这里只保留 status 合法值清单。
|
|
71
|
+
contract: z
|
|
72
|
+
.object({
|
|
73
|
+
validStatuses: z.array(z.string()).default(['ok']),
|
|
74
|
+
})
|
|
75
|
+
.default({ validStatuses: ['ok'] }),
|
|
76
|
+
retry: z.object({ maxAttempts: z.number().int().min(1).default(1) }).default({ maxAttempts: 1 }),
|
|
77
|
+
depends_on: z
|
|
78
|
+
.array(z.object({
|
|
79
|
+
task: z.string().min(1),
|
|
80
|
+
semantics: z.enum(dependencySemantics),
|
|
81
|
+
}))
|
|
82
|
+
.optional(),
|
|
83
|
+
// 附加文件(用户 2026-09-29):link = 工作区路径(不复制);upload = 插件数据目录文件名(落盘于 task-attachments/)。
|
|
84
|
+
// 持久化随 P2 落库;此处先纳入 schema,保证手写 JSON 也能带附件、且 P2 无需再改型。
|
|
85
|
+
attachments: z
|
|
86
|
+
.array(z.object({
|
|
87
|
+
id: z.string().min(1),
|
|
88
|
+
name: z.string().min(1),
|
|
89
|
+
kind: z.enum(['link', 'upload']),
|
|
90
|
+
ref: z.string().min(1),
|
|
91
|
+
/** link 型必带:来源工作区 title(同一路径在不同工作区指向不同文件)。 */
|
|
92
|
+
workspace: z.string().optional(),
|
|
93
|
+
})
|
|
94
|
+
.refine(
|
|
95
|
+
// 唯一实现 = attachment-allowlist.isSafeAttachmentRef(客户端保存前校验 / 真删 / 上传定位同源)。
|
|
96
|
+
item => isSafeAttachmentRef(item.ref), { message: '附件 ref 非法:不允许相对路径上跳、绝对路径或反斜杠' }))
|
|
97
|
+
.optional(),
|
|
98
|
+
});
|
|
99
|
+
/** 展示名:优先 title,回退 id(决策 25:title 只是给人看的,永不参与身份)。 */
|
|
100
|
+
export function titleOf(task) {
|
|
101
|
+
return task.title ?? task.id;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* 会话显示名标题(决策 42):title 回退 code,再回退短 id(前 8 位)。
|
|
105
|
+
* 与 titleOf 的差别:code 也参与回退(编号比 UUID 对人更有意义),且永不落完整 UUID。
|
|
106
|
+
*/
|
|
107
|
+
export function displayNameOf(task) {
|
|
108
|
+
if (task.title !== undefined && task.title.trim() !== '')
|
|
109
|
+
return task.title.trim();
|
|
110
|
+
if (task.code !== undefined && task.code.trim() !== '')
|
|
111
|
+
return task.code.trim();
|
|
112
|
+
return task.id.slice(0, 8);
|
|
113
|
+
}
|
|
114
|
+
/** 计划时刻短格式 `YYMMDD-HHmm`(本地时区,决策 42;例 `260928-1600`)。 */
|
|
115
|
+
export function formatSlotShort(scheduledAtIso) {
|
|
116
|
+
const d = new Date(scheduledAtIso);
|
|
117
|
+
if (Number.isNaN(d.getTime()))
|
|
118
|
+
return scheduledAtIso;
|
|
119
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
120
|
+
return `${p(d.getFullYear() % 100)}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}`;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* 派发会话名(决策 42):`[TASK] <260928-1600> · <标题>`,attempt>0(第 2 次起)追加「 · 第N次」。
|
|
124
|
+
* 时间取计划时刻(重试不变,与执行记录「计划时刻」列一致);重试后缀既点明重试、又防同刻度重名。
|
|
125
|
+
*/
|
|
126
|
+
export function sessionTitleOf(scheduledAtIso, displayName, attempt) {
|
|
127
|
+
const base = `[TASK] ${formatSlotShort(scheduledAtIso)} · ${displayName}`;
|
|
128
|
+
return attempt > 0 ? `${base} · 第${attempt + 1}次` : base;
|
|
129
|
+
}
|
|
130
|
+
/** 标准 UUID(36 位带横线,大小写不敏感)——任务 id 的唯一合法形态(决策 30 修订)。 */
|
|
131
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
132
|
+
/** 任务 id 是否合法:必须是标准 UUID。手写 kebab-case / 旧内容指纹等一律不算。 */
|
|
133
|
+
export function isUuid(value) {
|
|
134
|
+
return typeof value === 'string' && UUID_RE.test(value);
|
|
135
|
+
}
|
|
136
|
+
/** 生成一个任务 id:标准 UUID(决策 30:机器身份与内容、名称彻底解耦,保存时生成并固化写入)。 */
|
|
137
|
+
export function newTaskId() {
|
|
138
|
+
return randomUUID();
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* 从已保存的 tasksInline 文本里提取全部合法 UUID 集合(保存闸门「修改必须命中」的比对基准)。
|
|
142
|
+
* 非法 JSON / 非数组 / 非 UUID id(老数据)一律不算 ⇒ 拿 UUID 去改老记录同样会被拒(老记录无 UUID 身份,想用就删 id 重录)。
|
|
143
|
+
*/
|
|
144
|
+
export function existingUuidIds(raw) {
|
|
145
|
+
let data;
|
|
146
|
+
try {
|
|
147
|
+
data = JSON.parse(raw.trim());
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return new Set();
|
|
151
|
+
}
|
|
152
|
+
if (!Array.isArray(data))
|
|
153
|
+
return new Set();
|
|
154
|
+
const ids = new Set();
|
|
155
|
+
for (const item of data) {
|
|
156
|
+
if (item === null || typeof item !== 'object' || Array.isArray(item))
|
|
157
|
+
continue;
|
|
158
|
+
const id = item.id;
|
|
159
|
+
if (isUuid(id))
|
|
160
|
+
ids.add(id);
|
|
161
|
+
}
|
|
162
|
+
return ids;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* 保存闸门(决策 30 修订版 + 第三次拍板,用户 2026-09-25:**保存时固化,运行时只认,UUID 不能凭空引入**)。
|
|
166
|
+
* ① 条目无 id ⇒ 生成随机 UUID 补上(= 新增任务,这一刻固化进 JSON);
|
|
167
|
+
* ② 有 id 且是 UUID ⇒ 必须在 `existingIds`(现有已保存任务表)中命中,命中即原样保留(= 修改既有任务);
|
|
168
|
+
* 不命中 ⇒ 报错拒绝——带 UUID 就是修改,修改目标必须存在,UUID 身份只能由本闸门生成,不允许凭空写入;
|
|
169
|
+
* ③ 有 id 但不是 UUID ⇒ 报错,**整批拒绝保存**;
|
|
170
|
+
* 非法 JSON / 非数组原样返回(error=null)——那是既有校验的职责,不是身份闸门的。
|
|
171
|
+
* `existingIds` 缺省(undefined)时跳过 ② 的命中校验(仅单测直调用)。
|
|
172
|
+
*/
|
|
173
|
+
export function ensureIdsInInlineJson(raw, existingIds) {
|
|
174
|
+
const text = raw.trim();
|
|
175
|
+
if (text === '')
|
|
176
|
+
return { json: raw, changed: false, assigned: 0, error: null };
|
|
177
|
+
let data;
|
|
178
|
+
try {
|
|
179
|
+
data = JSON.parse(text);
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
return { json: raw, changed: false, assigned: 0, error: null };
|
|
183
|
+
}
|
|
184
|
+
if (!Array.isArray(data))
|
|
185
|
+
return { json: raw, changed: false, assigned: 0, error: null };
|
|
186
|
+
let assigned = 0;
|
|
187
|
+
for (const [index, item] of data.entries()) {
|
|
188
|
+
if (item === null || typeof item !== 'object' || Array.isArray(item))
|
|
189
|
+
continue;
|
|
190
|
+
const record = item;
|
|
191
|
+
const id = record.id;
|
|
192
|
+
if (id === undefined || id === null || (typeof id === 'string' && id.trim() === '')) {
|
|
193
|
+
record.id = newTaskId(); // 新增:保存这一刻生成并固化,之后运行时不再生成任何 id
|
|
194
|
+
assigned++;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (typeof id === 'string' && UUID_RE.test(id)) {
|
|
198
|
+
// 修改:带 UUID 即修改既有任务,必须在现有表中命中;UUID 只能由本闸门生成,凭空引入即非法
|
|
199
|
+
if (existingIds !== undefined && !existingIds.has(id)) {
|
|
200
|
+
const title = record.title;
|
|
201
|
+
return {
|
|
202
|
+
json: raw, changed: false, assigned: 0,
|
|
203
|
+
error: `第 ${index + 1} 条(${typeof title === 'string' && title.trim() !== '' ? title : '未命名'})的 id "${id}"`
|
|
204
|
+
+ ' 不在现有任务表中——带 UUID 的 id 即为修改,只能指向已有任务;新增任务请删掉该条目的 id 字段',
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
const title = record.title;
|
|
210
|
+
return {
|
|
211
|
+
json: raw, changed: false, assigned: 0,
|
|
212
|
+
error: `第 ${index + 1} 条(${typeof title === 'string' && title.trim() !== '' ? title : '未命名'})的 id "${String(id)}"`
|
|
213
|
+
+ ' 不是合法 UUID,拒绝保存。删掉该条目的 id 字段让系统重新生成,或原样保留系统生成的 id',
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
if (assigned === 0)
|
|
217
|
+
return { json: raw, changed: false, assigned: 0, error: null };
|
|
218
|
+
return { json: JSON.stringify(data, null, 2), changed: true, assigned, error: null };
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* 运行时身份校验(保存闸门的另一半,用户拍板:**运行时只认不修**)。
|
|
222
|
+
* 有 id 且为 UUID ⇒ 归一返回(code trim、title 缺省回退 id);
|
|
223
|
+
* 无 id(老数据)或 id 非 UUID ⇒ 返回 null,调用方记一条 warn 后跳过,不做任何兜底。
|
|
224
|
+
*/
|
|
225
|
+
export function applyIdentity(def) {
|
|
226
|
+
if (!isUuid(def.id))
|
|
227
|
+
return null;
|
|
228
|
+
const code = def.code !== undefined && def.code.trim() !== '' ? def.code.trim() : undefined;
|
|
229
|
+
return { ...def, id: def.id, title: def.title ?? def.id, code };
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* 单条任务定义的保存校验(服务端把关,data-model §5.2 校验清单):
|
|
233
|
+
* ① 提示词非空;② 工作区非空;③ `cron` 与 `once` **恰有其一**且 cron 可解析;
|
|
234
|
+
* ④ 前置任务必须指向已存在的任务(**停用可以、不存在不行**)。
|
|
235
|
+
* `title` / `code` **不做**格式与唯一性校验(决策 30:它们只是人读字段)。
|
|
236
|
+
*/
|
|
237
|
+
export function validateDefinitionForSave(def, existingIds) {
|
|
238
|
+
const prompt = typeof def.target?.prompt === 'string' ? def.target.prompt.trim() : '';
|
|
239
|
+
if (prompt === '')
|
|
240
|
+
return { ok: false, error: '提示词不能为空' };
|
|
241
|
+
const workspace = typeof def.target?.workspace === 'string' ? def.target.workspace.trim() : '';
|
|
242
|
+
if (workspace === '')
|
|
243
|
+
return { ok: false, error: '工作区不能为空' };
|
|
244
|
+
const cron = typeof def.schedule?.cron === 'string' && def.schedule.cron.trim() !== '' ? def.schedule.cron.trim() : undefined;
|
|
245
|
+
const once = typeof def.schedule?.once === 'string' && def.schedule.once.trim() !== '' ? def.schedule.once.trim() : undefined;
|
|
246
|
+
if (cron === undefined && once === undefined)
|
|
247
|
+
return { ok: false, error: '排期缺失:周期任务必须有 cron,单次任务必须有 once' };
|
|
248
|
+
if (cron !== undefined && once !== undefined)
|
|
249
|
+
return { ok: false, error: '排期冲突:cron 与 once 只能有一个' };
|
|
250
|
+
if (cron !== undefined) {
|
|
251
|
+
try {
|
|
252
|
+
CronExpressionParser.parse(cron, { tz: typeof def.schedule?.timezone === 'string' ? def.schedule.timezone : undefined });
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
return { ok: false, error: `cron 无法解析(${cron}):${error instanceof Error ? error.message : String(error)}` };
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
else if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(once ?? '')) {
|
|
259
|
+
return { ok: false, error: '单次执行时刻格式应为 YYYY-MM-DDTHH:mm' };
|
|
260
|
+
}
|
|
261
|
+
const deps = def.depends_on;
|
|
262
|
+
if (Array.isArray(deps)) {
|
|
263
|
+
for (const dep of deps) {
|
|
264
|
+
if (dep === null || typeof dep !== 'object')
|
|
265
|
+
continue;
|
|
266
|
+
const task = typeof dep.task === 'string' ? dep.task : '';
|
|
267
|
+
if (typeof def.id === 'string' && task === def.id) {
|
|
268
|
+
return { ok: false, error: '前置任务不能是它自己(必然死锁)' };
|
|
269
|
+
}
|
|
270
|
+
if (task === '' || !existingIds.has(task)) {
|
|
271
|
+
return { ok: false, error: `前置任务「${task === '' ? '(空)' : task}」不存在——停用可以,不存在不行` };
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const timezone = def.schedule?.timezone;
|
|
276
|
+
if (typeof timezone === 'string' && timezone.trim() !== '') {
|
|
277
|
+
try {
|
|
278
|
+
new Intl.DateTimeFormat('en-US', { timeZone: timezone });
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
return { ok: false, error: `时区「${timezone}」无法识别` };
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return { ok: true, error: null };
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* 把一条任务定义并回整表(新增 = 追加,修改 = 按 id 替换)。
|
|
288
|
+
* **身份闸门在这里落地**:无 id ⇒ 生成 UUID(新增);带 UUID ⇒ 必须命中现有表(修改);
|
|
289
|
+
* 非 UUID ⇒ 拒绝。
|
|
290
|
+
* @param opts.allowNew - 表单通道专用:带 UUID 但表中没有 ⇒ **视为新增**(id 由服务端刚生成,
|
|
291
|
+
* 可信)。整批 JSON 通道不传 ⇒ 沿用闸门语义(凭空引入的 UUID 一律拒)。
|
|
292
|
+
*/
|
|
293
|
+
export function upsertDefinitionInline(raw, definition, opts = {}) {
|
|
294
|
+
const text = raw.trim();
|
|
295
|
+
let data;
|
|
296
|
+
if (text === '')
|
|
297
|
+
data = [];
|
|
298
|
+
else {
|
|
299
|
+
try {
|
|
300
|
+
data = JSON.parse(text);
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
return { json: raw, mode: 'create', id: '', error: '现有任务表不是合法 JSON,无法保存' };
|
|
304
|
+
}
|
|
305
|
+
if (!Array.isArray(data))
|
|
306
|
+
return { json: raw, mode: 'create', id: '', error: '现有任务表不是数组,无法保存' };
|
|
307
|
+
}
|
|
308
|
+
const rows = data;
|
|
309
|
+
const rawId = definition.id;
|
|
310
|
+
if (rawId === undefined || rawId === null || (typeof rawId === 'string' && rawId.trim() === '')) {
|
|
311
|
+
const id = newTaskId();
|
|
312
|
+
rows.push({ ...definition, id });
|
|
313
|
+
return { json: JSON.stringify(rows, null, 2), mode: 'create', id, error: null };
|
|
314
|
+
}
|
|
315
|
+
if (!isUuid(rawId))
|
|
316
|
+
return { json: raw, mode: 'update', id: String(rawId), error: `id "${String(rawId)}" 不是合法 UUID` };
|
|
317
|
+
const index = rows.findIndex(row => row !== null && typeof row === 'object' && row.id === rawId);
|
|
318
|
+
if (index < 0) {
|
|
319
|
+
// 表单通道(id 由服务端刚生成)⇒ 视为新增;整批 JSON 通道 ⇒ 拒(闸门:UUID 不能凭空引入)。
|
|
320
|
+
if (opts.allowNew === true) {
|
|
321
|
+
rows.push({ ...definition, id: rawId });
|
|
322
|
+
return { json: JSON.stringify(rows, null, 2), mode: 'create', id: rawId, error: null };
|
|
323
|
+
}
|
|
324
|
+
return { json: raw, mode: 'update', id: rawId, error: `任务 ${rawId} 不在现有任务表中,无法修改` };
|
|
325
|
+
}
|
|
326
|
+
rows[index] = { ...definition, id: rawId };
|
|
327
|
+
return { json: JSON.stringify(rows, null, 2), mode: 'update', id: rawId, error: null };
|
|
328
|
+
}
|
|
329
|
+
/** 从整表里摘掉一条任务定义(删除任务;实例 / 事件保留在库里做审计)。 */
|
|
330
|
+
export function removeDefinitionInline(raw, id) {
|
|
331
|
+
const text = raw.trim();
|
|
332
|
+
if (text === '')
|
|
333
|
+
return { json: raw, removed: false, error: null };
|
|
334
|
+
let data;
|
|
335
|
+
try {
|
|
336
|
+
data = JSON.parse(text);
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
return { json: raw, removed: false, error: '现有任务表不是合法 JSON,无法删除' };
|
|
340
|
+
}
|
|
341
|
+
if (!Array.isArray(data))
|
|
342
|
+
return { json: raw, removed: false, error: '现有任务表不是数组,无法删除' };
|
|
343
|
+
const rows = data.filter(row => !(row !== null && typeof row === 'object' && row.id === id));
|
|
344
|
+
const removed = rows.length !== data.length;
|
|
345
|
+
return { json: JSON.stringify(rows, null, 2), removed, error: null };
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* 只改一条任务的 `enabled`(编辑器头部的启用开关实时写回,用户 2026-09-30:独立于保存按钮)。
|
|
349
|
+
* 定义不存在 ⇒ error(404 语义);值没变 ⇒ changed=false(不落库不审计)。
|
|
350
|
+
*/
|
|
351
|
+
export function setEnabledDefinitionInline(raw, id, enabled) {
|
|
352
|
+
const text = raw.trim();
|
|
353
|
+
let data;
|
|
354
|
+
if (text === '')
|
|
355
|
+
return { json: raw, changed: false, error: 'task-not-found' };
|
|
356
|
+
try {
|
|
357
|
+
data = JSON.parse(text);
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
return { json: raw, changed: false, error: '现有任务表不是合法 JSON,无法修改' };
|
|
361
|
+
}
|
|
362
|
+
if (!Array.isArray(data))
|
|
363
|
+
return { json: raw, changed: false, error: '现有任务表不是数组,无法修改' };
|
|
364
|
+
const rows = data;
|
|
365
|
+
const row = rows.find(item => item !== null && typeof item === 'object' && item.id === id);
|
|
366
|
+
if (row === undefined)
|
|
367
|
+
return { json: raw, changed: false, error: 'task-not-found' };
|
|
368
|
+
if (row.enabled === enabled)
|
|
369
|
+
return { json: raw, changed: false, error: null };
|
|
370
|
+
row.enabled = enabled;
|
|
371
|
+
return { json: JSON.stringify(rows, null, 2), changed: true, error: null };
|
|
372
|
+
}
|
|
373
|
+
/** 把 ISO 8601 时长解析成毫秒。 */
|
|
374
|
+
export function durationMs(iso) {
|
|
375
|
+
const match = /^PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?$/.exec(iso);
|
|
376
|
+
if (match === null)
|
|
377
|
+
throw new Error(`非法 ISO 8601 时长: ${iso}`);
|
|
378
|
+
const [, h, m, s] = match;
|
|
379
|
+
return (Number(h ?? 0) * 3600 + Number(m ?? 0) * 60 + Number(s ?? 0)) * 1000;
|
|
380
|
+
}
|
|
381
|
+
/** 用 Intl 验证 IANA 时区名(schedule.timezone 缺省 = 宿主时区)。 */
|
|
382
|
+
export function isValidTimeZone(tz) {
|
|
383
|
+
try {
|
|
384
|
+
new Intl.DateTimeFormat('en-CA', { timeZone: tz });
|
|
385
|
+
return true;
|
|
386
|
+
}
|
|
387
|
+
catch {
|
|
388
|
+
return false;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
// 「cron / once → 时刻」的纯计算(logicalDateOf / intervalSpecOf / anchoredIntervalSlots /
|
|
392
|
+
// scheduledSlotsFor / filterSlotsBySchedule / wallClockToAbsolute 及其私有辅助)
|
|
393
|
+
// 已于 2026-10-05 **原样迁出** ⇒ [`./schedule-next.ts`](./schedule-next.ts)(见该文件头说明)。
|
|
394
|
+
// tasks.ts 顶部已 import + re-export,内部(firstSlotOnDay)与外部调用面零改动。
|
|
395
|
+
/**
|
|
396
|
+
* 某日历日上的第一个刻度(面板展示「下次执行」/ 目录模式历史锚点用;
|
|
397
|
+
* 决策 20 的 live 重排与 §7 backfill 已于决策 31 移除,不再依赖「天」粒度补跑)。
|
|
398
|
+
*/
|
|
399
|
+
export function firstSlotOnDay(task, day) {
|
|
400
|
+
if (task.schedule.once !== undefined)
|
|
401
|
+
return onceScheduledAt(task, day);
|
|
402
|
+
const anchor = Date.parse(`${day}T00:00:00`);
|
|
403
|
+
// 前后各放宽:起点减 26h 覆盖最大时区偏移,终点加 48h 覆盖 DST 造成的日界漂移。
|
|
404
|
+
const slots = scheduledSlotsFor(task, new Date(anchor - 26 * 3600_000), new Date(anchor + 48 * 3600_000));
|
|
405
|
+
return slots.find(slot => logicalDateOf(slot, task.schedule.timezone) === day);
|
|
406
|
+
}
|
|
407
|
+
// nextSlotAfter / tzOffsetMs / onceScheduledAt 同批迁出 ⇒ schedule-next.ts(见上方说明)。
|
|
408
|
+
/** 单个任务定义的公共校验(schema + 互斥 + 时区 + cron/once),文件目录与内嵌两路共用。 */
|
|
409
|
+
function checkedTask(logger, label, data) {
|
|
410
|
+
const parsed = taskDefinitionSchema.safeParse(data);
|
|
411
|
+
if (!parsed.success) {
|
|
412
|
+
logger.warn(`任务定义校验失败 ${label}: ${parsed.error.message}`);
|
|
413
|
+
return undefined;
|
|
414
|
+
}
|
|
415
|
+
const def = parsed.data;
|
|
416
|
+
// cron 与 once 恰有其一:周期任务用 cron,一次性任务用 once(决策 18)。
|
|
417
|
+
if ((def.schedule.cron === undefined) === (def.schedule.once === undefined)) {
|
|
418
|
+
logger.warn(`任务定义校验失败 ${label}: schedule.cron 与 schedule.once 必须恰有其一`);
|
|
419
|
+
return undefined;
|
|
420
|
+
}
|
|
421
|
+
if (def.schedule.timezone !== undefined && !isValidTimeZone(def.schedule.timezone)) {
|
|
422
|
+
logger.warn(`任务定义时区非法 ${label}: ${def.schedule.timezone}`);
|
|
423
|
+
return undefined;
|
|
424
|
+
}
|
|
425
|
+
if (def.schedule.cron !== undefined) {
|
|
426
|
+
try {
|
|
427
|
+
CronExpressionParser.parse(def.schedule.cron, { tz: def.schedule.timezone });
|
|
428
|
+
}
|
|
429
|
+
catch (error) {
|
|
430
|
+
logger.warn(`任务定义 cron 非法 ${label}: ${String(error)}`);
|
|
431
|
+
return undefined;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
else if (def.schedule.once !== undefined && !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(def.schedule.once)) {
|
|
435
|
+
logger.warn(`任务定义 once 格式非法 ${label}: ${def.schedule.once}(应为 YYYY-MM-DDTHH:mm)`);
|
|
436
|
+
return undefined;
|
|
437
|
+
}
|
|
438
|
+
if (def.schedule.start !== undefined && !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(def.schedule.start)) {
|
|
439
|
+
logger.warn(`任务定义 start 格式非法 ${label}: ${def.schedule.start}(应为 YYYY-MM-DDTHH:mm)`);
|
|
440
|
+
return undefined;
|
|
441
|
+
}
|
|
442
|
+
return def;
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* 解析内嵌任务表 JSON(tasksInline 配置):须为数组,逐项校验,坏项告警跳过。
|
|
446
|
+
* 身份走 `applyIdentity`(决策 30 修订:**运行时只认不修**)——无 id / 非 UUID 的条目
|
|
447
|
+
* warn 跳过,绝不在这里生成或兜底 id;生成只发生在保存闸门 `ensureIdsInInlineJson`。
|
|
448
|
+
*/
|
|
449
|
+
export function parseInlineTasks(logger, raw, opts = {}) {
|
|
450
|
+
const text = raw.trim();
|
|
451
|
+
if (text.length === 0)
|
|
452
|
+
return [];
|
|
453
|
+
let data;
|
|
454
|
+
try {
|
|
455
|
+
data = JSON.parse(text);
|
|
456
|
+
}
|
|
457
|
+
catch (error) {
|
|
458
|
+
logger.warn(`内嵌任务表 JSON 非法: ${String(error)}`);
|
|
459
|
+
return [];
|
|
460
|
+
}
|
|
461
|
+
if (!Array.isArray(data)) {
|
|
462
|
+
logger.warn('内嵌任务表必须是 JSON 数组');
|
|
463
|
+
return [];
|
|
464
|
+
}
|
|
465
|
+
const tasks = [];
|
|
466
|
+
for (const [index, item] of data.entries()) {
|
|
467
|
+
const def = checkedTask(logger, `内嵌任务表[${index}]`, item);
|
|
468
|
+
if (def === undefined || (!opts.includeDisabled && !def.enabled))
|
|
469
|
+
continue;
|
|
470
|
+
const task = applyIdentity(def);
|
|
471
|
+
if (task === null) {
|
|
472
|
+
logger.warn(`内嵌任务表[${index}] 缺少合法 UUID 的 id,不处理(保存时应由系统生成并固化写入)`);
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
tasks.push(task);
|
|
476
|
+
}
|
|
477
|
+
return tasks;
|
|
478
|
+
}
|
|
479
|
+
/** 目录读取告警去重:dir → 上次告警的错误文本(恢复可读时清空并提示一次)。 */
|
|
480
|
+
const dirWarnMemo = new Map();
|
|
481
|
+
/**
|
|
482
|
+
* 读任务表目录:逐文件 safeParse,坏文件告警跳过;返回 enabled 的定义。
|
|
483
|
+
* 身份同样「运行时只认」(决策 30 修订):缺 id / id 非 UUID 的文件 warn 跳过,**不再写回**。
|
|
484
|
+
* 目录不存在(ENOENT)= 合法空态(用户没在用目录模式),静默返回,不刷告警。
|
|
485
|
+
*/
|
|
486
|
+
export function loadTasks(logger, tasksDir) {
|
|
487
|
+
const dir = resolve(tasksDir);
|
|
488
|
+
let names;
|
|
489
|
+
try {
|
|
490
|
+
names = readdirSync(dir).filter(name => name.endsWith('.json')).sort();
|
|
491
|
+
}
|
|
492
|
+
catch (error) {
|
|
493
|
+
if (error?.code === 'ENOENT')
|
|
494
|
+
return [];
|
|
495
|
+
// 其余不可读错误(权限等):同一错误只告警一次,恢复可读或错误变化时再提示。
|
|
496
|
+
const message = String(error);
|
|
497
|
+
if (dirWarnMemo.get(dir) !== message) {
|
|
498
|
+
dirWarnMemo.set(dir, message);
|
|
499
|
+
logger.warn(`任务表目录不可读 ${dir}: ${message}`);
|
|
500
|
+
}
|
|
501
|
+
return [];
|
|
502
|
+
}
|
|
503
|
+
if (dirWarnMemo.delete(dir))
|
|
504
|
+
logger.info(`任务表目录 ${dir} 恢复可读`);
|
|
505
|
+
const tasks = [];
|
|
506
|
+
for (const name of names) {
|
|
507
|
+
const file = `${dir}/${name}`;
|
|
508
|
+
try {
|
|
509
|
+
if (!statSync(file).isFile())
|
|
510
|
+
continue;
|
|
511
|
+
const raw = JSON.parse(readFileSync(file, 'utf8'));
|
|
512
|
+
const def = checkedTask(logger, file, raw);
|
|
513
|
+
if (def === undefined || !def.enabled)
|
|
514
|
+
continue;
|
|
515
|
+
const task = applyIdentity(def);
|
|
516
|
+
if (task === null) {
|
|
517
|
+
logger.warn(`任务定义 ${file} 缺少合法 UUID 的 id,不处理(请补一个 UUID 或删掉 id 字段后经面板保存生成)`);
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
tasks.push(task);
|
|
521
|
+
}
|
|
522
|
+
catch (error) {
|
|
523
|
+
logger.warn(`任务定义读取失败 ${file}: ${String(error)}`);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
return tasks;
|
|
527
|
+
}
|
package/locale/en.json
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"meta": {
|
|
3
|
+
"title": "Scheduled Task Dispatcher (dsh-task-dispatch-table)",
|
|
4
|
+
"description": "Drives recurring tasks from a task definition table: due tasks are dispatched into independent sessions by time window and dependencies, with zero LLM involvement in the scheduling layer. Supports prerequisites, retries and run history."
|
|
5
|
+
}
|
|
6
|
+
}
|
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-task-dispatch-table",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Scheduled task dispatcher: a dsh host plugin that drives recurring agent tasks from a single task table, dispatching each run as an independent dsh session (scheduling itself uses zero model calls)",
|
|
5
|
+
"icon": "./assets/icon-scheduler.svg",
|
|
5
6
|
"keywords": [
|
|
6
7
|
"deepseek",
|
|
7
8
|
"harness",
|
|
@@ -17,13 +18,73 @@
|
|
|
17
18
|
"author": "cq-guojia",
|
|
18
19
|
"license": "MIT",
|
|
19
20
|
"type": "module",
|
|
20
|
-
"main": "index.js",
|
|
21
|
+
"main": "dist/index.js",
|
|
22
|
+
"exports": {
|
|
23
|
+
".": "./dist/index.js",
|
|
24
|
+
"./client": "./dist/client.js",
|
|
25
|
+
"./locale/*.json": "./locale/*.json",
|
|
26
|
+
"./package.json": "./package.json"
|
|
27
|
+
},
|
|
21
28
|
"files": [
|
|
29
|
+
"dist",
|
|
30
|
+
"cordis.patch.yml",
|
|
31
|
+
"assets",
|
|
32
|
+
"locale",
|
|
22
33
|
"README.md",
|
|
23
|
-
"LICENSE"
|
|
24
|
-
"index.js"
|
|
34
|
+
"LICENSE"
|
|
25
35
|
],
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "npm run build:host && npm run build:client",
|
|
38
|
+
"build:host": "tsc",
|
|
39
|
+
"build:client": "tsc --noEmit -p tsconfig.client.json && tsdown --config tsdown.client.config.ts",
|
|
40
|
+
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.client.json",
|
|
41
|
+
"smoke": "node scripts/smoke.mjs"
|
|
42
|
+
},
|
|
26
43
|
"engines": {
|
|
27
|
-
"node": ">=
|
|
44
|
+
"node": ">=22.5"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"@codemirror/lang-css": "^6.3.1",
|
|
48
|
+
"@codemirror/lang-html": "^6.4.12",
|
|
49
|
+
"@codemirror/lang-javascript": "^6.2.5",
|
|
50
|
+
"@codemirror/lang-json": "^6.0.2",
|
|
51
|
+
"@codemirror/lang-markdown": "^6.5.2",
|
|
52
|
+
"@codemirror/lang-python": "^6.2.1",
|
|
53
|
+
"@codemirror/lang-yaml": "^6.1.3",
|
|
54
|
+
"@codemirror/language": "^6.12.4",
|
|
55
|
+
"@codemirror/legacy-modes": "^6.5.3",
|
|
56
|
+
"@codemirror/state": "^6.7.6",
|
|
57
|
+
"@codemirror/view": "^6.43.13",
|
|
58
|
+
"@deepseek-ai/schemastery": "^3.18.4",
|
|
59
|
+
"@lezer/highlight": "^1.2.3",
|
|
60
|
+
"@uiw/react-codemirror": "^4.25.12",
|
|
61
|
+
"cron-parser": "^5.10.1",
|
|
62
|
+
"zod": "^4.6.5"
|
|
63
|
+
},
|
|
64
|
+
"devDependencies": {
|
|
65
|
+
"@types/node": "^22.20.4",
|
|
66
|
+
"@types/react": "~18.3.1",
|
|
67
|
+
"tsdown": "^0.23.0",
|
|
68
|
+
"typescript": "^5.9.3"
|
|
69
|
+
},
|
|
70
|
+
"dsh": {
|
|
71
|
+
"bundle": {
|
|
72
|
+
"patch": "./cordis.patch.yml"
|
|
73
|
+
},
|
|
74
|
+
"client": {
|
|
75
|
+
"platform": "web",
|
|
76
|
+
"inject": [
|
|
77
|
+
"@deepseek-ai/dsh-client-locale",
|
|
78
|
+
"@deepseek-ai/dsh-client-ui-renderer",
|
|
79
|
+
"@deepseek-ai/dsh-client-ui-settings",
|
|
80
|
+
"@deepseek-ai/dsh-client-ui-settings-plugins",
|
|
81
|
+
"@deepseek-ai/dsh-client-ui-layout",
|
|
82
|
+
"@deepseek-ai/dsh-client-ui-sidebar",
|
|
83
|
+
"@deepseek-ai/dsh-api-session-controller",
|
|
84
|
+
"@deepseek-ai/dsh-client-ui-conversation",
|
|
85
|
+
"@deepseek-ai/dsh-client-ui-workspace",
|
|
86
|
+
"@deepseek-ai/dsh-api-workspace-files"
|
|
87
|
+
]
|
|
88
|
+
}
|
|
28
89
|
}
|
|
29
90
|
}
|
package/index.js
DELETED
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
// dsh-task-dispatch-table — placeholder package (design phase).
|
|
2
|
-
//
|
|
3
|
-
// This is an empty stub published only to reserve the npm name.
|
|
4
|
-
// The real implementation is not released yet.
|
|
5
|
-
//
|
|
6
|
-
// Design docs: https://github.com/cq-guojia/dsh-task-dispatch-table
|
|
7
|
-
export default {};
|