operon-agents 0.1.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +55 -0
- package/README.md +18 -0
- package/dist/app-server/client.d.ts +80 -0
- package/dist/app-server/client.js +187 -0
- package/dist/app-server/codec.d.ts +52 -0
- package/dist/app-server/codec.js +252 -0
- package/dist/app-server/index.d.ts +22 -0
- package/dist/app-server/index.js +22 -0
- package/dist/app-server/main.d.ts +3 -0
- package/dist/app-server/main.js +85 -0
- package/dist/app-server/protocol.d.ts +217 -0
- package/dist/app-server/protocol.js +100 -0
- package/dist/app-server/server.d.ts +51 -0
- package/dist/app-server/server.js +274 -0
- package/dist/core.d.ts +12 -0
- package/dist/core.js +12 -0
- package/dist/cron/clock.d.ts +11 -0
- package/dist/cron/clock.js +19 -0
- package/dist/cron/cron-expr.d.ts +15 -0
- package/dist/cron/cron-expr.js +267 -0
- package/dist/cron/extension.d.ts +25 -0
- package/dist/cron/extension.js +141 -0
- package/dist/cron/index.d.ts +20 -0
- package/dist/cron/index.js +11 -0
- package/dist/cron/jitter.d.ts +18 -0
- package/dist/cron/jitter.js +46 -0
- package/dist/cron/manager.d.ts +55 -0
- package/dist/cron/manager.js +123 -0
- package/dist/cron/persist.d.ts +10 -0
- package/dist/cron/persist.js +22 -0
- package/dist/cron/scheduler.d.ts +25 -0
- package/dist/cron/scheduler.js +192 -0
- package/dist/cron/session-store.d.ts +14 -0
- package/dist/cron/session-store.js +50 -0
- package/dist/cron/time-format.d.ts +2 -0
- package/dist/cron/time-format.js +12 -0
- package/dist/cron/tools.d.ts +7 -0
- package/dist/cron/tools.js +161 -0
- package/dist/cron/types.d.ts +9 -0
- package/dist/cron/types.js +2 -0
- package/dist/extensions/index.d.ts +23 -0
- package/dist/extensions/index.js +41 -0
- package/dist/extensions/loader.d.ts +121 -0
- package/dist/extensions/loader.js +285 -0
- package/dist/extensions/manager.d.ts +103 -0
- package/dist/extensions/manager.js +190 -0
- package/dist/extensions/runtime.d.ts +188 -0
- package/dist/extensions/runtime.js +1127 -0
- package/dist/extensions/services.d.ts +85 -0
- package/dist/extensions/services.js +265 -0
- package/dist/extensions/types.d.ts +594 -0
- package/dist/extensions/types.js +2 -0
- package/dist/harness.d.ts +746 -0
- package/dist/harness.js +1483 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +29 -0
- package/dist/internal.d.ts +9 -0
- package/dist/internal.js +9 -0
- package/dist/llm.d.ts +2 -0
- package/dist/llm.js +2 -0
- package/dist/local.d.ts +24 -0
- package/dist/local.js +58 -0
- package/dist/mcp.d.ts +2 -0
- package/dist/mcp.js +2 -0
- package/dist/protocol.d.ts +2 -0
- package/dist/protocol.js +2 -0
- package/dist/tools.d.ts +2 -0
- package/dist/tools.js +2 -0
- package/dist/tracing.d.ts +2 -0
- package/dist/tracing.js +2 -0
- package/package.json +78 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { CronManager } from "./manager.js";
|
|
2
|
+
import { isValidCronTask } from "./persist.js";
|
|
3
|
+
import { MAX_CRON_JOBS_PER_SESSION, cronCreateTool, cronDeleteTool, cronListTool } from "./tools.js";
|
|
4
|
+
import { hasFireWithinYears, parseCronExpression } from "./cron-expr.js";
|
|
5
|
+
const MAX_CRON_PROMPT_BYTES = 8 * 1024;
|
|
6
|
+
const STATE_KEY = "registry";
|
|
7
|
+
/** Host-path validation (the tools carry their own): trims/caps the input, refuses expressions
|
|
8
|
+
* that never fire within 5 years, enforces the per-session job cap. */
|
|
9
|
+
export function normalizeCronTaskInput(manager, input) {
|
|
10
|
+
const cron = input.cron.trim().split(/\s+/).join(" ");
|
|
11
|
+
const prompt = input.prompt;
|
|
12
|
+
if (cron.length === 0)
|
|
13
|
+
throw new Error("Cron expression cannot be empty.");
|
|
14
|
+
if (prompt.trim().length === 0)
|
|
15
|
+
throw new Error("Cron prompt cannot be empty.");
|
|
16
|
+
const promptBytes = Buffer.byteLength(prompt, "utf8");
|
|
17
|
+
if (promptBytes > MAX_CRON_PROMPT_BYTES) {
|
|
18
|
+
throw new Error(`Cron prompt exceeds ${MAX_CRON_PROMPT_BYTES} bytes (got ${promptBytes}).`);
|
|
19
|
+
}
|
|
20
|
+
if (manager.store.list().length >= MAX_CRON_JOBS_PER_SESSION) {
|
|
21
|
+
throw new Error(`Cron job cap reached (max ${MAX_CRON_JOBS_PER_SESSION} per session).`);
|
|
22
|
+
}
|
|
23
|
+
const parsed = parseCronExpression(cron);
|
|
24
|
+
if (!hasFireWithinYears(parsed, 5, manager.clocks.wallNow())) {
|
|
25
|
+
throw new Error(`Cron expression ${JSON.stringify(cron)} has no fire within 5 years; refusing to schedule.`);
|
|
26
|
+
}
|
|
27
|
+
return { ...input, cron };
|
|
28
|
+
}
|
|
29
|
+
export function cronExtension(options = {}) {
|
|
30
|
+
return {
|
|
31
|
+
id: "cron",
|
|
32
|
+
async setup(api) {
|
|
33
|
+
// One manager per session: setup runs per session open, so scheduling state is
|
|
34
|
+
// session-scoped exactly as the capability's openSession was.
|
|
35
|
+
const manager = new CronManager(options);
|
|
36
|
+
manager.attach({
|
|
37
|
+
steer: (prompt, metadata) => {
|
|
38
|
+
const receipt = api.actions.steer(prompt, { metadata });
|
|
39
|
+
return receipt === undefined ? undefined : { buffered: receipt.wakeTurnId === null };
|
|
40
|
+
},
|
|
41
|
+
emit: (name, data) => api.emitEvent(name, data),
|
|
42
|
+
isIdle: () => api.actions.isIdle(),
|
|
43
|
+
persistence: {
|
|
44
|
+
load: async () => {
|
|
45
|
+
const raw = await api.state.get(STATE_KEY);
|
|
46
|
+
return Array.isArray(raw) ? raw.filter(isValidCronTask) : [];
|
|
47
|
+
},
|
|
48
|
+
save: (tasks) => api.state.set(STATE_KEY, tasks),
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
api.registerTool(cronCreateTool(manager));
|
|
52
|
+
api.registerTool(cronDeleteTool(manager));
|
|
53
|
+
api.registerTool(cronListTool(manager));
|
|
54
|
+
const handle = {
|
|
55
|
+
addTask: (input) => manager.addTask(normalizeCronTaskInput(manager, input)),
|
|
56
|
+
listTasks: () => manager.store.list(),
|
|
57
|
+
removeTask: (id) => manager.removeTasks([id]).length > 0,
|
|
58
|
+
tick: () => manager.tick(),
|
|
59
|
+
getNextFireTime: (taskId) => (taskId === undefined ? manager.getNextFireTime() : manager.getNextFireForTask(taskId)),
|
|
60
|
+
};
|
|
61
|
+
api.expose(handle);
|
|
62
|
+
api.registerCommand(cronCommand(handle));
|
|
63
|
+
await manager.loadPersisted();
|
|
64
|
+
manager.start();
|
|
65
|
+
return async () => {
|
|
66
|
+
await manager.stop();
|
|
67
|
+
};
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/** `/cron` — behaviorally the command the core registry used to carry, now carried by the
|
|
72
|
+
* extension itself: attached ⇒ the command exists, detached ⇒ it doesn't. */
|
|
73
|
+
function cronCommand(handle) {
|
|
74
|
+
return {
|
|
75
|
+
name: "cron",
|
|
76
|
+
description: "Manage cron jobs (list | add <m h dom mon dow> <prompt> [--once] | remove <id> | tick | next [id]).",
|
|
77
|
+
run: (rawArgs) => {
|
|
78
|
+
const { head: sub, tail } = splitHead(rawArgs);
|
|
79
|
+
if (sub.length === 0 || sub === "list") {
|
|
80
|
+
const tasks = handle.listTasks();
|
|
81
|
+
return { ok: true, message: `${tasks.length} cron job(s).`, data: tasks };
|
|
82
|
+
}
|
|
83
|
+
if (sub === "add") {
|
|
84
|
+
const init = parseCronAdd(tail);
|
|
85
|
+
if (init === undefined)
|
|
86
|
+
return { ok: false, message: "Usage: /cron add <m h dom mon dow> <prompt> [--once]" };
|
|
87
|
+
try {
|
|
88
|
+
const task = handle.addTask(init);
|
|
89
|
+
return { ok: true, message: `Scheduled cron job ${task.id} (${task.cron}).`, data: task };
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
return { ok: false, message: error instanceof Error ? error.message : String(error) };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (sub === "remove" || sub === "delete" || sub === "rm") {
|
|
96
|
+
const { head: id } = splitHead(tail);
|
|
97
|
+
if (id.length === 0)
|
|
98
|
+
return { ok: false, message: `Usage: /cron ${sub} <id>` };
|
|
99
|
+
return handle.removeTask(id)
|
|
100
|
+
? { ok: true, message: `Removed cron job ${id}.` }
|
|
101
|
+
: { ok: false, message: `No cron job "${id}".` };
|
|
102
|
+
}
|
|
103
|
+
if (sub === "tick") {
|
|
104
|
+
handle.tick();
|
|
105
|
+
return { ok: true, message: "Cron ticked.", data: { nextFireAt: handle.getNextFireTime() } };
|
|
106
|
+
}
|
|
107
|
+
if (sub === "next") {
|
|
108
|
+
const { head: id } = splitHead(tail);
|
|
109
|
+
const taskId = id.length > 0 ? id : undefined;
|
|
110
|
+
const nextFireAt = handle.getNextFireTime(taskId);
|
|
111
|
+
return {
|
|
112
|
+
ok: true,
|
|
113
|
+
message: nextFireAt === null ? "No upcoming cron fire." : `Next fire at ${nextFireAt}.`,
|
|
114
|
+
data: { taskId: taskId ?? null, nextFireAt },
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
return { ok: false, message: `Unknown /cron action: ${sub}` };
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
function splitHead(input) {
|
|
122
|
+
const trimmed = input.trim();
|
|
123
|
+
const space = trimmed.search(/\s/);
|
|
124
|
+
return space === -1 ? { head: trimmed, tail: "" } : { head: trimmed.slice(0, space), tail: trimmed.slice(space + 1).trim() };
|
|
125
|
+
}
|
|
126
|
+
function parseCronAdd(input) {
|
|
127
|
+
let recurring = true;
|
|
128
|
+
const tokens = [];
|
|
129
|
+
for (const token of input.trim().split(/\s+/)) {
|
|
130
|
+
if (token.length === 0)
|
|
131
|
+
continue;
|
|
132
|
+
if (token === "--once")
|
|
133
|
+
recurring = false;
|
|
134
|
+
else
|
|
135
|
+
tokens.push(token);
|
|
136
|
+
}
|
|
137
|
+
if (tokens.length < 6)
|
|
138
|
+
return undefined; // 5 cron fields + at least one prompt token
|
|
139
|
+
return { cron: tokens.slice(0, 5).join(" "), prompt: tokens.slice(5).join(" "), recurring };
|
|
140
|
+
}
|
|
141
|
+
//# sourceMappingURL=extension.js.map
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export { CronManager } from "./manager.ts";
|
|
2
|
+
export type { CronManagerOptions, CronManagerRuntime } from "./manager.ts";
|
|
3
|
+
export { CRON_ID_REGEX, isValidCronTask } from "./persist.ts";
|
|
4
|
+
export type { CronPersistence } from "./persist.ts";
|
|
5
|
+
export { SessionCronStore } from "./session-store.ts";
|
|
6
|
+
export type { SessionCronTaskInit } from "./session-store.ts";
|
|
7
|
+
export { createCronScheduler } from "./scheduler.ts";
|
|
8
|
+
export type { CronScheduler, CronSchedulerOptions } from "./scheduler.ts";
|
|
9
|
+
export { SYSTEM_CLOCKS, mutableClock } from "./clock.ts";
|
|
10
|
+
export type { ClockSources, MutableClock } from "./clock.ts";
|
|
11
|
+
export { parseCronExpression, computeNextCronRun, hasFireWithinYears, cronToHuman, } from "./cron-expr.ts";
|
|
12
|
+
export type { ParsedCronExpression } from "./cron-expr.ts";
|
|
13
|
+
export { jitteredNextCronRunMs, oneShotJitteredNextCronRunMs, DEFAULT_CRON_JITTER_CONFIG, } from "./jitter.ts";
|
|
14
|
+
export type { JitterConfig } from "./jitter.ts";
|
|
15
|
+
export { formatLocalIsoWithOffset } from "./time-format.ts";
|
|
16
|
+
export { cronCreateTool, cronDeleteTool, cronListTool, MAX_CRON_JOBS_PER_SESSION } from "./tools.ts";
|
|
17
|
+
export type { CronTask } from "./types.ts";
|
|
18
|
+
export { cronExtension, normalizeCronTaskInput } from "./extension.ts";
|
|
19
|
+
export type { CronHandle } from "./extension.ts";
|
|
20
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { CronManager } from "./manager.js";
|
|
2
|
+
export { CRON_ID_REGEX, isValidCronTask } from "./persist.js";
|
|
3
|
+
export { SessionCronStore } from "./session-store.js";
|
|
4
|
+
export { createCronScheduler } from "./scheduler.js";
|
|
5
|
+
export { SYSTEM_CLOCKS, mutableClock } from "./clock.js";
|
|
6
|
+
export { parseCronExpression, computeNextCronRun, hasFireWithinYears, cronToHuman, } from "./cron-expr.js";
|
|
7
|
+
export { jitteredNextCronRunMs, oneShotJitteredNextCronRunMs, DEFAULT_CRON_JITTER_CONFIG, } from "./jitter.js";
|
|
8
|
+
export { formatLocalIsoWithOffset } from "./time-format.js";
|
|
9
|
+
export { cronCreateTool, cronDeleteTool, cronListTool, MAX_CRON_JOBS_PER_SESSION } from "./tools.js";
|
|
10
|
+
export { cronExtension, normalizeCronTaskInput } from "./extension.js";
|
|
11
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ParsedCronExpression } from "./cron-expr.ts";
|
|
2
|
+
export interface JitterConfig {
|
|
3
|
+
readonly recurringMaxFractionOfPeriod: number;
|
|
4
|
+
readonly recurringMaxMs: number;
|
|
5
|
+
readonly oneShotMaxMs: number;
|
|
6
|
+
readonly noJitter?: boolean;
|
|
7
|
+
}
|
|
8
|
+
export declare const DEFAULT_CRON_JITTER_CONFIG: JitterConfig;
|
|
9
|
+
export declare function jitteredNextCronRunMs(task: {
|
|
10
|
+
id: string;
|
|
11
|
+
cron: string;
|
|
12
|
+
recurring?: boolean;
|
|
13
|
+
}, parsed: ParsedCronExpression, idealMs: number, config?: JitterConfig): number;
|
|
14
|
+
export declare function oneShotJitteredNextCronRunMs(task: {
|
|
15
|
+
id: string;
|
|
16
|
+
createdAt?: number | undefined;
|
|
17
|
+
}, idealMs: number, config?: JitterConfig): number;
|
|
18
|
+
//# sourceMappingURL=jitter.d.ts.map
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { computeNextCronRun } from "./cron-expr.js";
|
|
2
|
+
export const DEFAULT_CRON_JITTER_CONFIG = {
|
|
3
|
+
recurringMaxFractionOfPeriod: 0.1,
|
|
4
|
+
recurringMaxMs: 15 * 60_000,
|
|
5
|
+
oneShotMaxMs: 90_000,
|
|
6
|
+
};
|
|
7
|
+
const MS_PER_DAY = 24 * 60 * 60_000;
|
|
8
|
+
const MS_PER_MINUTE = 60_000;
|
|
9
|
+
function fractionFromId(id) {
|
|
10
|
+
if (/^[0-9a-f]{8}$/i.test(id)) {
|
|
11
|
+
const n = Number.parseInt(id, 16);
|
|
12
|
+
if (Number.isFinite(n))
|
|
13
|
+
return n / 0x1_0000_0000;
|
|
14
|
+
}
|
|
15
|
+
let hash = 5381;
|
|
16
|
+
for (let i = 0; i < id.length; i++)
|
|
17
|
+
hash = ((hash << 5) + hash + id.charCodeAt(i)) | 0;
|
|
18
|
+
return (hash >>> 0) / 0x1_0000_0000;
|
|
19
|
+
}
|
|
20
|
+
export function jitteredNextCronRunMs(task, parsed, idealMs, config = DEFAULT_CRON_JITTER_CONFIG) {
|
|
21
|
+
if (config.noJitter === true)
|
|
22
|
+
return idealMs;
|
|
23
|
+
const nextNext = computeNextCronRun(parsed, idealMs);
|
|
24
|
+
const period = nextNext !== null && nextNext > idealMs ? nextNext - idealMs : MS_PER_DAY;
|
|
25
|
+
const cap = Math.min(period * config.recurringMaxFractionOfPeriod, config.recurringMaxMs);
|
|
26
|
+
if (!(cap > 0))
|
|
27
|
+
return idealMs;
|
|
28
|
+
return idealMs + cap * fractionFromId(task.id);
|
|
29
|
+
}
|
|
30
|
+
export function oneShotJitteredNextCronRunMs(task, idealMs, config = DEFAULT_CRON_JITTER_CONFIG) {
|
|
31
|
+
if (config.noJitter === true)
|
|
32
|
+
return idealMs;
|
|
33
|
+
if (idealMs % MS_PER_MINUTE !== 0)
|
|
34
|
+
return idealMs;
|
|
35
|
+
const minuteOfHour = new Date(idealMs).getMinutes();
|
|
36
|
+
if (minuteOfHour !== 0 && minuteOfHour !== 30)
|
|
37
|
+
return idealMs;
|
|
38
|
+
if (!(config.oneShotMaxMs > 0))
|
|
39
|
+
return idealMs;
|
|
40
|
+
const shifted = idealMs + -config.oneShotMaxMs * fractionFromId(task.id);
|
|
41
|
+
// Insufficient budget: returning idealMs keeps the fire on schedule, never earlier.
|
|
42
|
+
if (task.createdAt !== undefined && shifted < task.createdAt)
|
|
43
|
+
return idealMs;
|
|
44
|
+
return shifted;
|
|
45
|
+
}
|
|
46
|
+
//# sourceMappingURL=jitter.js.map
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { type ClockSources } from "./clock.ts";
|
|
2
|
+
import type { JitterConfig } from "./jitter.ts";
|
|
3
|
+
import type { CronPersistence } from "./persist.ts";
|
|
4
|
+
import { SessionCronStore, type SessionCronTaskInit } from "./session-store.ts";
|
|
5
|
+
import type { CronTask } from "./types.ts";
|
|
6
|
+
export interface CronManagerOptions {
|
|
7
|
+
readonly clocks?: ClockSources;
|
|
8
|
+
readonly pollIntervalMs?: number | null;
|
|
9
|
+
readonly jitter?: JitterConfig;
|
|
10
|
+
readonly noStale?: boolean;
|
|
11
|
+
readonly isKilled?: () => boolean;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* What the manager needs from its host, narrowed so both worlds can supply it: the cron
|
|
15
|
+
* EXTENSION wires these to `api.actions.steer` / `api.emitEvent` / `api.state`, and a test
|
|
16
|
+
* can hand in stubs. The manager never sees a steer bus, an event sink or a store.
|
|
17
|
+
*/
|
|
18
|
+
export interface CronManagerRuntime {
|
|
19
|
+
/** Deliver a fired task's prompt. `metadata` becomes the `<extension-message>` attributes.
|
|
20
|
+
* Returns whether the message was buffered (no run woke for it), or undefined if delivery
|
|
21
|
+
* was impossible. */
|
|
22
|
+
readonly steer?: (prompt: string, metadata: Readonly<Record<string, string | number | boolean>>) => {
|
|
23
|
+
readonly buffered: boolean;
|
|
24
|
+
} | undefined;
|
|
25
|
+
/** Ephemeral stream signal (e.g. "cron.fired"). */
|
|
26
|
+
readonly emit?: (name: string, data: Record<string, unknown>) => void;
|
|
27
|
+
/** Whether the session currently has no run in flight (scheduler poll gating). */
|
|
28
|
+
readonly isIdle?: () => boolean;
|
|
29
|
+
readonly persistence?: CronPersistence;
|
|
30
|
+
}
|
|
31
|
+
export declare class CronManager {
|
|
32
|
+
readonly store: SessionCronStore;
|
|
33
|
+
readonly clocks: ClockSources;
|
|
34
|
+
private readonly scheduler;
|
|
35
|
+
private readonly noStale;
|
|
36
|
+
private started;
|
|
37
|
+
private runtime;
|
|
38
|
+
private persistQueue;
|
|
39
|
+
constructor(options?: CronManagerOptions);
|
|
40
|
+
attach(runtime: CronManagerRuntime): void;
|
|
41
|
+
addTask(init: SessionCronTaskInit): CronTask;
|
|
42
|
+
removeTasks(ids: readonly string[]): readonly string[];
|
|
43
|
+
private advanceCursor;
|
|
44
|
+
loadPersisted(): Promise<void>;
|
|
45
|
+
private persistSnapshot;
|
|
46
|
+
flushPersist(): Promise<void>;
|
|
47
|
+
start(): void;
|
|
48
|
+
stop(): Promise<void>;
|
|
49
|
+
tick(): void;
|
|
50
|
+
getNextFireTime(): number | null;
|
|
51
|
+
getNextFireForTask(taskId: string): number | null;
|
|
52
|
+
isStale(task: CronTask): boolean;
|
|
53
|
+
private handleFire;
|
|
54
|
+
}
|
|
55
|
+
//# sourceMappingURL=manager.d.ts.map
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { SYSTEM_CLOCKS } from "./clock.js";
|
|
2
|
+
import { SessionCronStore } from "./session-store.js";
|
|
3
|
+
import { createCronScheduler } from "./scheduler.js";
|
|
4
|
+
const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;
|
|
5
|
+
export class CronManager {
|
|
6
|
+
store;
|
|
7
|
+
clocks;
|
|
8
|
+
scheduler;
|
|
9
|
+
noStale;
|
|
10
|
+
started = false;
|
|
11
|
+
runtime = {};
|
|
12
|
+
persistQueue = Promise.resolve();
|
|
13
|
+
constructor(options = {}) {
|
|
14
|
+
this.store = new SessionCronStore();
|
|
15
|
+
this.clocks = options.clocks ?? SYSTEM_CLOCKS;
|
|
16
|
+
this.noStale = options.noStale ?? false;
|
|
17
|
+
this.scheduler = createCronScheduler({
|
|
18
|
+
clocks: this.clocks,
|
|
19
|
+
source: () => this.store.list(),
|
|
20
|
+
isIdle: () => this.runtime.isIdle?.() ?? true,
|
|
21
|
+
...(options.isKilled !== undefined ? { isKilled: options.isKilled } : {}),
|
|
22
|
+
onFire: (task, ctx) => this.handleFire(task, ctx),
|
|
23
|
+
removeOneShot: (id) => {
|
|
24
|
+
this.removeTasks([id]);
|
|
25
|
+
},
|
|
26
|
+
onAdvanceCursor: (id, lastFiredAt) => this.advanceCursor(id, lastFiredAt),
|
|
27
|
+
...(options.pollIntervalMs !== undefined ? { pollIntervalMs: options.pollIntervalMs } : {}),
|
|
28
|
+
...(options.jitter !== undefined ? { jitter: options.jitter } : {}),
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
attach(runtime) {
|
|
32
|
+
this.runtime = runtime;
|
|
33
|
+
}
|
|
34
|
+
addTask(init) {
|
|
35
|
+
const task = this.store.add(init, this.clocks.wallNow());
|
|
36
|
+
this.persistSnapshot();
|
|
37
|
+
return task;
|
|
38
|
+
}
|
|
39
|
+
removeTasks(ids) {
|
|
40
|
+
const removed = this.store.remove(ids);
|
|
41
|
+
if (removed.length > 0)
|
|
42
|
+
this.persistSnapshot();
|
|
43
|
+
return removed;
|
|
44
|
+
}
|
|
45
|
+
advanceCursor(id, lastFiredAt) {
|
|
46
|
+
const updated = this.store.markFired(id, lastFiredAt);
|
|
47
|
+
if (updated === undefined)
|
|
48
|
+
return;
|
|
49
|
+
this.persistSnapshot();
|
|
50
|
+
}
|
|
51
|
+
async loadPersisted() {
|
|
52
|
+
if (this.runtime.persistence === undefined)
|
|
53
|
+
return;
|
|
54
|
+
const tasks = await this.runtime.persistence.load();
|
|
55
|
+
this.store.clear();
|
|
56
|
+
for (const task of tasks)
|
|
57
|
+
this.store.adopt(task);
|
|
58
|
+
}
|
|
59
|
+
// The registry is one state value written whole, so every mutation enqueues a save of the
|
|
60
|
+
// list as of that mutation; the chain serializes writes and the last one wins.
|
|
61
|
+
persistSnapshot() {
|
|
62
|
+
const persistence = this.runtime.persistence;
|
|
63
|
+
if (persistence === undefined)
|
|
64
|
+
return;
|
|
65
|
+
const tasks = [...this.store.list()];
|
|
66
|
+
this.persistQueue = this.persistQueue.catch(() => { }).then(() => persistence.save(tasks)).catch(() => { });
|
|
67
|
+
}
|
|
68
|
+
async flushPersist() {
|
|
69
|
+
await this.persistQueue.catch(() => { });
|
|
70
|
+
}
|
|
71
|
+
start() {
|
|
72
|
+
if (this.started)
|
|
73
|
+
return;
|
|
74
|
+
this.started = true;
|
|
75
|
+
this.scheduler.start();
|
|
76
|
+
}
|
|
77
|
+
async stop() {
|
|
78
|
+
await this.scheduler.stop();
|
|
79
|
+
await this.flushPersist();
|
|
80
|
+
this.started = false;
|
|
81
|
+
}
|
|
82
|
+
tick() {
|
|
83
|
+
this.scheduler.tick();
|
|
84
|
+
}
|
|
85
|
+
getNextFireTime() {
|
|
86
|
+
return this.scheduler.getNextFireTime();
|
|
87
|
+
}
|
|
88
|
+
getNextFireForTask(taskId) {
|
|
89
|
+
return this.scheduler.getNextFireForTask(taskId);
|
|
90
|
+
}
|
|
91
|
+
isStale(task) {
|
|
92
|
+
if (this.noStale)
|
|
93
|
+
return false;
|
|
94
|
+
if (task.recurring === false)
|
|
95
|
+
return false;
|
|
96
|
+
const age = this.clocks.wallNow() - task.createdAt;
|
|
97
|
+
return Number.isFinite(age) && age >= STALE_THRESHOLD_MS;
|
|
98
|
+
}
|
|
99
|
+
handleFire(task, ctx) {
|
|
100
|
+
const stale = this.isStale(task);
|
|
101
|
+
const recurring = task.recurring !== false;
|
|
102
|
+
const receipt = this.runtime.steer?.(task.prompt, {
|
|
103
|
+
jobId: task.id,
|
|
104
|
+
cron: task.cron,
|
|
105
|
+
recurring,
|
|
106
|
+
coalescedCount: ctx.coalescedCount,
|
|
107
|
+
stale,
|
|
108
|
+
});
|
|
109
|
+
this.runtime.emit?.("cron.fired", {
|
|
110
|
+
jobId: task.id,
|
|
111
|
+
cron: task.cron,
|
|
112
|
+
recurring,
|
|
113
|
+
coalescedCount: ctx.coalescedCount,
|
|
114
|
+
stale,
|
|
115
|
+
prompt: task.prompt,
|
|
116
|
+
buffered: receipt === undefined || receipt.buffered,
|
|
117
|
+
});
|
|
118
|
+
// 7-day auto-expire: a stale recurring task gets one final delivery (above) then drops.
|
|
119
|
+
if (stale && recurring)
|
|
120
|
+
this.removeTasks([task.id]);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
//# sourceMappingURL=manager.js.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { CronTask } from "./types.ts";
|
|
2
|
+
export declare const CRON_ID_REGEX: RegExp;
|
|
3
|
+
export declare function isValidCronTask(obj: unknown): obj is CronTask;
|
|
4
|
+
/** Where the registry snapshot lives. The extension supplies this over `api.state` (the
|
|
5
|
+
* per-extension SessionStore KV) — one key, written whole, single writer (the session loop). */
|
|
6
|
+
export interface CronPersistence {
|
|
7
|
+
load(): Promise<CronTask[]>;
|
|
8
|
+
save(tasks: readonly CronTask[]): Promise<void>;
|
|
9
|
+
}
|
|
10
|
+
//# sourceMappingURL=persist.d.ts.map
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export const CRON_ID_REGEX = /^[0-9a-f]{8}$/;
|
|
2
|
+
/** The whole cron registry lives under one state key (bounded by MAX_CRON_JOBS_PER_SESSION). */
|
|
3
|
+
const CRON_STATE_KEY = "cron";
|
|
4
|
+
export function isValidCronTask(obj) {
|
|
5
|
+
if (typeof obj !== "object" || obj === null)
|
|
6
|
+
return false;
|
|
7
|
+
const o = obj;
|
|
8
|
+
if (typeof o["id"] !== "string" || !CRON_ID_REGEX.test(o["id"]))
|
|
9
|
+
return false;
|
|
10
|
+
if (typeof o["cron"] !== "string")
|
|
11
|
+
return false;
|
|
12
|
+
if (typeof o["prompt"] !== "string")
|
|
13
|
+
return false;
|
|
14
|
+
if (typeof o["createdAt"] !== "number")
|
|
15
|
+
return false;
|
|
16
|
+
if (o["recurring"] !== undefined && typeof o["recurring"] !== "boolean")
|
|
17
|
+
return false;
|
|
18
|
+
if (o["lastFiredAt"] !== undefined && (typeof o["lastFiredAt"] !== "number" || !Number.isFinite(o["lastFiredAt"])))
|
|
19
|
+
return false;
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
//# sourceMappingURL=persist.js.map
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ClockSources } from "./clock.ts";
|
|
2
|
+
import { type JitterConfig } from "./jitter.ts";
|
|
3
|
+
import type { CronTask } from "./types.ts";
|
|
4
|
+
export interface CronSchedulerOptions {
|
|
5
|
+
readonly clocks: ClockSources;
|
|
6
|
+
readonly source: () => readonly CronTask[];
|
|
7
|
+
readonly onFire: (task: CronTask, ctx: {
|
|
8
|
+
readonly coalescedCount: number;
|
|
9
|
+
}) => void;
|
|
10
|
+
readonly isIdle: () => boolean;
|
|
11
|
+
readonly isKilled?: () => boolean;
|
|
12
|
+
readonly removeOneShot?: (id: string) => void;
|
|
13
|
+
readonly onAdvanceCursor?: (taskId: string, lastFiredAt: number) => void;
|
|
14
|
+
readonly pollIntervalMs?: number | null;
|
|
15
|
+
readonly jitter?: JitterConfig;
|
|
16
|
+
}
|
|
17
|
+
export interface CronScheduler {
|
|
18
|
+
start(): void;
|
|
19
|
+
stop(): Promise<void>;
|
|
20
|
+
tick(): void;
|
|
21
|
+
getNextFireTime(): number | null;
|
|
22
|
+
getNextFireForTask(taskId: string): number | null;
|
|
23
|
+
}
|
|
24
|
+
export declare function createCronScheduler(opts: CronSchedulerOptions): CronScheduler;
|
|
25
|
+
//# sourceMappingURL=scheduler.d.ts.map
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { computeNextCronRun, parseCronExpression } from "./cron-expr.js";
|
|
2
|
+
import { DEFAULT_CRON_JITTER_CONFIG, jitteredNextCronRunMs, oneShotJitteredNextCronRunMs } from "./jitter.js";
|
|
3
|
+
const DEFAULT_POLL_INTERVAL_MS = 1_000;
|
|
4
|
+
const MAX_COALESCE_ITERATIONS = 10_000;
|
|
5
|
+
export function createCronScheduler(opts) {
|
|
6
|
+
const { clocks, source, onFire, isIdle, isKilled, removeOneShot, onAdvanceCursor, pollIntervalMs } = opts;
|
|
7
|
+
const jitter = opts.jitter ?? DEFAULT_CRON_JITTER_CONFIG;
|
|
8
|
+
const parsedCache = new Map();
|
|
9
|
+
const lastSeenAt = new Map();
|
|
10
|
+
const seededFromDisk = new Set();
|
|
11
|
+
const inFlight = new Set();
|
|
12
|
+
let timerHandle = null;
|
|
13
|
+
function getParsed(expr) {
|
|
14
|
+
const cached = parsedCache.get(expr);
|
|
15
|
+
if (cached !== undefined)
|
|
16
|
+
return cached;
|
|
17
|
+
const parsed = parseCronExpression(expr);
|
|
18
|
+
parsedCache.set(expr, parsed);
|
|
19
|
+
return parsed;
|
|
20
|
+
}
|
|
21
|
+
function debugLog(message) {
|
|
22
|
+
if (process.env["AGENTS_CRON_DEBUG"] === "1")
|
|
23
|
+
process.stderr.write(`[cron/scheduler] ${message}\n`);
|
|
24
|
+
}
|
|
25
|
+
function computeJitteredNext(task, parsed, baseMs) {
|
|
26
|
+
const ideal = computeNextCronRun(parsed, baseMs);
|
|
27
|
+
if (ideal === null)
|
|
28
|
+
return null;
|
|
29
|
+
if (task.recurring === false)
|
|
30
|
+
return oneShotJitteredNextCronRunMs(task, ideal, jitter);
|
|
31
|
+
return jitteredNextCronRunMs(task, parsed, ideal, jitter);
|
|
32
|
+
}
|
|
33
|
+
function countCoalesced(task, parsed, firstFireMs, nowMs) {
|
|
34
|
+
let count = 1;
|
|
35
|
+
let cursor = firstFireMs;
|
|
36
|
+
let lastDueMs = firstFireMs;
|
|
37
|
+
while (count < MAX_COALESCE_ITERATIONS) {
|
|
38
|
+
const next = computeNextCronRun(parsed, cursor);
|
|
39
|
+
if (next === null)
|
|
40
|
+
break;
|
|
41
|
+
if (next > nowMs)
|
|
42
|
+
break;
|
|
43
|
+
const jitteredNext = task.recurring === false ? oneShotJitteredNextCronRunMs(task, next, jitter) : jitteredNextCronRunMs(task, parsed, next, jitter);
|
|
44
|
+
if (jitteredNext > nowMs)
|
|
45
|
+
break;
|
|
46
|
+
count++;
|
|
47
|
+
cursor = next;
|
|
48
|
+
lastDueMs = next;
|
|
49
|
+
}
|
|
50
|
+
return { count, lastDueMs };
|
|
51
|
+
}
|
|
52
|
+
function tick() {
|
|
53
|
+
if (isKilled?.() === true)
|
|
54
|
+
return;
|
|
55
|
+
if (!isIdle())
|
|
56
|
+
return;
|
|
57
|
+
const tasks = source();
|
|
58
|
+
if (tasks.length === 0)
|
|
59
|
+
return;
|
|
60
|
+
const now = clocks.wallNow();
|
|
61
|
+
try {
|
|
62
|
+
for (const task of tasks) {
|
|
63
|
+
try {
|
|
64
|
+
if (inFlight.has(task.id))
|
|
65
|
+
continue;
|
|
66
|
+
const parsed = getParsed(task.cron);
|
|
67
|
+
// Seed `lastSeenAt` from persisted `lastFiredAt` once per task (resume no-replay).
|
|
68
|
+
if (!seededFromDisk.has(task.id) &&
|
|
69
|
+
task.lastFiredAt !== undefined &&
|
|
70
|
+
Number.isFinite(task.lastFiredAt) &&
|
|
71
|
+
task.lastFiredAt <= now &&
|
|
72
|
+
!lastSeenAt.has(task.id)) {
|
|
73
|
+
lastSeenAt.set(task.id, task.lastFiredAt);
|
|
74
|
+
}
|
|
75
|
+
seededFromDisk.add(task.id);
|
|
76
|
+
const seen = lastSeenAt.get(task.id);
|
|
77
|
+
const baseFromMs = seen !== undefined && seen > task.createdAt ? seen : task.createdAt;
|
|
78
|
+
const nextFireAt = computeJitteredNext(task, parsed, baseFromMs);
|
|
79
|
+
if (nextFireAt === null)
|
|
80
|
+
continue;
|
|
81
|
+
if (now < nextFireAt)
|
|
82
|
+
continue;
|
|
83
|
+
const ideal = computeNextCronRun(parsed, baseFromMs);
|
|
84
|
+
let coalescedCount = 1;
|
|
85
|
+
let lastDueMs = null;
|
|
86
|
+
if (task.recurring !== false && ideal !== null) {
|
|
87
|
+
const result = countCoalesced(task, parsed, ideal, now);
|
|
88
|
+
coalescedCount = Math.max(1, result.count);
|
|
89
|
+
lastDueMs = result.lastDueMs;
|
|
90
|
+
}
|
|
91
|
+
inFlight.add(task.id);
|
|
92
|
+
let delivered = false;
|
|
93
|
+
try {
|
|
94
|
+
onFire(task, { coalescedCount });
|
|
95
|
+
delivered = true;
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
debugLog(`onFire threw for task ${task.id}: ${error instanceof Error ? error.message : String(error)}`);
|
|
99
|
+
}
|
|
100
|
+
if (!delivered)
|
|
101
|
+
continue;
|
|
102
|
+
if (task.recurring === false) {
|
|
103
|
+
try {
|
|
104
|
+
removeOneShot?.(task.id);
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
debugLog(`removeOneShot threw for task ${task.id}: ${error instanceof Error ? error.message : String(error)}`);
|
|
108
|
+
}
|
|
109
|
+
lastSeenAt.delete(task.id);
|
|
110
|
+
seededFromDisk.delete(task.id);
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
const advancedTo = lastDueMs ?? now;
|
|
114
|
+
lastSeenAt.set(task.id, advancedTo);
|
|
115
|
+
try {
|
|
116
|
+
onAdvanceCursor?.(task.id, advancedTo);
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
debugLog(`onAdvanceCursor threw for task ${task.id}: ${error instanceof Error ? error.message : String(error)}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
debugLog(`tick failed for task ${task.id}: ${error instanceof Error ? error.message : String(error)}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
finally {
|
|
129
|
+
inFlight.clear();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function start() {
|
|
133
|
+
if (timerHandle !== null)
|
|
134
|
+
return;
|
|
135
|
+
const interval = pollIntervalMs === undefined ? DEFAULT_POLL_INTERVAL_MS : pollIntervalMs;
|
|
136
|
+
if (interval === null || interval === 0)
|
|
137
|
+
return; // no automatic polling
|
|
138
|
+
const handle = setInterval(tick, interval);
|
|
139
|
+
if (typeof handle === "object" && handle !== null && "unref" in handle) {
|
|
140
|
+
handle.unref();
|
|
141
|
+
}
|
|
142
|
+
timerHandle = handle;
|
|
143
|
+
}
|
|
144
|
+
async function stop() {
|
|
145
|
+
if (timerHandle !== null) {
|
|
146
|
+
clearInterval(timerHandle);
|
|
147
|
+
timerHandle = null;
|
|
148
|
+
}
|
|
149
|
+
inFlight.clear();
|
|
150
|
+
lastSeenAt.clear();
|
|
151
|
+
seededFromDisk.clear();
|
|
152
|
+
parsedCache.clear();
|
|
153
|
+
}
|
|
154
|
+
function nextFireFor(task) {
|
|
155
|
+
try {
|
|
156
|
+
const parsed = getParsed(task.cron);
|
|
157
|
+
const seen = lastSeenAt.get(task.id);
|
|
158
|
+
const persistedCursor = task.lastFiredAt !== undefined && Number.isFinite(task.lastFiredAt) && task.lastFiredAt <= clocks.wallNow()
|
|
159
|
+
? task.lastFiredAt
|
|
160
|
+
: undefined;
|
|
161
|
+
const cursor = seen !== undefined ? seen : persistedCursor;
|
|
162
|
+
const baseFromMs = cursor !== undefined && cursor > task.createdAt ? cursor : task.createdAt;
|
|
163
|
+
return computeJitteredNext(task, parsed, baseFromMs);
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
debugLog(`getNextFireFor skipping task ${task.id}: ${error instanceof Error ? error.message : String(error)}`);
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
function getNextFireTime() {
|
|
171
|
+
const tasks = source();
|
|
172
|
+
if (tasks.length === 0)
|
|
173
|
+
return null;
|
|
174
|
+
let min = null;
|
|
175
|
+
for (const task of tasks) {
|
|
176
|
+
const next = nextFireFor(task);
|
|
177
|
+
if (next === null)
|
|
178
|
+
continue;
|
|
179
|
+
if (min === null || next < min)
|
|
180
|
+
min = next;
|
|
181
|
+
}
|
|
182
|
+
return min;
|
|
183
|
+
}
|
|
184
|
+
function getNextFireForTask(taskId) {
|
|
185
|
+
const task = source().find((t) => t.id === taskId);
|
|
186
|
+
if (task === undefined)
|
|
187
|
+
return null;
|
|
188
|
+
return nextFireFor(task);
|
|
189
|
+
}
|
|
190
|
+
return { start, stop, tick, getNextFireTime, getNextFireForTask };
|
|
191
|
+
}
|
|
192
|
+
//# sourceMappingURL=scheduler.js.map
|