@kevin5251984/guild 0.2.21 → 0.2.23
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/cordis.yml +2 -0
- package/package.json +1 -1
- package/src/cron-schedule.ts +388 -0
- package/src/cron.ts +403 -0
- package/src/db.ts +135 -0
- package/src/generate.ts +9 -0
- package/src/handlers.ts +5 -0
- package/src/harness.ts +25 -5
- package/src/plugins/cron.ts +26 -0
- package/src/public/chat.css +112 -9
- package/src/public/chat.html +354 -19
- package/src/public/i18n.js +20 -0
- package/src/public/md.js +99 -22
- package/src/public/mobile.css +12 -2
- package/src/public/mobile.html +23 -5
- package/src/router.ts +97 -1
- package/src/store.ts +22 -1
- package/src/subagent.ts +3 -0
- package/src/tools.ts +57 -1
package/src/cron.ts
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { CronJobRow } from "./db.ts";
|
|
3
|
+
import type { HandlerExtras } from "./handlers.ts";
|
|
4
|
+
import { StoreError, type GuildStore } from "./store.ts";
|
|
5
|
+
import type { ToolContext, ToolOutcome } from "./tools.ts";
|
|
6
|
+
import {
|
|
7
|
+
CRON_JOB_CAP,
|
|
8
|
+
CRON_MIN_EVERY_MS,
|
|
9
|
+
followingRun,
|
|
10
|
+
nextRunAt,
|
|
11
|
+
parseCronSchedule,
|
|
12
|
+
tokenizeCronSlash,
|
|
13
|
+
type CronSpec,
|
|
14
|
+
} from "./cron-schedule.ts";
|
|
15
|
+
|
|
16
|
+
const inflight = new Set<string>();
|
|
17
|
+
|
|
18
|
+
function specOf(job: CronJobRow): CronSpec {
|
|
19
|
+
return {
|
|
20
|
+
raw: job.schedule,
|
|
21
|
+
kind: job.kind,
|
|
22
|
+
atMs: job.atMs,
|
|
23
|
+
everyMs: job.everyMs,
|
|
24
|
+
cron: job.cronExpr,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function publicCronJob(job: CronJobRow) {
|
|
29
|
+
return {
|
|
30
|
+
id: job.id,
|
|
31
|
+
name: job.name,
|
|
32
|
+
roomId: job.roomId,
|
|
33
|
+
botId: job.botId,
|
|
34
|
+
prompt: job.prompt,
|
|
35
|
+
schedule: job.schedule,
|
|
36
|
+
kind: job.kind,
|
|
37
|
+
nextRunAt: job.nextRunAt,
|
|
38
|
+
paused: job.paused,
|
|
39
|
+
createdAt: job.createdAt,
|
|
40
|
+
lastRunAt: job.lastRunAt,
|
|
41
|
+
lastStatus: job.lastStatus,
|
|
42
|
+
lastError: job.lastError,
|
|
43
|
+
running: inflight.has(job.id),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function createCronJob(
|
|
48
|
+
store: GuildStore,
|
|
49
|
+
input: {
|
|
50
|
+
roomId: string;
|
|
51
|
+
botId: string;
|
|
52
|
+
prompt: string;
|
|
53
|
+
schedule: string;
|
|
54
|
+
name?: string;
|
|
55
|
+
},
|
|
56
|
+
): CronJobRow {
|
|
57
|
+
const room = store.getRoom(input.roomId);
|
|
58
|
+
if (!room) throw new StoreError(404, "room not found");
|
|
59
|
+
const bot = store.getBot(input.botId);
|
|
60
|
+
if (!bot) throw new StoreError(400, "bot not found");
|
|
61
|
+
if (room.kind === "channel" && !room.memberIds.includes(bot.id)) {
|
|
62
|
+
throw new StoreError(400, "bot is not on this quest");
|
|
63
|
+
}
|
|
64
|
+
const prompt = input.prompt.trim();
|
|
65
|
+
if (!prompt) throw new StoreError(400, "prompt is required");
|
|
66
|
+
if (store.listCronJobs().length >= CRON_JOB_CAP) {
|
|
67
|
+
throw new StoreError(400, `at most ${CRON_JOB_CAP} cron jobs`);
|
|
68
|
+
}
|
|
69
|
+
let spec;
|
|
70
|
+
try {
|
|
71
|
+
spec = parseCronSchedule(input.schedule);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
throw new StoreError(
|
|
74
|
+
400,
|
|
75
|
+
error instanceof Error ? error.message : String(error),
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
const now = Date.now();
|
|
79
|
+
const name =
|
|
80
|
+
(input.name || "").trim() ||
|
|
81
|
+
prompt.replace(/\s+/g, " ").trim().slice(0, 28) ||
|
|
82
|
+
"cron";
|
|
83
|
+
const job: CronJobRow = {
|
|
84
|
+
id: randomUUID(),
|
|
85
|
+
name,
|
|
86
|
+
roomId: room.id,
|
|
87
|
+
botId: bot.id,
|
|
88
|
+
prompt,
|
|
89
|
+
schedule: spec.raw,
|
|
90
|
+
kind: spec.kind,
|
|
91
|
+
everyMs: spec.everyMs,
|
|
92
|
+
cronExpr: spec.cron,
|
|
93
|
+
atMs: spec.atMs,
|
|
94
|
+
nextRunAt: new Date(nextRunAt(spec, now)).toISOString(),
|
|
95
|
+
paused: false,
|
|
96
|
+
createdAt: new Date(now).toISOString(),
|
|
97
|
+
};
|
|
98
|
+
store.writeCronJob(job);
|
|
99
|
+
return job;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function pauseCronJob(store: GuildStore, id: string): CronJobRow {
|
|
103
|
+
const job = store.getCronJob(id);
|
|
104
|
+
job.paused = true;
|
|
105
|
+
store.writeCronJob(job);
|
|
106
|
+
return job;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function resumeCronJob(store: GuildStore, id: string): CronJobRow {
|
|
110
|
+
const job = store.getCronJob(id);
|
|
111
|
+
job.paused = false;
|
|
112
|
+
job.nextRunAt = new Date(nextRunAt(specOf(job), Date.now())).toISOString();
|
|
113
|
+
store.writeCronJob(job);
|
|
114
|
+
return job;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function removeCronJob(store: GuildStore, id: string): { ok: true; id: string } {
|
|
118
|
+
if (!store.deleteCronJob(id)) throw new StoreError(404, "cron job not found");
|
|
119
|
+
return { ok: true, id };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function readCronJob(store: GuildStore, id: string): CronJobRow | null {
|
|
123
|
+
try {
|
|
124
|
+
return store.getCronJob(id);
|
|
125
|
+
} catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function commitFiredJob(
|
|
131
|
+
store: GuildStore,
|
|
132
|
+
id: string,
|
|
133
|
+
patch: Partial<CronJobRow> | "delete",
|
|
134
|
+
): void {
|
|
135
|
+
const current = readCronJob(store, id);
|
|
136
|
+
if (!current) return;
|
|
137
|
+
if (patch === "delete") {
|
|
138
|
+
store.deleteCronJob(id);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
store.writeCronJob({
|
|
142
|
+
...current,
|
|
143
|
+
...patch,
|
|
144
|
+
id: current.id,
|
|
145
|
+
roomId: current.roomId,
|
|
146
|
+
botId: current.botId,
|
|
147
|
+
paused: current.paused || Boolean(patch.paused),
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function seatBlocked(store: GuildStore, roomId: string, botId: string): string | null {
|
|
152
|
+
const live = store.listLiveRoomTurns(roomId);
|
|
153
|
+
if (live.some((turn) => turn.botId === botId)) return "seat busy";
|
|
154
|
+
if (live.some((turn) => !turn.paused)) return "room busy";
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function findCronJob(store: GuildStore, ref: string): CronJobRow {
|
|
159
|
+
const id = ref.trim();
|
|
160
|
+
if (!id) throw new StoreError(400, "job id is required");
|
|
161
|
+
const direct = store.listCronJobs().find((job) => job.id === id);
|
|
162
|
+
if (direct) return direct;
|
|
163
|
+
const needle = id.toLowerCase();
|
|
164
|
+
const named = store.listCronJobs().filter((job) => job.name.toLowerCase() === needle);
|
|
165
|
+
if (named.length === 1) return named[0];
|
|
166
|
+
if (named.length > 1) {
|
|
167
|
+
throw new StoreError(
|
|
168
|
+
400,
|
|
169
|
+
`name matches ${named.length} jobs: ${named.map((job) => job.id).join(", ")}`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
throw new StoreError(404, "cron job not found");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function fireCronJob(
|
|
176
|
+
store: GuildStore,
|
|
177
|
+
id: string,
|
|
178
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
179
|
+
extras: HandlerExtras = {},
|
|
180
|
+
): Promise<{ ok: boolean; skipped?: string; error?: string }> {
|
|
181
|
+
const job = store.getCronJob(id);
|
|
182
|
+
if (job.paused) return { ok: false, skipped: "paused" };
|
|
183
|
+
if (inflight.has(job.id)) return { ok: false, skipped: "already running" };
|
|
184
|
+
const blocked = seatBlocked(store, job.roomId, job.botId);
|
|
185
|
+
if (blocked) return { ok: false, skipped: blocked };
|
|
186
|
+
const room = store.getRoom(job.roomId);
|
|
187
|
+
if (!room) {
|
|
188
|
+
commitFiredJob(store, job.id, {
|
|
189
|
+
paused: true,
|
|
190
|
+
lastStatus: "failed",
|
|
191
|
+
lastError: "room not found",
|
|
192
|
+
});
|
|
193
|
+
return { ok: false, error: "room not found" };
|
|
194
|
+
}
|
|
195
|
+
if (room.kind === "channel" && !room.memberIds.includes(job.botId)) {
|
|
196
|
+
commitFiredJob(store, job.id, {
|
|
197
|
+
paused: true,
|
|
198
|
+
lastStatus: "failed",
|
|
199
|
+
lastError: "bot is not on this quest",
|
|
200
|
+
});
|
|
201
|
+
return { ok: false, error: "bot is not on this quest" };
|
|
202
|
+
}
|
|
203
|
+
const bot = store.getBot(job.botId);
|
|
204
|
+
if (!bot) {
|
|
205
|
+
commitFiredJob(store, job.id, {
|
|
206
|
+
paused: true,
|
|
207
|
+
lastStatus: "failed",
|
|
208
|
+
lastError: "bot not found",
|
|
209
|
+
});
|
|
210
|
+
return { ok: false, error: "bot not found" };
|
|
211
|
+
}
|
|
212
|
+
inflight.add(job.id);
|
|
213
|
+
const now = Date.now();
|
|
214
|
+
const failPatch = (message: string, pauseOnce: boolean): Partial<CronJobRow> => {
|
|
215
|
+
const next = followingRun(specOf(job), now);
|
|
216
|
+
return {
|
|
217
|
+
lastRunAt: new Date(now).toISOString(),
|
|
218
|
+
lastStatus: "failed",
|
|
219
|
+
lastError: message.slice(0, 400),
|
|
220
|
+
paused: pauseOnce && job.kind === "once",
|
|
221
|
+
nextRunAt: new Date(
|
|
222
|
+
next ?? now + CRON_MIN_EVERY_MS,
|
|
223
|
+
).toISOString(),
|
|
224
|
+
};
|
|
225
|
+
};
|
|
226
|
+
try {
|
|
227
|
+
const { postUserMessage } = await import("./handlers.ts");
|
|
228
|
+
const body = `排程 · ${job.name}\n\n@${bot.handle} ${job.prompt}`;
|
|
229
|
+
const posted = await postUserMessage(
|
|
230
|
+
store,
|
|
231
|
+
job.roomId,
|
|
232
|
+
body,
|
|
233
|
+
env,
|
|
234
|
+
undefined,
|
|
235
|
+
undefined,
|
|
236
|
+
job.botId,
|
|
237
|
+
{ ...extras, mentions: [job.botId], harvest: false, cronRun: true },
|
|
238
|
+
);
|
|
239
|
+
const spoke = (posted.replies || []).some((msg) => msg.author === job.botId);
|
|
240
|
+
if (!spoke) {
|
|
241
|
+
commitFiredJob(store, job.id, failPatch("no reply", true));
|
|
242
|
+
return { ok: false, skipped: "no reply" };
|
|
243
|
+
}
|
|
244
|
+
const next = followingRun(specOf(job), now);
|
|
245
|
+
if (next == null) {
|
|
246
|
+
commitFiredJob(store, job.id, "delete");
|
|
247
|
+
} else {
|
|
248
|
+
commitFiredJob(store, job.id, {
|
|
249
|
+
lastRunAt: new Date(now).toISOString(),
|
|
250
|
+
lastStatus: "ok",
|
|
251
|
+
lastError: undefined,
|
|
252
|
+
nextRunAt: new Date(next).toISOString(),
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
return { ok: true };
|
|
256
|
+
} catch (error) {
|
|
257
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
258
|
+
commitFiredJob(store, job.id, failPatch(message, true));
|
|
259
|
+
return { ok: false, error: message };
|
|
260
|
+
} finally {
|
|
261
|
+
inflight.delete(job.id);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export async function tickCronJobs(
|
|
266
|
+
store: GuildStore,
|
|
267
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
268
|
+
now = Date.now(),
|
|
269
|
+
): Promise<void> {
|
|
270
|
+
const due = store
|
|
271
|
+
.listCronJobs()
|
|
272
|
+
.filter((job) => !job.paused && Date.parse(job.nextRunAt) <= now);
|
|
273
|
+
for (const job of due) {
|
|
274
|
+
if (inflight.has(job.id)) continue;
|
|
275
|
+
try {
|
|
276
|
+
await fireCronJob(store, job.id, env);
|
|
277
|
+
} catch {
|
|
278
|
+
/* one job must not block the tick */
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function executeCronjob(
|
|
284
|
+
store: GuildStore,
|
|
285
|
+
args: Record<string, unknown>,
|
|
286
|
+
ctx: ToolContext = {},
|
|
287
|
+
): Promise<ToolOutcome> | ToolOutcome {
|
|
288
|
+
if (ctx.cronRun) {
|
|
289
|
+
return {
|
|
290
|
+
text: "cron jobs cannot manage cron (Hermes: no recursive scheduling)",
|
|
291
|
+
isError: true,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
const action = String(args.action || args.op || "list").trim().toLowerCase();
|
|
295
|
+
try {
|
|
296
|
+
if (action === "list") {
|
|
297
|
+
const roomId =
|
|
298
|
+
typeof args.room_id === "string" ? args.room_id : ctx.roomId;
|
|
299
|
+
const jobs = store
|
|
300
|
+
.listCronJobs(roomId || undefined)
|
|
301
|
+
.map(publicCronJob);
|
|
302
|
+
return {
|
|
303
|
+
text: jobs.length ? JSON.stringify(jobs, null, 2) : "(no cron jobs)",
|
|
304
|
+
isError: false,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
if (action === "create") {
|
|
308
|
+
const schedule = String(args.schedule || "").trim();
|
|
309
|
+
const prompt = String(args.prompt || args.task || "").trim();
|
|
310
|
+
const roomId =
|
|
311
|
+
(typeof args.room_id === "string" && args.room_id) || ctx.roomId || "";
|
|
312
|
+
const botId =
|
|
313
|
+
(typeof args.bot_id === "string" && args.bot_id) ||
|
|
314
|
+
(typeof args.botId === "string" && args.botId) ||
|
|
315
|
+
ctx.botId ||
|
|
316
|
+
"";
|
|
317
|
+
if (!roomId) throw new StoreError(400, "room_id is required");
|
|
318
|
+
if (!botId) throw new StoreError(400, "bot_id is required");
|
|
319
|
+
const job = createCronJob(store, {
|
|
320
|
+
roomId,
|
|
321
|
+
botId,
|
|
322
|
+
prompt,
|
|
323
|
+
schedule,
|
|
324
|
+
name: typeof args.name === "string" ? args.name : "",
|
|
325
|
+
});
|
|
326
|
+
return {
|
|
327
|
+
text: `created ${job.id} · ${job.name} · next ${job.nextRunAt} · ${job.schedule}`,
|
|
328
|
+
isError: false,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
const ref = String(args.job_id || args.id || args.name || "").trim();
|
|
332
|
+
if (action === "pause") {
|
|
333
|
+
const job = pauseCronJob(store, findCronJob(store, ref).id);
|
|
334
|
+
return { text: `paused ${job.id} · ${job.name}`, isError: false };
|
|
335
|
+
}
|
|
336
|
+
if (action === "resume") {
|
|
337
|
+
const job = resumeCronJob(store, findCronJob(store, ref).id);
|
|
338
|
+
return {
|
|
339
|
+
text: `resumed ${job.id} · next ${job.nextRunAt}`,
|
|
340
|
+
isError: false,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
if (action === "remove" || action === "delete") {
|
|
344
|
+
const job = findCronJob(store, ref);
|
|
345
|
+
removeCronJob(store, job.id);
|
|
346
|
+
return { text: `removed ${job.id} · ${job.name}`, isError: false };
|
|
347
|
+
}
|
|
348
|
+
if (action === "run") {
|
|
349
|
+
const job = findCronJob(store, ref);
|
|
350
|
+
return fireCronJob(store, job.id, ctx.env).then((result) => ({
|
|
351
|
+
text: result.ok
|
|
352
|
+
? `ran ${job.id}`
|
|
353
|
+
: result.skipped
|
|
354
|
+
? `skipped ${job.id}: ${result.skipped}`
|
|
355
|
+
: `failed ${job.id}: ${result.error || "error"}`,
|
|
356
|
+
isError: !result.ok && !result.skipped,
|
|
357
|
+
}));
|
|
358
|
+
}
|
|
359
|
+
return { text: `unknown cron action: ${action}`, isError: true };
|
|
360
|
+
} catch (error) {
|
|
361
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
362
|
+
return { text: message, isError: true };
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export function runCronSlash(
|
|
367
|
+
store: GuildStore,
|
|
368
|
+
roomId: string,
|
|
369
|
+
botId: string,
|
|
370
|
+
text: string,
|
|
371
|
+
): { text: string; isError: boolean } {
|
|
372
|
+
const tokens = tokenizeCronSlash(text.replace(/^\/cron\b/i, "").trim());
|
|
373
|
+
const action = (tokens[0] || "list").toLowerCase();
|
|
374
|
+
if (action === "list" || action === "") {
|
|
375
|
+
return executeCronjob(store, { action: "list", room_id: roomId }, { roomId });
|
|
376
|
+
}
|
|
377
|
+
if (action === "add" || action === "create") {
|
|
378
|
+
const schedule = tokens[1] || "";
|
|
379
|
+
const prompt = tokens.slice(2).join(" ");
|
|
380
|
+
return executeCronjob(
|
|
381
|
+
store,
|
|
382
|
+
{ action: "create", schedule, prompt, room_id: roomId, bot_id: botId },
|
|
383
|
+
{ roomId },
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
if (
|
|
387
|
+
action === "pause" ||
|
|
388
|
+
action === "resume" ||
|
|
389
|
+
action === "run" ||
|
|
390
|
+
action === "remove" ||
|
|
391
|
+
action === "delete"
|
|
392
|
+
) {
|
|
393
|
+
return executeCronjob(
|
|
394
|
+
store,
|
|
395
|
+
{ action, job_id: tokens[1] || "" },
|
|
396
|
+
{ roomId },
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
return {
|
|
400
|
+
text: 'usage: /cron list | add "<schedule>" "<prompt>" | pause|resume|run|remove <id>',
|
|
401
|
+
isError: true,
|
|
402
|
+
};
|
|
403
|
+
}
|
package/src/db.ts
CHANGED
|
@@ -97,6 +97,27 @@ CREATE TABLE IF NOT EXISTS compact (
|
|
|
97
97
|
updated_at TEXT NOT NULL,
|
|
98
98
|
message_count INTEGER NOT NULL DEFAULT 0
|
|
99
99
|
);
|
|
100
|
+
|
|
101
|
+
CREATE TABLE IF NOT EXISTS cron_jobs (
|
|
102
|
+
id TEXT PRIMARY KEY,
|
|
103
|
+
name TEXT NOT NULL,
|
|
104
|
+
room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
|
|
105
|
+
bot_id TEXT NOT NULL,
|
|
106
|
+
prompt TEXT NOT NULL,
|
|
107
|
+
schedule TEXT NOT NULL,
|
|
108
|
+
kind TEXT NOT NULL CHECK (kind IN ('once', 'every', 'cron')),
|
|
109
|
+
every_ms INTEGER,
|
|
110
|
+
cron_expr TEXT,
|
|
111
|
+
at_ms INTEGER,
|
|
112
|
+
next_run_at TEXT NOT NULL,
|
|
113
|
+
paused INTEGER NOT NULL DEFAULT 0,
|
|
114
|
+
created_at TEXT NOT NULL,
|
|
115
|
+
last_run_at TEXT,
|
|
116
|
+
last_status TEXT,
|
|
117
|
+
last_error TEXT
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
CREATE INDEX IF NOT EXISTS cron_jobs_next ON cron_jobs(paused, next_run_at);
|
|
100
121
|
`;
|
|
101
122
|
|
|
102
123
|
type CompactRow = {
|
|
@@ -106,6 +127,54 @@ type CompactRow = {
|
|
|
106
127
|
messageCount: number;
|
|
107
128
|
};
|
|
108
129
|
|
|
130
|
+
export type CronJobRow = {
|
|
131
|
+
id: string;
|
|
132
|
+
name: string;
|
|
133
|
+
roomId: string;
|
|
134
|
+
botId: string;
|
|
135
|
+
prompt: string;
|
|
136
|
+
schedule: string;
|
|
137
|
+
kind: "once" | "every" | "cron";
|
|
138
|
+
everyMs?: number;
|
|
139
|
+
cronExpr?: string;
|
|
140
|
+
atMs?: number;
|
|
141
|
+
nextRunAt: string;
|
|
142
|
+
paused: boolean;
|
|
143
|
+
createdAt: string;
|
|
144
|
+
lastRunAt?: string;
|
|
145
|
+
lastStatus?: string;
|
|
146
|
+
lastError?: string;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
function cronJobFromRow(row: Record<string, unknown>): CronJobRow {
|
|
150
|
+
const job: CronJobRow = {
|
|
151
|
+
id: asString(row.id),
|
|
152
|
+
name: asString(row.name),
|
|
153
|
+
roomId: asString(row.room_id),
|
|
154
|
+
botId: asString(row.bot_id),
|
|
155
|
+
prompt: asString(row.prompt),
|
|
156
|
+
schedule: asString(row.schedule),
|
|
157
|
+
kind:
|
|
158
|
+
asString(row.kind) === "once" || asString(row.kind) === "cron"
|
|
159
|
+
? asString(row.kind)
|
|
160
|
+
: "every",
|
|
161
|
+
nextRunAt: asString(row.next_run_at),
|
|
162
|
+
paused: asNumber(row.paused) === 1,
|
|
163
|
+
createdAt: asString(row.created_at),
|
|
164
|
+
};
|
|
165
|
+
if (row.every_ms != null) job.everyMs = asNumber(row.every_ms);
|
|
166
|
+
const cronExpr = asString(row.cron_expr);
|
|
167
|
+
if (cronExpr) job.cronExpr = cronExpr;
|
|
168
|
+
if (row.at_ms != null) job.atMs = asNumber(row.at_ms);
|
|
169
|
+
const lastRun = asString(row.last_run_at);
|
|
170
|
+
if (lastRun) job.lastRunAt = lastRun;
|
|
171
|
+
const lastStatus = asString(row.last_status);
|
|
172
|
+
if (lastStatus) job.lastStatus = lastStatus;
|
|
173
|
+
const lastError = asString(row.last_error);
|
|
174
|
+
if (lastError) job.lastError = lastError;
|
|
175
|
+
return job;
|
|
176
|
+
}
|
|
177
|
+
|
|
109
178
|
function asString(value: unknown, fallback = ""): string {
|
|
110
179
|
return typeof value === "string" ? value : fallback;
|
|
111
180
|
}
|
|
@@ -771,6 +840,72 @@ export class GuildDb {
|
|
|
771
840
|
}
|
|
772
841
|
}
|
|
773
842
|
|
|
843
|
+
listCronJobs(roomId?: string): CronJobRow[] {
|
|
844
|
+
const rows = roomId
|
|
845
|
+
? (this.sqlite
|
|
846
|
+
.prepare("SELECT * FROM cron_jobs WHERE room_id = ? ORDER BY created_at")
|
|
847
|
+
.all(roomId) as Record<string, unknown>[])
|
|
848
|
+
: (this.sqlite
|
|
849
|
+
.prepare("SELECT * FROM cron_jobs ORDER BY next_run_at")
|
|
850
|
+
.all() as Record<string, unknown>[]);
|
|
851
|
+
return rows.map(cronJobFromRow);
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
getCronJob(id: string): CronJobRow | null {
|
|
855
|
+
const row = this.sqlite
|
|
856
|
+
.prepare("SELECT * FROM cron_jobs WHERE id = ?")
|
|
857
|
+
.get(id) as Record<string, unknown> | undefined;
|
|
858
|
+
return row ? cronJobFromRow(row) : null;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
upsertCronJob(job: CronJobRow): void {
|
|
862
|
+
this.sqlite
|
|
863
|
+
.prepare(
|
|
864
|
+
`INSERT INTO cron_jobs (
|
|
865
|
+
id, name, room_id, bot_id, prompt, schedule, kind, every_ms, cron_expr, at_ms,
|
|
866
|
+
next_run_at, paused, created_at, last_run_at, last_status, last_error
|
|
867
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
868
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
869
|
+
name = excluded.name,
|
|
870
|
+
room_id = excluded.room_id,
|
|
871
|
+
bot_id = excluded.bot_id,
|
|
872
|
+
prompt = excluded.prompt,
|
|
873
|
+
schedule = excluded.schedule,
|
|
874
|
+
kind = excluded.kind,
|
|
875
|
+
every_ms = excluded.every_ms,
|
|
876
|
+
cron_expr = excluded.cron_expr,
|
|
877
|
+
at_ms = excluded.at_ms,
|
|
878
|
+
next_run_at = excluded.next_run_at,
|
|
879
|
+
paused = excluded.paused,
|
|
880
|
+
last_run_at = excluded.last_run_at,
|
|
881
|
+
last_status = excluded.last_status,
|
|
882
|
+
last_error = excluded.last_error`,
|
|
883
|
+
)
|
|
884
|
+
.run(
|
|
885
|
+
job.id,
|
|
886
|
+
job.name,
|
|
887
|
+
job.roomId,
|
|
888
|
+
job.botId,
|
|
889
|
+
job.prompt,
|
|
890
|
+
job.schedule,
|
|
891
|
+
job.kind,
|
|
892
|
+
job.everyMs ?? null,
|
|
893
|
+
job.cronExpr ?? null,
|
|
894
|
+
job.atMs ?? null,
|
|
895
|
+
job.nextRunAt,
|
|
896
|
+
job.paused ? 1 : 0,
|
|
897
|
+
job.createdAt,
|
|
898
|
+
job.lastRunAt ?? null,
|
|
899
|
+
job.lastStatus ?? null,
|
|
900
|
+
job.lastError ?? null,
|
|
901
|
+
);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
deleteCronJob(id: string): boolean {
|
|
905
|
+
const result = this.sqlite.prepare("DELETE FROM cron_jobs WHERE id = ?").run(id);
|
|
906
|
+
return result.changes > 0;
|
|
907
|
+
}
|
|
908
|
+
|
|
774
909
|
private messageCount(roomId: string): number {
|
|
775
910
|
const row = this.sqlite
|
|
776
911
|
.prepare("SELECT COUNT(*) AS n FROM messages WHERE room_id = ?")
|
package/src/generate.ts
CHANGED
|
@@ -504,6 +504,9 @@ export async function chatReply(input: {
|
|
|
504
504
|
mcpTools?: McpToolRef[];
|
|
505
505
|
sandbox?: Sandbox;
|
|
506
506
|
workspace?: string;
|
|
507
|
+
roomId?: string;
|
|
508
|
+
botId?: string;
|
|
509
|
+
cronRun?: boolean;
|
|
507
510
|
dispatch?: ToolContext["dispatch"];
|
|
508
511
|
}): Promise<ChatReply> {
|
|
509
512
|
const env = input.env ?? process.env;
|
|
@@ -569,6 +572,9 @@ async function tryChatLlm(
|
|
|
569
572
|
mcpTools?: McpToolRef[];
|
|
570
573
|
sandbox?: Sandbox;
|
|
571
574
|
workspace?: string;
|
|
575
|
+
roomId?: string;
|
|
576
|
+
botId?: string;
|
|
577
|
+
cronRun?: boolean;
|
|
572
578
|
dispatch?: ToolContext["dispatch"];
|
|
573
579
|
},
|
|
574
580
|
env: NodeJS.ProcessEnv,
|
|
@@ -625,6 +631,9 @@ async function tryChatLlm(
|
|
|
625
631
|
signal: input.signal,
|
|
626
632
|
mcpTools: input.mcpTools ?? (await listMcpToolRefs(dataDir)),
|
|
627
633
|
spawnHandles: new Map(),
|
|
634
|
+
...(input.roomId ? { roomId: input.roomId } : {}),
|
|
635
|
+
...(input.botId ? { botId: input.botId } : {}),
|
|
636
|
+
...(input.cronRun ? { cronRun: true } : {}),
|
|
628
637
|
...policyFor(env, {
|
|
629
638
|
sandbox: input.sandbox,
|
|
630
639
|
workspace: input.workspace,
|
package/src/handlers.ts
CHANGED
|
@@ -75,6 +75,8 @@ export type HandlerExtras = {
|
|
|
75
75
|
turn?: (input: Parameters<typeof chatReply>[0]) => Promise<ChatReply>;
|
|
76
76
|
/** Bot ids the client already resolved from @mentions. */
|
|
77
77
|
mentions?: string[];
|
|
78
|
+
/** Hermes: cron child sessions cannot manage cron. */
|
|
79
|
+
cronRun?: boolean;
|
|
78
80
|
};
|
|
79
81
|
|
|
80
82
|
export function healthPayload(): HealthResponse {
|
|
@@ -914,6 +916,8 @@ export function chatTurnForBot(
|
|
|
914
916
|
history,
|
|
915
917
|
userMessage,
|
|
916
918
|
dataDir: store.dataDir,
|
|
919
|
+
roomId,
|
|
920
|
+
botId,
|
|
917
921
|
model: detail.model ?? null,
|
|
918
922
|
skills: mergeSkillRefs(
|
|
919
923
|
staffedSkills(store, botId),
|
|
@@ -1276,6 +1280,7 @@ async function generateReplies(
|
|
|
1276
1280
|
env,
|
|
1277
1281
|
signal: botSignal,
|
|
1278
1282
|
mcpTools,
|
|
1283
|
+
...(extras.cronRun ? { cronRun: true } : {}),
|
|
1279
1284
|
onProgress: (update) => {
|
|
1280
1285
|
const prev = store.getLiveBotTurn(roomId, botId);
|
|
1281
1286
|
if (prev?.paused) return;
|
package/src/harness.ts
CHANGED
|
@@ -133,6 +133,23 @@ export function pathInsideWorkspace(target: string, workspace: string): boolean
|
|
|
133
133
|
return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
/** Scratch dirs `workspace_write` may also read / write / run-cwd. Not `$HOME`. */
|
|
137
|
+
export function extraWriteRoots(dataDir?: string): string[] {
|
|
138
|
+
const roots = [resolve("/tmp")];
|
|
139
|
+
const home = dataDir?.trim();
|
|
140
|
+
if (home) roots.push(resolve(home, "cache"));
|
|
141
|
+
return roots;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function allowedWorkspaceWritePath(
|
|
145
|
+
target: string,
|
|
146
|
+
workspace: string,
|
|
147
|
+
dataDir?: string,
|
|
148
|
+
): boolean {
|
|
149
|
+
if (pathInsideWorkspace(target, workspace)) return true;
|
|
150
|
+
return extraWriteRoots(dataDir).some((root) => pathInsideWorkspace(target, root));
|
|
151
|
+
}
|
|
152
|
+
|
|
136
153
|
export function mutatingTool(name: string): boolean {
|
|
137
154
|
return (
|
|
138
155
|
name === "run" ||
|
|
@@ -150,6 +167,7 @@ export function gateTool(
|
|
|
150
167
|
input: {
|
|
151
168
|
sandbox?: Sandbox;
|
|
152
169
|
workspace?: string;
|
|
170
|
+
dataDir?: string;
|
|
153
171
|
} = {},
|
|
154
172
|
): SandboxRefusal | null {
|
|
155
173
|
const sandbox = parseSandbox(input.sandbox);
|
|
@@ -161,7 +179,8 @@ export function gateTool(
|
|
|
161
179
|
name === "list" ||
|
|
162
180
|
name === "skill" ||
|
|
163
181
|
name === "spawn" ||
|
|
164
|
-
name === "read_spawn"
|
|
182
|
+
name === "read_spawn" ||
|
|
183
|
+
name === "cronjob"
|
|
165
184
|
) {
|
|
166
185
|
return null;
|
|
167
186
|
}
|
|
@@ -187,7 +206,7 @@ export function gateTool(
|
|
|
187
206
|
const raw = typeof args.path === "string" ? args.path : "";
|
|
188
207
|
if (name === "list" && !raw.trim()) return null;
|
|
189
208
|
const target = resolveToolPath(raw, workspace);
|
|
190
|
-
if (!
|
|
209
|
+
if (!allowedWorkspaceWritePath(target, workspace, input.dataDir)) {
|
|
191
210
|
return {
|
|
192
211
|
text: `sandbox=workspace_write refused ${name} outside workspace: ${target}`,
|
|
193
212
|
isError: true,
|
|
@@ -199,7 +218,8 @@ export function gateTool(
|
|
|
199
218
|
if (
|
|
200
219
|
name === "skill" ||
|
|
201
220
|
name === "spawn" ||
|
|
202
|
-
name === "read_spawn"
|
|
221
|
+
name === "read_spawn" ||
|
|
222
|
+
name === "cronjob"
|
|
203
223
|
) {
|
|
204
224
|
return null;
|
|
205
225
|
}
|
|
@@ -208,7 +228,7 @@ export function gateTool(
|
|
|
208
228
|
const raw = typeof args.path === "string" ? args.path : "";
|
|
209
229
|
if (!raw.trim()) return null;
|
|
210
230
|
const target = resolveToolPath(raw, workspace);
|
|
211
|
-
if (!
|
|
231
|
+
if (!allowedWorkspaceWritePath(target, workspace, input.dataDir)) {
|
|
212
232
|
return {
|
|
213
233
|
text: `sandbox=workspace_write refused write outside workspace: ${target}`,
|
|
214
234
|
isError: true,
|
|
@@ -220,7 +240,7 @@ export function gateTool(
|
|
|
220
240
|
if (name === "run") {
|
|
221
241
|
const workdir = typeof args.workdir === "string" ? args.workdir.trim() : "";
|
|
222
242
|
const cwd = workdir ? resolveToolPath(workdir, workspace) : workspace;
|
|
223
|
-
if (!
|
|
243
|
+
if (!allowedWorkspaceWritePath(cwd, workspace, input.dataDir)) {
|
|
224
244
|
return {
|
|
225
245
|
text: `sandbox=workspace_write refused run cwd outside workspace: ${cwd}`,
|
|
226
246
|
isError: true,
|