@aaroncql/pim-agent 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +212 -0
- package/bin/pim.ts +109 -0
- package/package.json +49 -0
- package/src/extensions/_init/index.ts +109 -0
- package/src/extensions/bash/capture.test.ts +126 -0
- package/src/extensions/bash/capture.ts +80 -0
- package/src/extensions/bash/format.test.ts +240 -0
- package/src/extensions/bash/format.ts +76 -0
- package/src/extensions/bash/index.ts +86 -0
- package/src/extensions/bash/run.test.ts +262 -0
- package/src/extensions/bash/run.ts +207 -0
- package/src/extensions/bash/schema.ts +54 -0
- package/src/extensions/command-picker/index.ts +52 -0
- package/src/extensions/command-picker/ranker.test.ts +46 -0
- package/src/extensions/command-picker/ranker.ts +17 -0
- package/src/extensions/edit/edit.test.ts +285 -0
- package/src/extensions/edit/edit.ts +382 -0
- package/src/extensions/edit/index.ts +54 -0
- package/src/extensions/edit/schema.ts +37 -0
- package/src/extensions/file-picker/catalog.test.ts +263 -0
- package/src/extensions/file-picker/catalog.ts +219 -0
- package/src/extensions/file-picker/index.test.ts +168 -0
- package/src/extensions/file-picker/index.ts +119 -0
- package/src/extensions/file-picker/ranker.test.ts +94 -0
- package/src/extensions/file-picker/ranker.ts +76 -0
- package/src/extensions/footer/git.test.ts +76 -0
- package/src/extensions/footer/git.ts +87 -0
- package/src/extensions/footer/index.test.ts +161 -0
- package/src/extensions/footer/index.ts +148 -0
- package/src/extensions/footer/powerline.ts +87 -0
- package/src/extensions/footer/segments.test.ts +164 -0
- package/src/extensions/footer/segments.ts +234 -0
- package/src/extensions/glob/glob.test.ts +171 -0
- package/src/extensions/glob/glob.ts +34 -0
- package/src/extensions/glob/index.test.ts +68 -0
- package/src/extensions/glob/index.ts +136 -0
- package/src/extensions/glob/render.test.ts +126 -0
- package/src/extensions/glob/render.ts +74 -0
- package/src/extensions/glob/schema.ts +52 -0
- package/src/extensions/grep/grep.test.ts +387 -0
- package/src/extensions/grep/grep.ts +215 -0
- package/src/extensions/grep/index.test.ts +68 -0
- package/src/extensions/grep/index.ts +158 -0
- package/src/extensions/grep/render.test.ts +269 -0
- package/src/extensions/grep/render.ts +243 -0
- package/src/extensions/grep/schema.ts +92 -0
- package/src/extensions/read/index.ts +84 -0
- package/src/extensions/read/read.test.ts +177 -0
- package/src/extensions/read/read.ts +206 -0
- package/src/extensions/read/render.test.ts +61 -0
- package/src/extensions/read/render.ts +33 -0
- package/src/extensions/read/schema.ts +27 -0
- package/src/extensions/subagent/index.test.ts +44 -0
- package/src/extensions/subagent/index.ts +30 -0
- package/src/extensions/subagent/render.test.ts +292 -0
- package/src/extensions/subagent/render.ts +359 -0
- package/src/extensions/subagent/schema.ts +9 -0
- package/src/extensions/subagent/subagent.test.ts +315 -0
- package/src/extensions/subagent/subagent.ts +418 -0
- package/src/extensions/system-prompt/index.ts +28 -0
- package/src/extensions/system-prompt/prompt.test.ts +64 -0
- package/src/extensions/system-prompt/prompt.ts +213 -0
- package/src/extensions/todo/index.test.ts +244 -0
- package/src/extensions/todo/index.ts +122 -0
- package/src/extensions/todo/render.test.ts +180 -0
- package/src/extensions/todo/render.ts +172 -0
- package/src/extensions/todo/schema.ts +24 -0
- package/src/extensions/todo/todo.test.ts +222 -0
- package/src/extensions/todo/todo.ts +188 -0
- package/src/extensions/tps/index.test.ts +254 -0
- package/src/extensions/tps/index.ts +136 -0
- package/src/extensions/web-fetch/JinaReaderClient.ts +230 -0
- package/src/extensions/web-fetch/WebViewFetchClient.ts +186 -0
- package/src/extensions/web-fetch/WebViewMarkdownSnapshot.test.ts +119 -0
- package/src/extensions/web-fetch/WebViewMarkdownSnapshot.ts +511 -0
- package/src/extensions/web-fetch/fetch.test.ts +244 -0
- package/src/extensions/web-fetch/fetch.ts +249 -0
- package/src/extensions/web-fetch/index.ts +107 -0
- package/src/extensions/web-fetch/render.test.ts +56 -0
- package/src/extensions/web-fetch/render.ts +39 -0
- package/src/extensions/web-fetch/schema.ts +23 -0
- package/src/extensions/web-search/ExaMcpClient.test.ts +143 -0
- package/src/extensions/web-search/ExaMcpClient.ts +258 -0
- package/src/extensions/web-search/index.ts +118 -0
- package/src/extensions/web-search/render.test.ts +21 -0
- package/src/extensions/web-search/render.ts +9 -0
- package/src/extensions/web-search/schema.ts +21 -0
- package/src/extensions/web-search/search.test.ts +53 -0
- package/src/extensions/web-search/search.ts +23 -0
- package/src/extensions/working-indicator/index.test.ts +21 -0
- package/src/extensions/working-indicator/index.ts +77 -0
- package/src/extensions/write/index.ts +76 -0
- package/src/extensions/write/render.test.ts +64 -0
- package/src/extensions/write/schema.ts +14 -0
- package/src/extensions/write/write.test.ts +108 -0
- package/src/extensions/write/write.ts +104 -0
- package/src/shared/DiffLines.test.ts +193 -0
- package/src/shared/DiffLines.ts +307 -0
- package/src/shared/DiffRenderer.test.ts +206 -0
- package/src/shared/DiffRenderer.ts +396 -0
- package/src/shared/DiffView.ts +199 -0
- package/src/shared/EditMatcher.test.ts +123 -0
- package/src/shared/EditMatcher.ts +826 -0
- package/src/shared/FileScanner.test.ts +158 -0
- package/src/shared/FileScanner.ts +41 -0
- package/src/shared/Fs.ts +46 -0
- package/src/shared/FsErrors.ts +72 -0
- package/src/shared/FuzzyMatcher.test.ts +114 -0
- package/src/shared/FuzzyMatcher.ts +73 -0
- package/src/shared/GitignoreFilter.test.ts +64 -0
- package/src/shared/GitignoreFilter.ts +142 -0
- package/src/shared/GlobExclusions.ts +23 -0
- package/src/shared/Levenshtein.ts +33 -0
- package/src/shared/Lines.test.ts +25 -0
- package/src/shared/Lines.ts +77 -0
- package/src/shared/McpClient.test.ts +235 -0
- package/src/shared/McpClient.ts +406 -0
- package/src/shared/OutputBudget.test.ts +99 -0
- package/src/shared/OutputBudget.ts +79 -0
- package/src/shared/Paths.test.ts +51 -0
- package/src/shared/Paths.ts +52 -0
- package/src/shared/PimSettings.test.ts +90 -0
- package/src/shared/PimSettings.ts +124 -0
- package/src/shared/Renderer.test.ts +190 -0
- package/src/shared/Renderer.ts +256 -0
- package/src/shared/SpillCache.test.ts +94 -0
- package/src/shared/SpillCache.ts +89 -0
- package/src/shared/Tools.test.ts +392 -0
- package/src/shared/Tools.ts +636 -0
- package/src/telegram/Bot.ts +198 -0
- package/src/telegram/Commands.ts +721 -0
- package/src/telegram/Config.test.ts +275 -0
- package/src/telegram/Config.ts +162 -0
- package/src/telegram/Markdown.test.ts +143 -0
- package/src/telegram/Markdown.ts +177 -0
- package/src/telegram/Message.ts +211 -0
- package/src/telegram/Renderer.test.ts +216 -0
- package/src/telegram/Renderer.ts +713 -0
- package/src/telegram/SendFileSchema.ts +19 -0
- package/src/telegram/SendFileTool.ts +94 -0
- package/src/telegram/Session.ts +579 -0
- package/src/telegram/SessionRegistry.test.ts +89 -0
- package/src/telegram/SessionRegistry.ts +170 -0
- package/src/telegram/Supervisor.ts +357 -0
- package/src/telegram/TaskScheduler.test.ts +278 -0
- package/src/telegram/TaskScheduler.ts +293 -0
- package/src/telegram/TaskSchema.ts +88 -0
- package/src/telegram/TaskStore.ts +73 -0
- package/src/telegram/TaskTool.test.ts +179 -0
- package/src/telegram/TaskTool.ts +159 -0
- package/src/telegram/TypingIndicator.ts +43 -0
- package/src/telegram/index.ts +32 -0
- package/src/themes/pim-dark.json +84 -0
- package/src/themes/pim-light.json +84 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { mkdtemp, readdir, rm } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
5
|
+
|
|
6
|
+
import type { SessionId } from "./Session";
|
|
7
|
+
import { TaskScheduler } from "./TaskScheduler";
|
|
8
|
+
import type { ScheduledTask } from "./TaskSchema";
|
|
9
|
+
import { TaskStore } from "./TaskStore";
|
|
10
|
+
|
|
11
|
+
let tmp: string;
|
|
12
|
+
const sessionId: SessionId = { chatId: 42, threadId: undefined };
|
|
13
|
+
|
|
14
|
+
beforeEach(async () => {
|
|
15
|
+
tmp = await mkdtemp(join(tmpdir(), "pim-scheduler-test-"));
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterEach(async () => {
|
|
19
|
+
await rm(tmp, { recursive: true, force: true });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
function makeScheduler(opts: {
|
|
23
|
+
readonly now: () => number;
|
|
24
|
+
readonly onFire?: (task: ScheduledTask) => Promise<void>;
|
|
25
|
+
}): { readonly scheduler: TaskScheduler; readonly fired: ScheduledTask[] } {
|
|
26
|
+
const fired: ScheduledTask[] = [];
|
|
27
|
+
const scheduler = new TaskScheduler({
|
|
28
|
+
configDir: tmp,
|
|
29
|
+
runTask: async (task) => {
|
|
30
|
+
fired.push(task);
|
|
31
|
+
await opts.onFire?.(task);
|
|
32
|
+
},
|
|
33
|
+
now: opts.now,
|
|
34
|
+
});
|
|
35
|
+
return { scheduler, fired };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function listTaskFiles(): Promise<string[]> {
|
|
39
|
+
try {
|
|
40
|
+
return await readdir(join(tmp, "tasks"));
|
|
41
|
+
} catch {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe("TaskScheduler.parseDuration", () => {
|
|
47
|
+
test("parses simple units", () => {
|
|
48
|
+
expect(TaskScheduler.parseDuration("30s")).toBe(30_000);
|
|
49
|
+
expect(TaskScheduler.parseDuration("5m")).toBe(300_000);
|
|
50
|
+
expect(TaskScheduler.parseDuration("2h")).toBe(7_200_000);
|
|
51
|
+
expect(TaskScheduler.parseDuration("1d")).toBe(86_400_000);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("parses compound durations", () => {
|
|
55
|
+
expect(TaskScheduler.parseDuration("1h30m")).toBe(5_400_000);
|
|
56
|
+
expect(TaskScheduler.parseDuration("2h15m30s")).toBe(8_130_000);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("rejects garbage", () => {
|
|
60
|
+
expect(() => TaskScheduler.parseDuration("")).toThrow();
|
|
61
|
+
expect(() => TaskScheduler.parseDuration("forever")).toThrow();
|
|
62
|
+
expect(() => TaskScheduler.parseDuration("10")).toThrow();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("TaskScheduler.tick", () => {
|
|
67
|
+
test("fires due active task and reschedules interval", async () => {
|
|
68
|
+
const t0 = Date.parse("2026-05-14T12:00:00Z");
|
|
69
|
+
const { scheduler, fired } = makeScheduler({ now: () => t0 });
|
|
70
|
+
const task = await scheduler.create(sessionId, {
|
|
71
|
+
prompt: "test",
|
|
72
|
+
schedule: { type: "interval", every: "30m" },
|
|
73
|
+
});
|
|
74
|
+
expect(task.nextRun).toBe(new Date(t0 + 30 * 60_000).toISOString());
|
|
75
|
+
|
|
76
|
+
// Not yet due
|
|
77
|
+
await scheduler.tick();
|
|
78
|
+
expect(fired).toHaveLength(0);
|
|
79
|
+
|
|
80
|
+
// 30 min later, due
|
|
81
|
+
const t1 = t0 + 30 * 60_000 + 5_000;
|
|
82
|
+
const later = makeScheduler({ now: () => t1 });
|
|
83
|
+
await later.scheduler.tick();
|
|
84
|
+
expect(later.fired).toHaveLength(1);
|
|
85
|
+
expect(later.fired[0]!.id).toBe(task.id);
|
|
86
|
+
|
|
87
|
+
const reloaded = (await TaskStore.loadAll(tmp))[0]!;
|
|
88
|
+
expect(reloaded.nextRun).toBe(new Date(t1 + 30 * 60_000).toISOString());
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("skips paused tasks", async () => {
|
|
92
|
+
const t0 = Date.parse("2026-05-14T12:00:00Z");
|
|
93
|
+
const { scheduler } = makeScheduler({ now: () => t0 });
|
|
94
|
+
const task = await scheduler.create(sessionId, {
|
|
95
|
+
prompt: "test",
|
|
96
|
+
schedule: { type: "interval", every: "5m" },
|
|
97
|
+
});
|
|
98
|
+
await scheduler.setStatus(sessionId, task.id, "paused");
|
|
99
|
+
|
|
100
|
+
const t1 = t0 + 6 * 60_000;
|
|
101
|
+
const { scheduler: s2, fired } = makeScheduler({ now: () => t1 });
|
|
102
|
+
await s2.tick();
|
|
103
|
+
expect(fired).toHaveLength(0);
|
|
104
|
+
|
|
105
|
+
// File still exists
|
|
106
|
+
expect(await listTaskFiles()).toHaveLength(1);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("once task is deleted after firing", async () => {
|
|
110
|
+
const t0 = Date.parse("2026-05-14T12:00:00Z");
|
|
111
|
+
const { scheduler } = makeScheduler({ now: () => t0 });
|
|
112
|
+
const at = new Date(t0 + 60_000).toISOString();
|
|
113
|
+
await scheduler.create(sessionId, {
|
|
114
|
+
prompt: "test",
|
|
115
|
+
schedule: { type: "once", at },
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const t1 = t0 + 90_000;
|
|
119
|
+
const { scheduler: s2, fired } = makeScheduler({ now: () => t1 });
|
|
120
|
+
await s2.tick();
|
|
121
|
+
expect(fired).toHaveLength(1);
|
|
122
|
+
expect(await listTaskFiles()).toHaveLength(0);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("missed >24h is advanced silently without firing", async () => {
|
|
126
|
+
const t0 = Date.parse("2026-05-14T12:00:00Z");
|
|
127
|
+
// Manually write a task whose nextRun is 26h in the past
|
|
128
|
+
const stale: ScheduledTask = {
|
|
129
|
+
id: "stale-task",
|
|
130
|
+
prompt: "test",
|
|
131
|
+
chatId: sessionId.chatId,
|
|
132
|
+
threadId: sessionId.threadId,
|
|
133
|
+
schedule: { type: "interval", every: "1h" },
|
|
134
|
+
status: "active",
|
|
135
|
+
nextRun: new Date(t0 - 26 * 3600_000).toISOString(),
|
|
136
|
+
expires: null,
|
|
137
|
+
isolatedSession: false,
|
|
138
|
+
createdAt: new Date(t0 - 30 * 3600_000).toISOString(),
|
|
139
|
+
};
|
|
140
|
+
await TaskStore.save(tmp, stale);
|
|
141
|
+
|
|
142
|
+
const { scheduler, fired } = makeScheduler({ now: () => t0 });
|
|
143
|
+
await scheduler.tick();
|
|
144
|
+
expect(fired).toHaveLength(0);
|
|
145
|
+
|
|
146
|
+
const reloaded = (await TaskStore.loadAll(tmp))[0]!;
|
|
147
|
+
expect(Date.parse(reloaded.nextRun)).toBeGreaterThan(t0);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("missed <24h fires once", async () => {
|
|
151
|
+
const t0 = Date.parse("2026-05-14T12:00:00Z");
|
|
152
|
+
const stale: ScheduledTask = {
|
|
153
|
+
id: "recent-miss",
|
|
154
|
+
prompt: "test",
|
|
155
|
+
chatId: sessionId.chatId,
|
|
156
|
+
threadId: sessionId.threadId,
|
|
157
|
+
schedule: { type: "interval", every: "1h" },
|
|
158
|
+
status: "active",
|
|
159
|
+
nextRun: new Date(t0 - 2 * 3600_000).toISOString(),
|
|
160
|
+
expires: null,
|
|
161
|
+
isolatedSession: false,
|
|
162
|
+
createdAt: new Date(t0 - 5 * 3600_000).toISOString(),
|
|
163
|
+
};
|
|
164
|
+
await TaskStore.save(tmp, stale);
|
|
165
|
+
|
|
166
|
+
const { scheduler, fired } = makeScheduler({ now: () => t0 });
|
|
167
|
+
await scheduler.tick();
|
|
168
|
+
expect(fired).toHaveLength(1);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("expired task is deleted without firing", async () => {
|
|
172
|
+
const t0 = Date.parse("2026-05-14T12:00:00Z");
|
|
173
|
+
const expired: ScheduledTask = {
|
|
174
|
+
id: "expired",
|
|
175
|
+
prompt: "test",
|
|
176
|
+
chatId: sessionId.chatId,
|
|
177
|
+
threadId: sessionId.threadId,
|
|
178
|
+
schedule: { type: "interval", every: "1h" },
|
|
179
|
+
status: "active",
|
|
180
|
+
nextRun: new Date(t0 - 60_000).toISOString(),
|
|
181
|
+
expires: new Date(t0 - 30_000).toISOString(),
|
|
182
|
+
isolatedSession: false,
|
|
183
|
+
createdAt: new Date(t0 - 2 * 3600_000).toISOString(),
|
|
184
|
+
};
|
|
185
|
+
await TaskStore.save(tmp, expired);
|
|
186
|
+
|
|
187
|
+
const { scheduler, fired } = makeScheduler({ now: () => t0 });
|
|
188
|
+
await scheduler.tick();
|
|
189
|
+
expect(fired).toHaveLength(0);
|
|
190
|
+
expect(await listTaskFiles()).toHaveLength(0);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("cron task next-run advances via Bun.cron.parse", async () => {
|
|
194
|
+
const t0 = Date.parse("2026-05-14T12:00:00Z");
|
|
195
|
+
const { scheduler } = makeScheduler({ now: () => t0 });
|
|
196
|
+
const task = await scheduler.create(sessionId, {
|
|
197
|
+
prompt: "test",
|
|
198
|
+
schedule: { type: "cron", expr: "0 * * * *" },
|
|
199
|
+
});
|
|
200
|
+
expect(task.nextRun).toBe(new Date(t0 + 3600_000).toISOString());
|
|
201
|
+
|
|
202
|
+
// Fire it
|
|
203
|
+
const fireTime = t0 + 3600_000 + 30_000;
|
|
204
|
+
const { scheduler: s2, fired } = makeScheduler({ now: () => fireTime });
|
|
205
|
+
await s2.tick();
|
|
206
|
+
expect(fired).toHaveLength(1);
|
|
207
|
+
|
|
208
|
+
const reloaded = (await TaskStore.loadAll(tmp))[0]!;
|
|
209
|
+
// Should advance to next top-of-hour after firing
|
|
210
|
+
expect(Date.parse(reloaded.nextRun)).toBeGreaterThan(fireTime);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe("TaskScheduler.create validation", () => {
|
|
215
|
+
test("rejects 'once' with past timestamp", async () => {
|
|
216
|
+
const t0 = Date.parse("2026-05-14T12:00:00Z");
|
|
217
|
+
const { scheduler } = makeScheduler({ now: () => t0 });
|
|
218
|
+
await expect(
|
|
219
|
+
scheduler.create(sessionId, {
|
|
220
|
+
prompt: "test",
|
|
221
|
+
schedule: { type: "once", at: new Date(t0 - 1000).toISOString() },
|
|
222
|
+
})
|
|
223
|
+
).rejects.toThrow();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("rejects sub-minute interval", async () => {
|
|
227
|
+
const t0 = Date.parse("2026-05-14T12:00:00Z");
|
|
228
|
+
const { scheduler } = makeScheduler({ now: () => t0 });
|
|
229
|
+
await expect(
|
|
230
|
+
scheduler.create(sessionId, {
|
|
231
|
+
prompt: "test",
|
|
232
|
+
schedule: { type: "interval", every: "30s" },
|
|
233
|
+
})
|
|
234
|
+
).rejects.toThrow();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("rejects expires before first nextRun", async () => {
|
|
238
|
+
const t0 = Date.parse("2026-05-14T12:00:00Z");
|
|
239
|
+
const { scheduler } = makeScheduler({ now: () => t0 });
|
|
240
|
+
await expect(
|
|
241
|
+
scheduler.create(sessionId, {
|
|
242
|
+
prompt: "test",
|
|
243
|
+
schedule: { type: "interval", every: "1h" },
|
|
244
|
+
expires: new Date(t0 + 5 * 60_000).toISOString(),
|
|
245
|
+
})
|
|
246
|
+
).rejects.toThrow();
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
describe("TaskScheduler.list", () => {
|
|
251
|
+
test("filters by chat/thread", async () => {
|
|
252
|
+
const t0 = Date.parse("2026-05-14T12:00:00Z");
|
|
253
|
+
const { scheduler } = makeScheduler({ now: () => t0 });
|
|
254
|
+
await scheduler.create(
|
|
255
|
+
{ chatId: 1, threadId: undefined },
|
|
256
|
+
{ prompt: "a", schedule: { type: "interval", every: "1h" } }
|
|
257
|
+
);
|
|
258
|
+
await scheduler.create(
|
|
259
|
+
{ chatId: 1, threadId: 99 },
|
|
260
|
+
{ prompt: "b", schedule: { type: "interval", every: "1h" } }
|
|
261
|
+
);
|
|
262
|
+
await scheduler.create(
|
|
263
|
+
{ chatId: 2, threadId: undefined },
|
|
264
|
+
{ prompt: "c", schedule: { type: "interval", every: "1h" } }
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
const main = await scheduler.list({
|
|
268
|
+
chatId: 1,
|
|
269
|
+
threadId: undefined,
|
|
270
|
+
});
|
|
271
|
+
expect(main).toHaveLength(1);
|
|
272
|
+
expect(main[0]!.prompt).toBe("a");
|
|
273
|
+
|
|
274
|
+
const threaded = await scheduler.list({ chatId: 1, threadId: 99 });
|
|
275
|
+
expect(threaded).toHaveLength(1);
|
|
276
|
+
expect(threaded[0]!.prompt).toBe("b");
|
|
277
|
+
});
|
|
278
|
+
});
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import type { SessionId } from "./Session";
|
|
2
|
+
import type { ScheduleSpec, ScheduledTask } from "./TaskSchema";
|
|
3
|
+
import { TaskStore } from "./TaskStore";
|
|
4
|
+
|
|
5
|
+
export type RunTaskFn = (task: ScheduledTask) => Promise<void>;
|
|
6
|
+
|
|
7
|
+
export type TaskSchedulerOptions = {
|
|
8
|
+
readonly configDir: string;
|
|
9
|
+
readonly runTask: RunTaskFn;
|
|
10
|
+
readonly pollIntervalMs?: number;
|
|
11
|
+
readonly now?: () => number;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const MISSED_TASK_WINDOW_MS = 24 * 3600_000;
|
|
15
|
+
const MIN_INTERVAL_MS = 60_000;
|
|
16
|
+
|
|
17
|
+
export class TaskScheduler {
|
|
18
|
+
private readonly configDir: string;
|
|
19
|
+
private readonly runTask: RunTaskFn;
|
|
20
|
+
private readonly pollIntervalMs: number;
|
|
21
|
+
private readonly now: () => number;
|
|
22
|
+
private timer: Timer | undefined;
|
|
23
|
+
private inflight: Promise<void> | undefined;
|
|
24
|
+
|
|
25
|
+
public constructor(opts: TaskSchedulerOptions) {
|
|
26
|
+
this.configDir = opts.configDir;
|
|
27
|
+
this.runTask = opts.runTask;
|
|
28
|
+
this.pollIntervalMs = opts.pollIntervalMs ?? 10_000;
|
|
29
|
+
this.now = opts.now ?? ((): number => Date.now());
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
public async start(): Promise<void> {
|
|
33
|
+
if (this.timer) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
this.timer = setInterval(() => {
|
|
37
|
+
void this.tick();
|
|
38
|
+
}, this.pollIntervalMs);
|
|
39
|
+
await this.tick();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
public async stop(): Promise<void> {
|
|
43
|
+
if (this.timer) {
|
|
44
|
+
clearInterval(this.timer);
|
|
45
|
+
this.timer = undefined;
|
|
46
|
+
}
|
|
47
|
+
if (this.inflight) {
|
|
48
|
+
await this.inflight;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
public async tick(): Promise<void> {
|
|
53
|
+
if (this.inflight) {
|
|
54
|
+
await this.inflight;
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const run = this.runTick().finally(() => {
|
|
58
|
+
this.inflight = undefined;
|
|
59
|
+
});
|
|
60
|
+
this.inflight = run;
|
|
61
|
+
await run;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
public async list(
|
|
65
|
+
sessionId: SessionId
|
|
66
|
+
): Promise<ReadonlyArray<ScheduledTask>> {
|
|
67
|
+
const all = await TaskStore.loadAll(this.configDir);
|
|
68
|
+
return all
|
|
69
|
+
.filter(
|
|
70
|
+
(t) =>
|
|
71
|
+
t.chatId === sessionId.chatId && t.threadId === sessionId.threadId
|
|
72
|
+
)
|
|
73
|
+
.sort((a, b) => a.nextRun.localeCompare(b.nextRun));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
public async create(
|
|
77
|
+
sessionId: SessionId,
|
|
78
|
+
input: {
|
|
79
|
+
readonly prompt: string;
|
|
80
|
+
readonly schedule: ScheduleSpec;
|
|
81
|
+
readonly expires?: string;
|
|
82
|
+
readonly isolatedSession?: boolean;
|
|
83
|
+
}
|
|
84
|
+
): Promise<ScheduledTask> {
|
|
85
|
+
const nextRun = this.computeFirstRun(input.schedule);
|
|
86
|
+
if (input.expires !== undefined) {
|
|
87
|
+
const expMs = Date.parse(input.expires);
|
|
88
|
+
if (!Number.isFinite(expMs)) {
|
|
89
|
+
throw new Error(`invalid expires timestamp: ${input.expires}`);
|
|
90
|
+
}
|
|
91
|
+
if (expMs <= Date.parse(nextRun)) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
`expires must be strictly after first nextRun (${nextRun})`
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const task: ScheduledTask = {
|
|
98
|
+
id: TaskStore.makeId(input.prompt),
|
|
99
|
+
prompt: input.prompt,
|
|
100
|
+
chatId: sessionId.chatId,
|
|
101
|
+
threadId: sessionId.threadId,
|
|
102
|
+
schedule: input.schedule,
|
|
103
|
+
status: "active",
|
|
104
|
+
nextRun,
|
|
105
|
+
expires: input.expires ?? null,
|
|
106
|
+
isolatedSession: input.isolatedSession ?? false,
|
|
107
|
+
createdAt: new Date(this.now()).toISOString(),
|
|
108
|
+
};
|
|
109
|
+
await TaskStore.save(this.configDir, task);
|
|
110
|
+
return task;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
public async delete(sessionId: SessionId, id: string): Promise<boolean> {
|
|
114
|
+
const t = await this.find(sessionId, id);
|
|
115
|
+
if (!t) {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
await TaskStore.delete(this.configDir, id);
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
public async setStatus(
|
|
123
|
+
sessionId: SessionId,
|
|
124
|
+
id: string,
|
|
125
|
+
status: "active" | "paused"
|
|
126
|
+
): Promise<ScheduledTask | undefined> {
|
|
127
|
+
const t = await this.find(sessionId, id);
|
|
128
|
+
if (!t) {
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
if (t.status === status) {
|
|
132
|
+
return t;
|
|
133
|
+
}
|
|
134
|
+
const updated: ScheduledTask = { ...t, status };
|
|
135
|
+
await TaskStore.save(this.configDir, updated);
|
|
136
|
+
return updated;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
public async updatePrompt(
|
|
140
|
+
sessionId: SessionId,
|
|
141
|
+
id: string,
|
|
142
|
+
prompt: string
|
|
143
|
+
): Promise<ScheduledTask | undefined> {
|
|
144
|
+
const t = await this.find(sessionId, id);
|
|
145
|
+
if (!t) {
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
if (t.prompt === prompt) {
|
|
149
|
+
return t;
|
|
150
|
+
}
|
|
151
|
+
const updated: ScheduledTask = { ...t, prompt };
|
|
152
|
+
await TaskStore.save(this.configDir, updated);
|
|
153
|
+
return updated;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private async find(
|
|
157
|
+
sessionId: SessionId,
|
|
158
|
+
id: string
|
|
159
|
+
): Promise<ScheduledTask | undefined> {
|
|
160
|
+
const all = await this.list(sessionId);
|
|
161
|
+
return all.find((t) => t.id === id);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private async runTick(): Promise<void> {
|
|
165
|
+
const all = await TaskStore.loadAll(this.configDir);
|
|
166
|
+
const now = this.now();
|
|
167
|
+
const fires: Promise<void>[] = [];
|
|
168
|
+
|
|
169
|
+
for (const task of all) {
|
|
170
|
+
if (task.status !== "active") {
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
const nextMs = Date.parse(task.nextRun);
|
|
174
|
+
if (!Number.isFinite(nextMs) || nextMs > now) {
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (task.expires) {
|
|
179
|
+
const expMs = Date.parse(task.expires);
|
|
180
|
+
if (Number.isFinite(expMs) && now > expMs) {
|
|
181
|
+
await TaskStore.delete(this.configDir, task.id);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (now - nextMs > MISSED_TASK_WINDOW_MS) {
|
|
187
|
+
const advanced = TaskScheduler.advanceNextRun(task, now);
|
|
188
|
+
if (advanced) {
|
|
189
|
+
await TaskStore.save(this.configDir, advanced);
|
|
190
|
+
} else {
|
|
191
|
+
await TaskStore.delete(this.configDir, task.id);
|
|
192
|
+
}
|
|
193
|
+
console.warn(
|
|
194
|
+
`[scheduler] task ${task.id} missed by >24h, advanced silently`
|
|
195
|
+
);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
fires.push(this.fireAndReschedule(task, now));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
await Promise.allSettled(fires);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private async fireAndReschedule(
|
|
206
|
+
task: ScheduledTask,
|
|
207
|
+
firedAt: number
|
|
208
|
+
): Promise<void> {
|
|
209
|
+
try {
|
|
210
|
+
await this.runTask(task);
|
|
211
|
+
} catch (err) {
|
|
212
|
+
console.error(`[scheduler] task ${task.id} runTask failed:`, err);
|
|
213
|
+
}
|
|
214
|
+
const next = TaskScheduler.advanceNextRun(task, firedAt);
|
|
215
|
+
if (next) {
|
|
216
|
+
await TaskStore.save(this.configDir, next);
|
|
217
|
+
} else {
|
|
218
|
+
await TaskStore.delete(this.configDir, task.id);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private computeFirstRun(schedule: ScheduleSpec): string {
|
|
223
|
+
const now = this.now();
|
|
224
|
+
if (schedule.type === "once") {
|
|
225
|
+
const at = Date.parse(schedule.at);
|
|
226
|
+
if (!Number.isFinite(at)) {
|
|
227
|
+
throw new Error(`invalid 'at' timestamp: ${schedule.at}`);
|
|
228
|
+
}
|
|
229
|
+
if (at <= now) {
|
|
230
|
+
throw new Error(`'at' must be strictly in the future`);
|
|
231
|
+
}
|
|
232
|
+
return new Date(at).toISOString();
|
|
233
|
+
}
|
|
234
|
+
if (schedule.type === "interval") {
|
|
235
|
+
const ms = TaskScheduler.parseDuration(schedule.every);
|
|
236
|
+
if (ms < MIN_INTERVAL_MS) {
|
|
237
|
+
throw new Error(`interval must be at least 1 minute`);
|
|
238
|
+
}
|
|
239
|
+
return new Date(now + ms).toISOString();
|
|
240
|
+
}
|
|
241
|
+
const next = Bun.cron.parse(schedule.expr, new Date(now));
|
|
242
|
+
if (!next) {
|
|
243
|
+
throw new Error(`cron expression has no future match: ${schedule.expr}`);
|
|
244
|
+
}
|
|
245
|
+
return next.toISOString();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private static advanceNextRun(
|
|
249
|
+
task: ScheduledTask,
|
|
250
|
+
fromMs: number
|
|
251
|
+
): ScheduledTask | undefined {
|
|
252
|
+
const schedule = task.schedule;
|
|
253
|
+
if (schedule.type === "once") {
|
|
254
|
+
return undefined;
|
|
255
|
+
}
|
|
256
|
+
if (schedule.type === "interval") {
|
|
257
|
+
const ms = TaskScheduler.parseDuration(schedule.every);
|
|
258
|
+
return { ...task, nextRun: new Date(fromMs + ms).toISOString() };
|
|
259
|
+
}
|
|
260
|
+
const next = Bun.cron.parse(schedule.expr, new Date(fromMs));
|
|
261
|
+
if (!next) {
|
|
262
|
+
return undefined;
|
|
263
|
+
}
|
|
264
|
+
return { ...task, nextRun: next.toISOString() };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
public static parseDuration(input: string): number {
|
|
268
|
+
const s = input.trim();
|
|
269
|
+
if (!s) {
|
|
270
|
+
throw new Error("empty duration");
|
|
271
|
+
}
|
|
272
|
+
let remaining = s;
|
|
273
|
+
let total = 0;
|
|
274
|
+
while (remaining.length > 0) {
|
|
275
|
+
const m = remaining.match(/^(\d+)([smhd])/);
|
|
276
|
+
if (!m) {
|
|
277
|
+
throw new Error(`bad duration: ${input}`);
|
|
278
|
+
}
|
|
279
|
+
const n = Number(m[1]);
|
|
280
|
+
const mult = DURATION_UNITS[m[2]!]!;
|
|
281
|
+
total += n * mult;
|
|
282
|
+
remaining = remaining.slice(m[0].length);
|
|
283
|
+
}
|
|
284
|
+
return total;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const DURATION_UNITS: Record<string, number> = {
|
|
289
|
+
s: 1_000,
|
|
290
|
+
m: 60_000,
|
|
291
|
+
h: 3_600_000,
|
|
292
|
+
d: 86_400_000,
|
|
293
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { type Static, Type } from "typebox";
|
|
2
|
+
|
|
3
|
+
export type ScheduleSpec =
|
|
4
|
+
| { readonly type: "once"; readonly at: string }
|
|
5
|
+
| { readonly type: "interval"; readonly every: string }
|
|
6
|
+
| { readonly type: "cron"; readonly expr: string };
|
|
7
|
+
|
|
8
|
+
export type ScheduledTask = {
|
|
9
|
+
readonly id: string;
|
|
10
|
+
readonly prompt: string;
|
|
11
|
+
readonly chatId: number;
|
|
12
|
+
readonly threadId: number | undefined;
|
|
13
|
+
readonly schedule: ScheduleSpec;
|
|
14
|
+
readonly status: "active" | "paused";
|
|
15
|
+
readonly nextRun: string;
|
|
16
|
+
readonly expires: string | null;
|
|
17
|
+
readonly isolatedSession: boolean;
|
|
18
|
+
readonly createdAt: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const scheduleSchema = Type.Union([
|
|
22
|
+
Type.Object({
|
|
23
|
+
type: Type.Literal("once"),
|
|
24
|
+
at: Type.String({
|
|
25
|
+
description:
|
|
26
|
+
"RFC3339 timestamp in the future, UTC. Example: '2026-05-14T15:30:00Z'.",
|
|
27
|
+
}),
|
|
28
|
+
}),
|
|
29
|
+
Type.Object({
|
|
30
|
+
type: Type.Literal("interval"),
|
|
31
|
+
every: Type.String({
|
|
32
|
+
description:
|
|
33
|
+
"Duration like '30m', '2h', '1h30m'. Units: s, m, h, d. Minimum 1 minute.",
|
|
34
|
+
}),
|
|
35
|
+
}),
|
|
36
|
+
Type.Object({
|
|
37
|
+
type: Type.Literal("cron"),
|
|
38
|
+
expr: Type.String({
|
|
39
|
+
description:
|
|
40
|
+
"Standard 5-field cron expression in UTC. Macros @hourly, @daily, @weekly, @monthly, @yearly supported.",
|
|
41
|
+
}),
|
|
42
|
+
}),
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
export const taskToolSchema = Type.Object({
|
|
46
|
+
action: Type.Union(
|
|
47
|
+
[
|
|
48
|
+
Type.Literal("create"),
|
|
49
|
+
Type.Literal("list"),
|
|
50
|
+
Type.Literal("delete"),
|
|
51
|
+
Type.Literal("pause"),
|
|
52
|
+
Type.Literal("resume"),
|
|
53
|
+
Type.Literal("update_prompt"),
|
|
54
|
+
],
|
|
55
|
+
{
|
|
56
|
+
description:
|
|
57
|
+
"create: schedule a new task. list: see this thread's tasks. delete/pause/resume: by id. update_prompt: change a task's prompt.",
|
|
58
|
+
}
|
|
59
|
+
),
|
|
60
|
+
prompt: Type.Optional(
|
|
61
|
+
Type.String({
|
|
62
|
+
description:
|
|
63
|
+
"Required for 'create' and 'update_prompt'. The instruction the agent will receive when the task fires.",
|
|
64
|
+
minLength: 1,
|
|
65
|
+
})
|
|
66
|
+
),
|
|
67
|
+
schedule: Type.Optional(scheduleSchema),
|
|
68
|
+
expires: Type.Optional(
|
|
69
|
+
Type.String({
|
|
70
|
+
description:
|
|
71
|
+
"Optional RFC3339 timestamp after which the task auto-deletes without firing again.",
|
|
72
|
+
})
|
|
73
|
+
),
|
|
74
|
+
isolatedSession: Type.Optional(
|
|
75
|
+
Type.Boolean({
|
|
76
|
+
description:
|
|
77
|
+
"If true, run in a fresh/isolated session with no chat history. Default false.",
|
|
78
|
+
})
|
|
79
|
+
),
|
|
80
|
+
id: Type.Optional(
|
|
81
|
+
Type.String({
|
|
82
|
+
description:
|
|
83
|
+
"Task id. Required for delete, pause, resume, update_prompt.",
|
|
84
|
+
})
|
|
85
|
+
),
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
export type TaskToolInput = Static<typeof taskToolSchema>;
|