agent-loft 0.2.30 → 0.2.32
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 +11 -1
- package/package.json +1 -1
- package/src/agents/claude/driver.ts +13 -8
- package/src/agents/claude/session.ts +38 -6
- package/src/agents/codex/session.ts +32 -5
- package/src/channels/feishu/bot.ts +8 -4
- package/src/channels/feishu/turn-stream.ts +3 -2
- package/src/channels/web/api.ts +8 -0
- package/src/channels/web/http.ts +2 -2
- package/src/channels/web/server.ts +21 -1
- package/src/channels/web/skills-api.ts +225 -0
- package/src/channels/web/tasks-api.ts +94 -0
- package/src/core/chat-mode.ts +16 -16
- package/src/core/command-catalog.ts +8 -1
- package/src/core/commands.ts +3 -0
- package/src/core/hub.ts +147 -19
- package/src/core/memory-mcp.ts +1 -1
- package/src/core/personal.ts +5 -1
- package/src/core/skill-runtime.ts +359 -0
- package/src/core/skills-bridge.ts +288 -0
- package/src/core/skills.ts +675 -0
- package/src/core/store.ts +2 -0
- package/src/core/task-prompt.ts +38 -0
- package/src/core/task-runner.ts +232 -0
- package/src/core/task-schedule.ts +250 -0
- package/src/core/tasks-bridge.ts +256 -0
- package/src/core/tasks-mcp.ts +66 -0
- package/src/core/tasks.ts +281 -0
- package/src/core/types.ts +21 -0
- package/src/core/zip.ts +98 -0
- package/src/main.ts +12 -0
- package/src/mcp/tasks-server.ts +327 -0
- package/web/css/skills.css +368 -0
- package/web/css/tasks.css +455 -0
- package/web/index.html +2 -0
- package/web/lib/dom.js +1 -0
- package/web/lib/ws.js +8 -0
- package/web/state.js +2 -0
- package/web/views/composer.js +15 -3
- package/web/views/conv-actions.js +15 -7
- package/web/views/layout.js +22 -1
- package/web/views/mention.js +193 -0
- package/web/views/messages.js +23 -2
- package/web/views/sidebar.js +4 -0
- package/web/views/skills.js +703 -0
- package/web/views/tasks.js +469 -0
- package/web/views/topbar.js +9 -3
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// 定时任务每次运行时发给 agent 的话:用户写的提示词 + 一段 agent-loft 附上的说明(用户看不到)。
|
|
2
|
+
//
|
|
3
|
+
// 「只在需要时提醒」靠一个暗号:agent 判断这次没什么要说的,就在回复第一行只写 SILENT_MARK,
|
|
4
|
+
// hub 看到它就把这一轮标成 silent,推送和飞书都不发。没写暗号一律当成要提醒 —— 宁可多提醒一次,
|
|
5
|
+
// 也不能漏掉。
|
|
6
|
+
import { wrapContext } from "./chat-mode.js";
|
|
7
|
+
import { formatDateTime, serverTimeZone } from "./task-schedule.js";
|
|
8
|
+
|
|
9
|
+
export const SILENT_MARK = "[[静默]]";
|
|
10
|
+
|
|
11
|
+
export function isSilentReply(text: string): boolean {
|
|
12
|
+
return text.trimStart().startsWith(SILENT_MARK);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** 去掉暗号,剩下的是 agent 顺手记的检查结果 */
|
|
16
|
+
export function stripSilentMark(text: string): string {
|
|
17
|
+
return isSilentReply(text) ? text.trimStart().slice(SILENT_MARK.length).trim() : text;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function taskMessage(
|
|
21
|
+
task: { title: string; prompt: string; notify: "smart" | "always" },
|
|
22
|
+
opts: { now?: number; manual?: boolean; version?: string } = {},
|
|
23
|
+
): string {
|
|
24
|
+
const now = opts.now ?? Date.now();
|
|
25
|
+
const lines = [
|
|
26
|
+
`这条消息是定时任务「${task.title}」${opts.manual ? "(用户刚才手动点了「立即运行」)" : "到点自动发来的"},现在是 ${formatDateTime(now)}(${serverTimeZone()})。${opts.manual ? "" : "用户此刻多半不在屏幕前,不要向他提问、等他回答。"}`,
|
|
27
|
+
...(opts.version ? [`agent-loft 服务端当前版本:${opts.version}。`] : []),
|
|
28
|
+
"按上面的要求做;这条对话里以前运行的结果可以拿来对比(比如「和上次比有没有变化」)。",
|
|
29
|
+
];
|
|
30
|
+
if (task.notify === "smart") {
|
|
31
|
+
lines.push(
|
|
32
|
+
"",
|
|
33
|
+
`用户希望只在有需要时才被提醒:如果按上面的要求,这次没有需要告诉他的事(比如没有更新、没有变化、条件没满足),回复的第一行只写 ${SILENT_MARK},后面用一两句话记下这次检查的结果(下次可以对比),他就不会收到通知。`,
|
|
34
|
+
`有需要告诉他的事时正常回复,开头直接说结论,不要写 ${SILENT_MARK}。`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
return `${task.prompt}\n\n${wrapContext(lines)}`;
|
|
38
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// 定时任务的调度:每 30 秒看一眼哪些到点了,核对主人和对话,把提示词送进对话;一轮结束后记下结果。
|
|
2
|
+
//
|
|
3
|
+
// 到点时的核对(任何一条不满足都不跑):
|
|
4
|
+
// - 主人还在、没被停用;
|
|
5
|
+
// - 回到的对话还在、没被删,而且还是他的(飞书私聊换绑了账号之类会让对话换主人)—— 不满足就暂停任务;
|
|
6
|
+
// - 对话没在忙、也没挂着授权 / 提问:忙的话等它(最多 WAIT_MAX_MS),不能插进别人正在进行的那一轮,
|
|
7
|
+
// 更不能被当成对挂着的提问的回答。
|
|
8
|
+
import crypto from "node:crypto";
|
|
9
|
+
import { config, resolveWorkspacePath } from "./config.js";
|
|
10
|
+
import { hub, type TaskTurnEnd } from "./hub.js";
|
|
11
|
+
import { interactions } from "./interactions.js";
|
|
12
|
+
import { memoryOwnerOf } from "./personal.js";
|
|
13
|
+
import { getConversation, updateAgentBinding, updateConversation, type Conversation } from "./store.js";
|
|
14
|
+
import { formatDateTime } from "./task-schedule.js";
|
|
15
|
+
import { stripSilentMark, taskMessage } from "./task-prompt.js";
|
|
16
|
+
import { advance, allTasks, finishRun, pauseTask, persistTask, pushRun, runId, type Task, type TaskRun } from "./tasks.js";
|
|
17
|
+
import { RUNNING_VERSION } from "./upgrade.js";
|
|
18
|
+
import { getUser } from "./users.js";
|
|
19
|
+
|
|
20
|
+
const TICK_MS = 30_000;
|
|
21
|
+
/** 服务停着的时候错过的:错过不到这么久的,启动后补跑一次;更早的记一笔「错过了」 */
|
|
22
|
+
const CATCHUP_MS = 2 * 3600_000;
|
|
23
|
+
/** 到点时对话在忙:最多等这么久,还忙就跳过这一次 */
|
|
24
|
+
const WAIT_MAX_MS = 30 * 60_000;
|
|
25
|
+
/** 送出去之后对话已经不忙了、却一直没等到结果(被别的操作吞掉了):这么久之后按中断记 */
|
|
26
|
+
const LOST_MS = 2 * 60_000;
|
|
27
|
+
|
|
28
|
+
/** 到点了、在等对话空下来的 */
|
|
29
|
+
const waiting = new Map<string, { since: number }>();
|
|
30
|
+
/** 正在跑的:任务 id → 这次运行 */
|
|
31
|
+
const running = new Map<string, { runId: string; key: string; at: number }>();
|
|
32
|
+
let timer: NodeJS.Timeout | null = null;
|
|
33
|
+
|
|
34
|
+
/** 这条对话现在归谁(和记忆同一个规则:飞书群聊、没绑定的私聊没有主人) */
|
|
35
|
+
function ownerOf(conv: Conversation): string | undefined {
|
|
36
|
+
return memoryOwnerOf(conv);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function preview(text: string, max = 120): string {
|
|
40
|
+
const plain = text
|
|
41
|
+
.replace(/```[\s\S]*?```/g, " [代码] ")
|
|
42
|
+
.replace(/[`*_~#>|]/g, "")
|
|
43
|
+
.replace(/\s+/g, " ")
|
|
44
|
+
.trim();
|
|
45
|
+
return plain.length > max ? `${plain.slice(0, max - 1)}…` : plain;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
type Resolved = { key: string } | { pause: string } | { error: string };
|
|
49
|
+
|
|
50
|
+
/** 这次该发到哪条对话;专用对话还没有(或者被删了)就新建一条 */
|
|
51
|
+
function resolveConv(task: Task): Resolved {
|
|
52
|
+
if (task.target.kind === "conv") {
|
|
53
|
+
const conv = getConversation(task.target.key);
|
|
54
|
+
if (!conv || conv.hidden) return { pause: "回复的那条对话已经被删除,任务已暂停。可以改成「专用对话」后再启用。" };
|
|
55
|
+
if (ownerOf(conv) !== task.owner) return { pause: "回复的那条对话已经不属于你了,任务已暂停。" };
|
|
56
|
+
return { key: conv.key };
|
|
57
|
+
}
|
|
58
|
+
const existing = task.target.key ? getConversation(task.target.key) : undefined;
|
|
59
|
+
if (existing && !existing.hidden && existing.taskId === task.id && ownerOf(existing) === task.owner) return { key: existing.key };
|
|
60
|
+
|
|
61
|
+
const enabled = hub.enabledAgents().map((d) => d.id);
|
|
62
|
+
if (!enabled.includes(task.agent)) return { error: `${hub.label(task.agent)} 没有启用` };
|
|
63
|
+
let cwd: string | null = null;
|
|
64
|
+
if (task.cwd) {
|
|
65
|
+
cwd = resolveWorkspacePath(task.cwd);
|
|
66
|
+
if (!cwd) return { error: `工作目录 ${task.cwd} 不存在了(或者不在工作区里),改一下任务的目录` };
|
|
67
|
+
}
|
|
68
|
+
const key = `web:${crypto.randomBytes(6).toString("hex")}`;
|
|
69
|
+
// 侧栏里专用对话前面有时钟图标(taskId),标题不用再加
|
|
70
|
+
hub.ensure({ key, channel: "web", channelId: "web", defaultAgent: task.agent, owner: task.owner, title: task.title.slice(0, 80) });
|
|
71
|
+
updateConversation(key, {
|
|
72
|
+
titleAuto: false,
|
|
73
|
+
taskId: task.id,
|
|
74
|
+
...(task.mode === "chat" ? { mode: "chat" as const } : {}),
|
|
75
|
+
...(cwd && cwd !== config().workspaceRoot ? { cwd, cwdExplicit: true } : {}),
|
|
76
|
+
});
|
|
77
|
+
if (task.model || task.effort) {
|
|
78
|
+
updateAgentBinding(key, task.agent, { ...(task.model ? { model: task.model } : {}), ...(task.effort ? { effort: task.effort } : {}) });
|
|
79
|
+
}
|
|
80
|
+
task.target = { kind: "dedicated", key };
|
|
81
|
+
persistTask(task);
|
|
82
|
+
hub.emitEvent(key, { type: "meta" });
|
|
83
|
+
return { key };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function record(task: Task, status: TaskRun["status"], note: string, now = Date.now()): void {
|
|
87
|
+
pushRun(task, { id: runId(), at: now, endedAt: now, status, note });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 试着跑一次。manual:用户点了「立即运行」—— 对话忙就直接报错,不排队。
|
|
92
|
+
* 返回给人看的错误;undefined 表示已经送出去了(或者在排队等)。
|
|
93
|
+
*/
|
|
94
|
+
function fire(task: Task, manual: boolean, now = Date.now()): string | undefined {
|
|
95
|
+
const user = getUser(task.owner);
|
|
96
|
+
if (!user || user.disabled) {
|
|
97
|
+
waiting.delete(task.id);
|
|
98
|
+
record(task, "skipped", "账号已停用", now);
|
|
99
|
+
return "账号已停用";
|
|
100
|
+
}
|
|
101
|
+
const where = resolveConv(task);
|
|
102
|
+
if ("pause" in where) {
|
|
103
|
+
waiting.delete(task.id);
|
|
104
|
+
pauseTask(task, where.pause, now);
|
|
105
|
+
return where.pause;
|
|
106
|
+
}
|
|
107
|
+
if ("error" in where) {
|
|
108
|
+
waiting.delete(task.id);
|
|
109
|
+
record(task, "error", where.error, now);
|
|
110
|
+
return where.error;
|
|
111
|
+
}
|
|
112
|
+
const key = where.key;
|
|
113
|
+
const conv = getConversation(key)!;
|
|
114
|
+
if (!hub.enabledAgents().some((d) => d.id === conv.agent)) {
|
|
115
|
+
waiting.delete(task.id);
|
|
116
|
+
record(task, "error", `${hub.label(conv.agent)} 没有启用`, now);
|
|
117
|
+
return `${hub.label(conv.agent)} 没有启用`;
|
|
118
|
+
}
|
|
119
|
+
// 刚点过停止的也当忙:被打断的那一轮可能还会晚到一个出错,别让它算到这次运行头上
|
|
120
|
+
if (hub.isBusy(key) || hub.recentlyStopped(key) || interactions.listPending(key).length > 0) {
|
|
121
|
+
if (manual) return "这条对话正在回复或者在等你处理授权,等它结束再试";
|
|
122
|
+
const since = waiting.get(task.id)?.since ?? now;
|
|
123
|
+
if (now - since > WAIT_MAX_MS) {
|
|
124
|
+
waiting.delete(task.id);
|
|
125
|
+
record(task, "skipped", `对话一直在忙(等了 ${WAIT_MAX_MS / 60_000} 分钟),跳过了这一次`, now);
|
|
126
|
+
} else {
|
|
127
|
+
waiting.set(task.id, { since });
|
|
128
|
+
}
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
waiting.delete(task.id);
|
|
132
|
+
const run = pushRun(task, { id: runId(), at: now, status: "running", convKey: key, ...(manual ? { note: "手动运行" } : {}) });
|
|
133
|
+
running.set(task.id, { runId: run.id, key, at: now });
|
|
134
|
+
console.log(`[定时任务] ${task.id}「${task.title}」${manual ? "手动运行" : "到点运行"},发到 ${key}`);
|
|
135
|
+
hub.send(
|
|
136
|
+
key,
|
|
137
|
+
{ text: taskMessage(task, { now, manual, version: RUNNING_VERSION }), attachments: [] },
|
|
138
|
+
{ channel: "task", channelId: task.id, name: `定时任务「${task.title}」` },
|
|
139
|
+
undefined,
|
|
140
|
+
{ task: { taskId: task.id, runId: run.id, title: task.title, smart: task.notify === "smart" } },
|
|
141
|
+
);
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function onTurnEnd(e: TaskTurnEnd): void {
|
|
146
|
+
const cur = running.get(e.taskId);
|
|
147
|
+
if (cur?.runId === e.runId) running.delete(e.taskId);
|
|
148
|
+
const text = e.status === "notified" || e.status === "silent" ? preview(stripSilentMark(e.text)) : "";
|
|
149
|
+
finishRun(e.taskId, e.runId, {
|
|
150
|
+
status: e.status,
|
|
151
|
+
endedAt: Date.now(),
|
|
152
|
+
...(text ? { preview: text } : {}),
|
|
153
|
+
...(e.status === "error" || e.status === "interrupted" ? { note: preview(e.text) } : {}),
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function tick(): void {
|
|
158
|
+
const now = Date.now();
|
|
159
|
+
for (const [taskId, cur] of running) {
|
|
160
|
+
// 送出去了,对话早就不忙了,却没等到结果:被吞掉了(比如期间切了会话),别让它永远「运行中」
|
|
161
|
+
if (now - cur.at > LOST_MS && !hub.isBusy(cur.key) && hub.currentTaskTurn(cur.key)?.runId !== cur.runId) {
|
|
162
|
+
running.delete(taskId);
|
|
163
|
+
finishRun(taskId, cur.runId, { status: "interrupted", endedAt: now, note: "没有拿到结果" });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
for (const task of allTasks()) {
|
|
167
|
+
if (running.has(task.id)) continue;
|
|
168
|
+
if (waiting.has(task.id)) {
|
|
169
|
+
// 等着的时候被暂停了:这次就不跑了(「一次」的任务到点时已经自动停用,照样要跑完这一次)
|
|
170
|
+
if (!task.enabled && task.schedule.kind !== "once") waiting.delete(task.id);
|
|
171
|
+
else fire(task, false, now);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (!task.enabled || task.nextRunAt === undefined || task.nextRunAt > now) continue;
|
|
175
|
+
// 先把下一次算好再跑:跑的过程中出了什么岔子,也不会在下一个 tick 又跑一遍
|
|
176
|
+
advance(task, now);
|
|
177
|
+
fire(task, false, now);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** 网页上点「立即运行」 */
|
|
182
|
+
export function runTaskNow(task: Task): string | undefined {
|
|
183
|
+
if (running.has(task.id)) return "这个任务正在运行";
|
|
184
|
+
return fire(task, true);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function isTaskRunning(id: string): boolean {
|
|
188
|
+
return running.has(id) || waiting.has(id);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** 任务删了:它的专用对话留着(里面有历史),只是不再是谁的专用对话 */
|
|
192
|
+
export function forgetTask(task: Task): void {
|
|
193
|
+
waiting.delete(task.id);
|
|
194
|
+
running.delete(task.id);
|
|
195
|
+
if (task.target.kind === "dedicated" && task.target.key) {
|
|
196
|
+
const conv = getConversation(task.target.key);
|
|
197
|
+
if (conv?.taskId === task.id) {
|
|
198
|
+
updateConversation(conv.key, { taskId: undefined });
|
|
199
|
+
hub.emitEvent(conv.key, { type: "meta" });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function startTaskRunner(): void {
|
|
205
|
+
if (timer) return;
|
|
206
|
+
const now = Date.now();
|
|
207
|
+
for (const task of allTasks()) {
|
|
208
|
+
// 上次进程退出时还在跑的:结果已经拿不到了
|
|
209
|
+
let dirty = false;
|
|
210
|
+
for (const run of task.runs) {
|
|
211
|
+
if (run.status !== "running") continue;
|
|
212
|
+
Object.assign(run, { status: "interrupted", endedAt: now, note: "服务重启,这次运行被打断了" });
|
|
213
|
+
dirty = true;
|
|
214
|
+
}
|
|
215
|
+
if (dirty) persistTask(task);
|
|
216
|
+
if (task.enabled && task.nextRunAt !== undefined && task.nextRunAt < now - CATCHUP_MS) {
|
|
217
|
+
record(task, "skipped", `服务没在运行,错过了 ${formatDateTime(task.nextRunAt)} 这一次`, now);
|
|
218
|
+
advance(task, now);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
hub.on("task", onTurnEnd);
|
|
222
|
+
timer = setInterval(tick, TICK_MS);
|
|
223
|
+
timer.unref();
|
|
224
|
+
// 等 agent 起来了再补跑错过的
|
|
225
|
+
setTimeout(tick, 5_000).unref();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function stopTaskRunner(): void {
|
|
229
|
+
if (timer) clearInterval(timer);
|
|
230
|
+
timer = null;
|
|
231
|
+
hub.off("task", onTurnEnd);
|
|
232
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// 定时任务的「什么时候跑」:几种常用的结构化写法 + 自定义 cron,一律按服务端所在时区解释。
|
|
2
|
+
//
|
|
3
|
+
// 存结构化的而不是一律存 cron:界面上的「每天 / 工作日 / 每周…」能原样回填,描述也好写;
|
|
4
|
+
// 除「一次」以外都先换成 cron 再算下次时间,只有一条计算路径。
|
|
5
|
+
|
|
6
|
+
export type TaskSchedule =
|
|
7
|
+
| { kind: "once"; at: number }
|
|
8
|
+
| { kind: "daily"; time: string }
|
|
9
|
+
| { kind: "weekdays"; time: string }
|
|
10
|
+
/** days:0 = 周日 … 6 = 周六 */
|
|
11
|
+
| { kind: "weekly"; days: number[]; time: string }
|
|
12
|
+
| { kind: "monthly"; day: number; time: string }
|
|
13
|
+
/** 每 every 小时一次,在第 minute 分(从 0 点起算:every=6 就是 0、6、12、18 点) */
|
|
14
|
+
| { kind: "hourly"; every: number; minute: number }
|
|
15
|
+
| { kind: "cron"; expr: string };
|
|
16
|
+
|
|
17
|
+
/** 两次运行至少隔这么久:太密了会把额度跑光 */
|
|
18
|
+
export const MIN_GAP_MS = 15 * 60_000;
|
|
19
|
+
/** 「一次」最多排到多久以后 */
|
|
20
|
+
export const ONCE_MAX_MS = 366 * 86400_000;
|
|
21
|
+
|
|
22
|
+
export function serverTimeZone(): string {
|
|
23
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || "本地时间";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ---------- cron ----------
|
|
27
|
+
|
|
28
|
+
type CronSpec = { minute: Set<number>; hour: Set<number>; dom: Set<number>; month: Set<number>; dow: Set<number>; domAny: boolean; dowAny: boolean };
|
|
29
|
+
|
|
30
|
+
const RANGES: [number, number][] = [
|
|
31
|
+
[0, 59],
|
|
32
|
+
[0, 23],
|
|
33
|
+
[1, 31],
|
|
34
|
+
[1, 12],
|
|
35
|
+
[0, 7],
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
function parseField(raw: string, [min, max]: [number, number], name: string): Set<number> {
|
|
39
|
+
const out = new Set<number>();
|
|
40
|
+
for (const part of raw.split(",")) {
|
|
41
|
+
const m = /^(\*|(\d+)(?:-(\d+))?)(?:\/(\d+))?$/.exec(part.trim());
|
|
42
|
+
if (!m) throw new Error(`cron 的${name}字段写得不对:${raw}`);
|
|
43
|
+
const step = m[4] ? Number(m[4]) : 1;
|
|
44
|
+
let lo = min;
|
|
45
|
+
let hi = max;
|
|
46
|
+
if (m[1] !== "*") {
|
|
47
|
+
lo = Number(m[2]);
|
|
48
|
+
// 「5/15」= 从 5 起每 15
|
|
49
|
+
hi = m[3] !== undefined ? Number(m[3]) : m[4] ? max : lo;
|
|
50
|
+
}
|
|
51
|
+
if (step < 1 || lo < min || hi > max || lo > hi) throw new Error(`cron 的${name}字段超出范围:${raw}`);
|
|
52
|
+
for (let v = lo; v <= hi; v += step) out.add(v);
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function parseCron(expr: string): CronSpec {
|
|
58
|
+
const fields = expr.trim().split(/\s+/);
|
|
59
|
+
if (fields.length !== 5) throw new Error("cron 要写 5 段:分 时 日 月 周,比如「0 2 * * *」是每天 2 点");
|
|
60
|
+
const names = ["分钟", "小时", "日期", "月份", "星期"];
|
|
61
|
+
const [minute, hour, dom, month, dowRaw] = fields.map((f, i) => parseField(f, RANGES[i]!, names[i]!));
|
|
62
|
+
const dow = new Set([...dowRaw!].map((d) => d % 7));
|
|
63
|
+
return { minute: minute!, hour: hour!, dom: dom!, month: month!, dow, domAny: fields[2] === "*", dowAny: fields[4] === "*" };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function dayMatches(spec: CronSpec, d: Date): boolean {
|
|
67
|
+
const dom = spec.dom.has(d.getDate());
|
|
68
|
+
const dow = spec.dow.has(d.getDay());
|
|
69
|
+
// 和标准 cron 一样:日期、星期都限定了的时候,满足其一就算
|
|
70
|
+
if (spec.domAny && spec.dowAny) return true;
|
|
71
|
+
if (spec.domAny) return dow;
|
|
72
|
+
if (spec.dowAny) return dom;
|
|
73
|
+
return dom || dow;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** from 之后(不含)最近一次匹配的时刻;一年内都没有就是 undefined */
|
|
77
|
+
export function cronNext(spec: CronSpec, from: number): number | undefined {
|
|
78
|
+
const d = new Date(from);
|
|
79
|
+
d.setSeconds(0, 0);
|
|
80
|
+
d.setMinutes(d.getMinutes() + 1);
|
|
81
|
+
const limit = from + 400 * 86400_000;
|
|
82
|
+
while (d.getTime() <= limit) {
|
|
83
|
+
if (!spec.month.has(d.getMonth() + 1)) {
|
|
84
|
+
d.setMonth(d.getMonth() + 1, 1);
|
|
85
|
+
d.setHours(0, 0, 0, 0);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (!dayMatches(spec, d)) {
|
|
89
|
+
d.setDate(d.getDate() + 1);
|
|
90
|
+
d.setHours(0, 0, 0, 0);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (!spec.hour.has(d.getHours())) {
|
|
94
|
+
d.setHours(d.getHours() + 1, 0, 0, 0);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (!spec.minute.has(d.getMinutes())) {
|
|
98
|
+
d.setMinutes(d.getMinutes() + 1, 0, 0);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
return d.getTime();
|
|
102
|
+
}
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---------- 结构化写法 ----------
|
|
107
|
+
|
|
108
|
+
function parseTime(time: string): [number, number] {
|
|
109
|
+
const m = /^(\d{1,2}):(\d{2})$/.exec(String(time ?? "").trim());
|
|
110
|
+
if (!m || Number(m[1]) > 23 || Number(m[2]) > 59) throw new Error(`时间要写成 HH:MM,比如 02:00(收到的是「${time}」)`);
|
|
111
|
+
return [Number(m[1]), Number(m[2])];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function toCron(s: Exclude<TaskSchedule, { kind: "once" }>): string {
|
|
115
|
+
switch (s.kind) {
|
|
116
|
+
case "daily": {
|
|
117
|
+
const [h, m] = parseTime(s.time);
|
|
118
|
+
return `${m} ${h} * * *`;
|
|
119
|
+
}
|
|
120
|
+
case "weekdays": {
|
|
121
|
+
const [h, m] = parseTime(s.time);
|
|
122
|
+
return `${m} ${h} * * 1-5`;
|
|
123
|
+
}
|
|
124
|
+
case "weekly": {
|
|
125
|
+
const [h, m] = parseTime(s.time);
|
|
126
|
+
return `${m} ${h} * * ${[...new Set(s.days)].sort((a, b) => a - b).join(",")}`;
|
|
127
|
+
}
|
|
128
|
+
case "monthly": {
|
|
129
|
+
const [h, m] = parseTime(s.time);
|
|
130
|
+
return `${m} ${h} ${s.day} * *`;
|
|
131
|
+
}
|
|
132
|
+
case "hourly":
|
|
133
|
+
return `${s.minute} ${s.every === 1 ? "*" : `*/${s.every}`} * * *`;
|
|
134
|
+
case "cron":
|
|
135
|
+
return s.expr.trim();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const int = (v: unknown, lo: number, hi: number, what: string): number => {
|
|
140
|
+
const n = Number(v);
|
|
141
|
+
if (!Number.isInteger(n) || n < lo || n > hi) throw new Error(`${what}要是 ${lo}-${hi} 之间的整数`);
|
|
142
|
+
return n;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/** 校验并规整(来自网页表单或 agent 的工具调用,都不可信)。不合规直接抛出给人看的错误 */
|
|
146
|
+
export function normalizeSchedule(raw: unknown, now = Date.now()): TaskSchedule {
|
|
147
|
+
const r = (raw ?? {}) as Record<string, unknown>;
|
|
148
|
+
let s: Exclude<TaskSchedule, { kind: "once" }>;
|
|
149
|
+
switch (r.kind) {
|
|
150
|
+
case "once": {
|
|
151
|
+
const at = Number(r.at);
|
|
152
|
+
if (!Number.isFinite(at)) throw new Error("「一次」要给运行时间");
|
|
153
|
+
if (at < now - 60_000) throw new Error("运行时间要在现在之后");
|
|
154
|
+
if (at > now + ONCE_MAX_MS) throw new Error("最多只能排到一年以后");
|
|
155
|
+
return { kind: "once", at: Math.floor(at / 60_000) * 60_000 };
|
|
156
|
+
}
|
|
157
|
+
case "daily":
|
|
158
|
+
case "weekdays":
|
|
159
|
+
s = { kind: r.kind, time: String(r.time ?? "") };
|
|
160
|
+
break;
|
|
161
|
+
case "weekly": {
|
|
162
|
+
const days = Array.isArray(r.days) ? r.days.map((d) => int(d, 0, 7, "星期") % 7) : [];
|
|
163
|
+
if (days.length === 0) throw new Error("「每周」至少选一天");
|
|
164
|
+
s = { kind: "weekly", days: [...new Set(days)].sort((a, b) => a - b), time: String(r.time ?? "") };
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
case "monthly":
|
|
168
|
+
s = { kind: "monthly", day: int(r.day, 1, 31, "日期"), time: String(r.time ?? "") };
|
|
169
|
+
break;
|
|
170
|
+
case "hourly":
|
|
171
|
+
s = { kind: "hourly", every: int(r.every, 1, 23, "间隔小时数"), minute: int(r.minute ?? 0, 0, 59, "分钟") };
|
|
172
|
+
break;
|
|
173
|
+
case "cron":
|
|
174
|
+
s = { kind: "cron", expr: String(r.expr ?? "").trim().replace(/\s+/g, " ") };
|
|
175
|
+
break;
|
|
176
|
+
default:
|
|
177
|
+
throw new Error("不认识的频率");
|
|
178
|
+
}
|
|
179
|
+
if ("time" in s) {
|
|
180
|
+
const [h, m] = parseTime(s.time);
|
|
181
|
+
s.time = `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
|
|
182
|
+
}
|
|
183
|
+
const spec = parseCron(toCron(s));
|
|
184
|
+
// 抽查接下来的几次,相邻两次不能太近
|
|
185
|
+
let prev = cronNext(spec, now);
|
|
186
|
+
if (prev === undefined) throw new Error("按这个写法一年内都不会运行");
|
|
187
|
+
for (let i = 0; i < 8; i++) {
|
|
188
|
+
const next = cronNext(spec, prev);
|
|
189
|
+
if (next === undefined) break;
|
|
190
|
+
if (next - prev < MIN_GAP_MS) throw new Error(`太频繁了:两次运行至少要隔 ${MIN_GAP_MS / 60_000} 分钟`);
|
|
191
|
+
prev = next;
|
|
192
|
+
}
|
|
193
|
+
return s;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** from 之后下一次该跑的时刻;「一次」跑过了就没有 */
|
|
197
|
+
export function nextRun(s: TaskSchedule, from: number): number | undefined {
|
|
198
|
+
if (s.kind === "once") return s.at > from ? s.at : undefined;
|
|
199
|
+
try {
|
|
200
|
+
return cronNext(parseCron(toCron(s)), from);
|
|
201
|
+
} catch {
|
|
202
|
+
return undefined;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ---------- 描述 ----------
|
|
207
|
+
|
|
208
|
+
const WEEK = ["日", "一", "二", "三", "四", "五", "六"];
|
|
209
|
+
|
|
210
|
+
export function formatClock(ms: number): string {
|
|
211
|
+
const d = new Date(ms);
|
|
212
|
+
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function formatDateTime(ms: number): string {
|
|
216
|
+
const d = new Date(ms);
|
|
217
|
+
const p = (n: number) => String(n).padStart(2, "0");
|
|
218
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${formatClock(ms)}`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** 给人看的一句话:「每天 02:00」「工作日 09:30」「每周一、三 08:00」 */
|
|
222
|
+
export function describeSchedule(s: TaskSchedule): string {
|
|
223
|
+
switch (s.kind) {
|
|
224
|
+
case "once":
|
|
225
|
+
return `一次:${formatDateTime(s.at)}`;
|
|
226
|
+
case "daily":
|
|
227
|
+
return `每天 ${s.time}`;
|
|
228
|
+
case "weekdays":
|
|
229
|
+
return `工作日 ${s.time}`;
|
|
230
|
+
case "weekly":
|
|
231
|
+
return s.days.length === 7 ? `每天 ${s.time}` : `每周${s.days.map((d) => WEEK[d]).join("、")} ${s.time}`;
|
|
232
|
+
case "monthly":
|
|
233
|
+
return `每月 ${s.day} 日 ${s.time}`;
|
|
234
|
+
case "hourly":
|
|
235
|
+
return s.every === 1 ? `每小时第 ${s.minute} 分` : `每 ${s.every} 小时(第 ${s.minute} 分)`;
|
|
236
|
+
case "cron":
|
|
237
|
+
return `cron:${s.expr}`;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* 「2026-10-23 15:00」这类本地时间(agent 给的),也认带时区的 ISO 写法。解析不了返回 NaN。
|
|
243
|
+
* 不带时区的一律按服务端时区解释 —— Date.parse 对「YYYY-MM-DDTHH:MM」本来就是按本地时间。
|
|
244
|
+
*/
|
|
245
|
+
export function parseLocalDateTime(text: string): number {
|
|
246
|
+
const t = String(text ?? "").trim();
|
|
247
|
+
const m = /^(\d{4})-(\d{1,2})-(\d{1,2})[ T](\d{1,2}):(\d{2})(?::\d{2})?$/.exec(t);
|
|
248
|
+
if (m) return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4]), Number(m[5])).getTime();
|
|
249
|
+
return Date.parse(t);
|
|
250
|
+
}
|