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,274 @@
|
|
|
1
|
+
import { errorToBody, failure, notification, request as makeRequest, RpcError, success, toWireSafe, } from "./codec.js";
|
|
2
|
+
import { ErrorCode, INVOKABLE_METHODS, Method, PROTOCOL_VERSION, } from "./protocol.js";
|
|
3
|
+
const INVOKABLE = new Set(INVOKABLE_METHODS);
|
|
4
|
+
export class AppServer {
|
|
5
|
+
harness;
|
|
6
|
+
transport;
|
|
7
|
+
serverInfo;
|
|
8
|
+
log;
|
|
9
|
+
initialized = false;
|
|
10
|
+
clientApproval = false;
|
|
11
|
+
clientQuestion = false;
|
|
12
|
+
wired = new Map();
|
|
13
|
+
reversePending = new Map();
|
|
14
|
+
reverseSeq = 0;
|
|
15
|
+
constructor(opts) {
|
|
16
|
+
this.harness = opts.harness;
|
|
17
|
+
this.transport = opts.transport;
|
|
18
|
+
this.serverInfo = opts.serverInfo ?? { name: "operon-app-server", version: "0.0.0" };
|
|
19
|
+
this.log = opts.log ?? (() => undefined);
|
|
20
|
+
}
|
|
21
|
+
/** Run the dispatch loop until the transport closes. */
|
|
22
|
+
async serve() {
|
|
23
|
+
for await (const message of this.transport.incoming()) {
|
|
24
|
+
// Fire-and-forget: a long-running `session/prompt` must not block reading the
|
|
25
|
+
// next message (cancel / steer / reverse-RPC answers arrive concurrently).
|
|
26
|
+
void this.handleMessage(message);
|
|
27
|
+
}
|
|
28
|
+
await this.shutdown();
|
|
29
|
+
}
|
|
30
|
+
async shutdown() {
|
|
31
|
+
for (const pending of this.reversePending.values()) {
|
|
32
|
+
pending.reject(new RpcError(ErrorCode.RequestCancelled, "transport closed"));
|
|
33
|
+
}
|
|
34
|
+
this.reversePending.clear();
|
|
35
|
+
for (const { unsubscribe } of this.wired.values())
|
|
36
|
+
unsubscribe();
|
|
37
|
+
this.wired.clear();
|
|
38
|
+
await this.harness.close();
|
|
39
|
+
}
|
|
40
|
+
// ── Dispatch ────────────────────────────────────────────────────────────────
|
|
41
|
+
async handleMessage(message) {
|
|
42
|
+
if (typeof message !== "object" || message === null)
|
|
43
|
+
return;
|
|
44
|
+
const m = message;
|
|
45
|
+
const hasMethod = typeof m.method === "string";
|
|
46
|
+
const hasId = typeof m.id === "string" || typeof m.id === "number";
|
|
47
|
+
// A response (no method, has id) is the host answering one of our reverse requests.
|
|
48
|
+
if (!hasMethod && hasId) {
|
|
49
|
+
this.resolveReverse(message);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (hasMethod && hasId) {
|
|
53
|
+
await this.handleRequest(message);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (hasMethod) {
|
|
57
|
+
this.handleNotification(m.method, m.params);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
async handleRequest(req) {
|
|
62
|
+
try {
|
|
63
|
+
const result = await this.route(req.method, req.params);
|
|
64
|
+
this.transport.send(success(req.id, toWireSafe(result)));
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
this.transport.send(failure(req.id, errorToBody(error)));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
handleNotification(method, params) {
|
|
71
|
+
// The host can tell us it dropped its end (rare); nothing else expected inbound.
|
|
72
|
+
this.log(`ignoring notification: ${method}${params === undefined ? "" : ""}`);
|
|
73
|
+
}
|
|
74
|
+
async route(method, params) {
|
|
75
|
+
if (method === Method.Initialize)
|
|
76
|
+
return this.onInitialize(params);
|
|
77
|
+
if (!this.initialized)
|
|
78
|
+
throw new RpcError(ErrorCode.NotInitialized, "call initialize first");
|
|
79
|
+
switch (method) {
|
|
80
|
+
case Method.SessionNew:
|
|
81
|
+
return this.onSessionNew((params ?? {}));
|
|
82
|
+
case Method.SessionResume:
|
|
83
|
+
return this.onSessionResume(params);
|
|
84
|
+
case Method.SessionFork:
|
|
85
|
+
return this.onSessionFork(params);
|
|
86
|
+
case Method.SessionList:
|
|
87
|
+
return this.harness.listSessions();
|
|
88
|
+
case Method.SessionClose:
|
|
89
|
+
return this.onSessionClose(params);
|
|
90
|
+
case Method.SessionPrompt:
|
|
91
|
+
return this.onSessionPrompt(params);
|
|
92
|
+
case Method.SessionSnapshot:
|
|
93
|
+
return this.onSessionSnapshot(params);
|
|
94
|
+
case Method.SessionRespond:
|
|
95
|
+
return this.onSessionRespond(params);
|
|
96
|
+
case Method.SessionSteer:
|
|
97
|
+
return this.onSessionSteer(params);
|
|
98
|
+
case Method.SessionFollowUp:
|
|
99
|
+
return this.onSessionFollowUp(params);
|
|
100
|
+
case Method.SessionCancel:
|
|
101
|
+
return this.onSessionCancel(params);
|
|
102
|
+
case Method.SessionInvoke:
|
|
103
|
+
return this.onSessionInvoke(params);
|
|
104
|
+
default:
|
|
105
|
+
throw new RpcError(ErrorCode.MethodNotFound, `unknown method: ${method}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// ── Handshake ─────────────────────────────────────────────────────────────
|
|
109
|
+
onInitialize(params) {
|
|
110
|
+
if (typeof params?.protocolVersion !== "number") {
|
|
111
|
+
throw new RpcError(ErrorCode.InvalidParams, "protocolVersion (number) required");
|
|
112
|
+
}
|
|
113
|
+
this.clientApproval = params.clientCapabilities?.approval ?? false;
|
|
114
|
+
this.clientQuestion = params.clientCapabilities?.question ?? false;
|
|
115
|
+
this.initialized = true;
|
|
116
|
+
return {
|
|
117
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
118
|
+
serverInfo: this.serverInfo,
|
|
119
|
+
permissionModes: ["manual", "workspace", "yolo"],
|
|
120
|
+
invokableMethods: INVOKABLE_METHODS,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
// ── Fleet ───────────────────────────────────────────────────────────────────
|
|
124
|
+
async onSessionNew(params) {
|
|
125
|
+
const session = await this.harness.createSession({
|
|
126
|
+
...(params.workDir !== undefined ? { workDir: params.workDir } : {}),
|
|
127
|
+
...(params.title !== undefined ? { title: params.title } : {}),
|
|
128
|
+
});
|
|
129
|
+
if (params.model !== undefined)
|
|
130
|
+
session.setModel(params.model);
|
|
131
|
+
this.wireSession(session);
|
|
132
|
+
return { sessionId: session.id, workDir: session.workDir };
|
|
133
|
+
}
|
|
134
|
+
async onSessionResume(params) {
|
|
135
|
+
const session = await this.harness.resumeSession(this.requireId(params));
|
|
136
|
+
this.wireSession(session);
|
|
137
|
+
return { sessionId: session.id, workDir: session.workDir };
|
|
138
|
+
}
|
|
139
|
+
async onSessionFork(params) {
|
|
140
|
+
const session = await this.harness.forkSession(this.requireId(params), {
|
|
141
|
+
...(params.title !== undefined ? { title: params.title } : {}),
|
|
142
|
+
});
|
|
143
|
+
this.wireSession(session);
|
|
144
|
+
return { sessionId: session.id, workDir: session.workDir };
|
|
145
|
+
}
|
|
146
|
+
async onSessionClose(params) {
|
|
147
|
+
const id = this.requireId(params);
|
|
148
|
+
this.wired.get(id)?.unsubscribe();
|
|
149
|
+
this.wired.delete(id);
|
|
150
|
+
this.rejectReverseForSession(id, "session closed");
|
|
151
|
+
await this.harness.closeSession(id);
|
|
152
|
+
return {};
|
|
153
|
+
}
|
|
154
|
+
// ── Run driving ─────────────────────────────────────────────────────────────
|
|
155
|
+
async onSessionPrompt(params) {
|
|
156
|
+
const session = this.requireSession(params);
|
|
157
|
+
// Returns when the run completes; events stream meanwhile. Concurrent prompts
|
|
158
|
+
// queue on the session's run lock (one run at a time) and each gets its own reply.
|
|
159
|
+
return session.prompt(params.input);
|
|
160
|
+
}
|
|
161
|
+
onSessionSnapshot(params) {
|
|
162
|
+
const session = this.requireSession(params);
|
|
163
|
+
// Synchronous on purpose: the response is written into the same ordered stdout stream
|
|
164
|
+
// as `session/event` notifications, so events notified before it are in the snapshot
|
|
165
|
+
// and events after it are not — the exact-seam contract documented on the protocol type.
|
|
166
|
+
return session.snapshot({
|
|
167
|
+
...(params.addresses !== undefined ? { addresses: params.addresses } : {}),
|
|
168
|
+
...(params.maxMessages !== undefined ? { maxMessages: params.maxMessages } : {}),
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
async onSessionRespond(params) {
|
|
172
|
+
const session = this.requireSession(params);
|
|
173
|
+
// Continue a run that durably interrupted for approval: apply the answers and resume.
|
|
174
|
+
// Like prompt, resolves when the run reaches its next stop (which may interrupt again).
|
|
175
|
+
return session.resume(params.answers);
|
|
176
|
+
}
|
|
177
|
+
onSessionSteer(params) {
|
|
178
|
+
const session = this.requireSession(params);
|
|
179
|
+
return { accepted: true, steerId: session.steer(params.text) };
|
|
180
|
+
}
|
|
181
|
+
onSessionFollowUp(params) {
|
|
182
|
+
const session = this.requireSession(params);
|
|
183
|
+
const steerId = session.followUp(params.text);
|
|
184
|
+
return steerId === null ? { accepted: false } : { accepted: true, steerId };
|
|
185
|
+
}
|
|
186
|
+
onSessionCancel(params) {
|
|
187
|
+
const session = this.requireSession(params);
|
|
188
|
+
session.cancel();
|
|
189
|
+
// Any in-flight approval/question for this session can never be answered now.
|
|
190
|
+
this.rejectReverseForSession(session.id, "run cancelled");
|
|
191
|
+
return {};
|
|
192
|
+
}
|
|
193
|
+
async onSessionInvoke(params) {
|
|
194
|
+
const session = this.requireSession(params);
|
|
195
|
+
if (typeof params.method !== "string" || !INVOKABLE.has(params.method)) {
|
|
196
|
+
throw new RpcError(ErrorCode.MethodNotFound, `not invokable: ${String(params.method)}`);
|
|
197
|
+
}
|
|
198
|
+
const fn = session[params.method];
|
|
199
|
+
if (typeof fn !== "function") {
|
|
200
|
+
throw new RpcError(ErrorCode.MethodNotFound, `not a method: ${params.method}`);
|
|
201
|
+
}
|
|
202
|
+
const args = Array.isArray(params.args) ? params.args : [];
|
|
203
|
+
const value = await fn.apply(session, args);
|
|
204
|
+
return { value: toWireSafe(value) };
|
|
205
|
+
}
|
|
206
|
+
// ── Per-session wiring: events out, reverse-RPC for approvals/questions ──────
|
|
207
|
+
wireSession(session) {
|
|
208
|
+
if (this.wired.has(session.id))
|
|
209
|
+
return;
|
|
210
|
+
const unsubscribe = session.onEvent((event) => {
|
|
211
|
+
this.transport.send(notification(Method.SessionEvent, event));
|
|
212
|
+
});
|
|
213
|
+
if (this.clientApproval) {
|
|
214
|
+
session.setApprovalHandler((req) => this.callClient(Method.ClientRequestApproval, session.id, { sessionId: session.id, request: req }));
|
|
215
|
+
}
|
|
216
|
+
if (this.clientQuestion) {
|
|
217
|
+
session.setQuestionHandler((req, options) => this.callClient(Method.ClientRequestQuestion, session.id, { sessionId: session.id, request: req }, options?.signal));
|
|
218
|
+
}
|
|
219
|
+
this.wired.set(session.id, { session, unsubscribe });
|
|
220
|
+
}
|
|
221
|
+
// ── Reverse-RPC (server → host request) ─────────────────────────────────────
|
|
222
|
+
callClient(method, sessionId, params, signal) {
|
|
223
|
+
if (signal?.aborted)
|
|
224
|
+
return Promise.reject(new RpcError(ErrorCode.RequestCancelled, "already aborted"));
|
|
225
|
+
const id = `rev-${++this.reverseSeq}`;
|
|
226
|
+
return new Promise((resolve, reject) => {
|
|
227
|
+
this.reversePending.set(id, { sessionId, resolve, reject });
|
|
228
|
+
if (signal) {
|
|
229
|
+
const onAbort = () => {
|
|
230
|
+
if (!this.reversePending.has(id))
|
|
231
|
+
return;
|
|
232
|
+
this.reversePending.delete(id);
|
|
233
|
+
this.transport.send(notification(Method.ClientCancelRequest, { id }));
|
|
234
|
+
reject(new RpcError(ErrorCode.RequestCancelled, "cancelled"));
|
|
235
|
+
};
|
|
236
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
237
|
+
}
|
|
238
|
+
this.transport.send(makeRequest(id, method, params));
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
resolveReverse(response) {
|
|
242
|
+
const pending = this.reversePending.get(response.id);
|
|
243
|
+
if (!pending)
|
|
244
|
+
return;
|
|
245
|
+
this.reversePending.delete(response.id);
|
|
246
|
+
if ("error" in response)
|
|
247
|
+
pending.reject(new RpcError(response.error.code, response.error.message, response.error.data));
|
|
248
|
+
else
|
|
249
|
+
pending.resolve(response.result);
|
|
250
|
+
}
|
|
251
|
+
rejectReverseForSession(sessionId, reason) {
|
|
252
|
+
for (const [id, pending] of this.reversePending) {
|
|
253
|
+
if (pending.sessionId !== sessionId)
|
|
254
|
+
continue;
|
|
255
|
+
this.reversePending.delete(id);
|
|
256
|
+
this.transport.send(notification(Method.ClientCancelRequest, { id }));
|
|
257
|
+
pending.reject(new RpcError(ErrorCode.RequestCancelled, reason));
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
261
|
+
requireId(ref) {
|
|
262
|
+
if (!ref || typeof ref.sessionId !== "string")
|
|
263
|
+
throw new RpcError(ErrorCode.InvalidParams, "sessionId required");
|
|
264
|
+
return ref.sessionId;
|
|
265
|
+
}
|
|
266
|
+
requireSession(ref) {
|
|
267
|
+
const id = this.requireId(ref);
|
|
268
|
+
const session = this.harness.getSession(id);
|
|
269
|
+
if (!session)
|
|
270
|
+
throw new RpcError(ErrorCode.SessionNotFound, `session not found: ${id}`);
|
|
271
|
+
return session;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
//# sourceMappingURL=server.js.map
|
package/dist/core.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mode 1 — low-level primitives (the engine, assemble-it-yourself).
|
|
3
|
+
*
|
|
4
|
+
* `import { Runner, defineAgent, defineModel } from 'operon-agents/core'`
|
|
5
|
+
*
|
|
6
|
+
* Everything the core engine exposes: Runner / Session / Agent / capabilities /
|
|
7
|
+
* machines / tools. Pass no machine and omit file tools to run a stateless,
|
|
8
|
+
* no-filesystem agent (a `NullMachine` refuses any stray I/O). For the
|
|
9
|
+
* batteries-included facade, import from `operon-agents` instead.
|
|
10
|
+
*/
|
|
11
|
+
export * from "operon-agents-core";
|
|
12
|
+
//# sourceMappingURL=core.d.ts.map
|
package/dist/core.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mode 1 — low-level primitives (the engine, assemble-it-yourself).
|
|
3
|
+
*
|
|
4
|
+
* `import { Runner, defineAgent, defineModel } from 'operon-agents/core'`
|
|
5
|
+
*
|
|
6
|
+
* Everything the core engine exposes: Runner / Session / Agent / capabilities /
|
|
7
|
+
* machines / tools. Pass no machine and omit file tools to run a stateless,
|
|
8
|
+
* no-filesystem agent (a `NullMachine` refuses any stray I/O). For the
|
|
9
|
+
* batteries-included facade, import from `operon-agents` instead.
|
|
10
|
+
*/
|
|
11
|
+
export * from "operon-agents-core";
|
|
12
|
+
//# sourceMappingURL=core.js.map
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface ClockSources {
|
|
2
|
+
wallNow(): number;
|
|
3
|
+
monoNowMs(): number;
|
|
4
|
+
}
|
|
5
|
+
export declare const SYSTEM_CLOCKS: ClockSources;
|
|
6
|
+
export interface MutableClock extends ClockSources {
|
|
7
|
+
set(ms: number): void;
|
|
8
|
+
advance(deltaMs: number): void;
|
|
9
|
+
}
|
|
10
|
+
export declare function mutableClock(initialMs: number): MutableClock;
|
|
11
|
+
//# sourceMappingURL=clock.d.ts.map
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const systemMonoNowMs = () => Number(process.hrtime.bigint() / 1000000n);
|
|
2
|
+
export const SYSTEM_CLOCKS = {
|
|
3
|
+
wallNow: () => Date.now(),
|
|
4
|
+
monoNowMs: systemMonoNowMs,
|
|
5
|
+
};
|
|
6
|
+
export function mutableClock(initialMs) {
|
|
7
|
+
let wall = initialMs;
|
|
8
|
+
return {
|
|
9
|
+
wallNow: () => wall,
|
|
10
|
+
monoNowMs: systemMonoNowMs,
|
|
11
|
+
set: (ms) => {
|
|
12
|
+
wall = ms;
|
|
13
|
+
},
|
|
14
|
+
advance: (deltaMs) => {
|
|
15
|
+
wall += deltaMs;
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
//# sourceMappingURL=clock.js.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface ParsedCronExpression {
|
|
2
|
+
readonly raw: string;
|
|
3
|
+
readonly minutes: ReadonlySet<number>;
|
|
4
|
+
readonly hours: ReadonlySet<number>;
|
|
5
|
+
readonly daysOfMonth: ReadonlySet<number>;
|
|
6
|
+
readonly months: ReadonlySet<number>;
|
|
7
|
+
readonly daysOfWeek: ReadonlySet<number>;
|
|
8
|
+
readonly daysOfMonthWildcard: boolean;
|
|
9
|
+
readonly daysOfWeekWildcard: boolean;
|
|
10
|
+
}
|
|
11
|
+
export declare function parseCronExpression(expr: string): ParsedCronExpression;
|
|
12
|
+
export declare function computeNextCronRun(expr: ParsedCronExpression, fromMs: number): number | null;
|
|
13
|
+
export declare function hasFireWithinYears(expr: ParsedCronExpression, years: number, fromMs: number): boolean;
|
|
14
|
+
export declare function cronToHuman(expr: ParsedCronExpression): string;
|
|
15
|
+
//# sourceMappingURL=cron-expr.d.ts.map
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
const MINUTE_RANGE = { min: 0, max: 59 };
|
|
2
|
+
const HOUR_RANGE = { min: 0, max: 23 };
|
|
3
|
+
const DOM_RANGE = { min: 1, max: 31 };
|
|
4
|
+
const MONTH_RANGE = { min: 1, max: 12 };
|
|
5
|
+
const DOW_RANGE = { min: 0, max: 7 }; // 7 → 0 fold after parse
|
|
6
|
+
const MS_PER_MINUTE = 60_000;
|
|
7
|
+
export function parseCronExpression(expr) {
|
|
8
|
+
if (typeof expr !== "string")
|
|
9
|
+
throw new TypeError("cron expression must be a string");
|
|
10
|
+
const trimmed = expr.trim();
|
|
11
|
+
if (trimmed === "")
|
|
12
|
+
throw new Error("cron expression is empty");
|
|
13
|
+
const fields = trimmed.split(/\s+/);
|
|
14
|
+
if (fields.length !== 5) {
|
|
15
|
+
throw new Error(`cron expression must have exactly 5 fields (minute hour day-of-month month day-of-week); got ${fields.length}`);
|
|
16
|
+
}
|
|
17
|
+
const [minField, hourField, domField, monthField, dowField] = fields;
|
|
18
|
+
const minutes = parseField(minField, MINUTE_RANGE.min, MINUTE_RANGE.max, "minute");
|
|
19
|
+
const hours = parseField(hourField, HOUR_RANGE.min, HOUR_RANGE.max, "hour");
|
|
20
|
+
const daysOfMonth = parseField(domField, DOM_RANGE.min, DOM_RANGE.max, "day-of-month");
|
|
21
|
+
const months = parseField(monthField, MONTH_RANGE.min, MONTH_RANGE.max, "month");
|
|
22
|
+
const dowRaw = parseField(dowField, DOW_RANGE.min, DOW_RANGE.max, "day-of-week");
|
|
23
|
+
const daysOfWeek = new Set();
|
|
24
|
+
for (const v of dowRaw)
|
|
25
|
+
daysOfWeek.add(v === 7 ? 0 : v);
|
|
26
|
+
return {
|
|
27
|
+
raw: trimmed,
|
|
28
|
+
minutes,
|
|
29
|
+
hours,
|
|
30
|
+
daysOfMonth,
|
|
31
|
+
months,
|
|
32
|
+
daysOfWeek,
|
|
33
|
+
daysOfMonthWildcard: isWildcard(domField),
|
|
34
|
+
daysOfWeekWildcard: isWildcard(dowField),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function isWildcard(field) {
|
|
38
|
+
// Only bare `*` suppresses the dom/dow OR rule; `*/n` is a restriction (cron tradition).
|
|
39
|
+
return field === "*";
|
|
40
|
+
}
|
|
41
|
+
function parseField(field, min, max, name) {
|
|
42
|
+
if (field === "")
|
|
43
|
+
throw new Error(`cron ${name} field is empty`);
|
|
44
|
+
const out = new Set();
|
|
45
|
+
for (const term of field.split(",")) {
|
|
46
|
+
if (term === "")
|
|
47
|
+
throw new Error(`cron ${name} field has empty term in list`);
|
|
48
|
+
addTerm(out, term, min, max, name);
|
|
49
|
+
}
|
|
50
|
+
if (out.size === 0)
|
|
51
|
+
throw new Error(`cron ${name} field matches no values`);
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
// Cron numeric fields are digit-only; `Number(...)` would otherwise accept '', '1e1',
|
|
55
|
+
// '0x10', '+5', ' 3 ' — none valid cron. Gate before conversion to machine typos.
|
|
56
|
+
const DIGIT_ONLY = /^\d+$/;
|
|
57
|
+
function parseCronInt(raw, name, role) {
|
|
58
|
+
if (!DIGIT_ONLY.test(raw)) {
|
|
59
|
+
throw new Error(`cron ${name} ${role} must be a non-negative integer with digits only (got ${JSON.stringify(raw)})`);
|
|
60
|
+
}
|
|
61
|
+
return Number.parseInt(raw, 10);
|
|
62
|
+
}
|
|
63
|
+
function addTerm(out, term, min, max, name) {
|
|
64
|
+
let rangePart = term;
|
|
65
|
+
let step = 1;
|
|
66
|
+
const slash = term.indexOf("/");
|
|
67
|
+
if (slash !== -1) {
|
|
68
|
+
rangePart = term.slice(0, slash);
|
|
69
|
+
const stepStr = term.slice(slash + 1);
|
|
70
|
+
if (stepStr === "")
|
|
71
|
+
throw new Error(`cron ${name} step is empty in "${term}"`);
|
|
72
|
+
const parsedStep = parseCronInt(stepStr, name, "step");
|
|
73
|
+
if (parsedStep <= 0)
|
|
74
|
+
throw new Error(`cron ${name} step must be a positive integer (got "${stepStr}")`);
|
|
75
|
+
step = parsedStep;
|
|
76
|
+
if (rangePart === "")
|
|
77
|
+
throw new Error(`cron ${name} step needs a range or "*" before "/" in "${term}"`);
|
|
78
|
+
}
|
|
79
|
+
let lo;
|
|
80
|
+
let hi;
|
|
81
|
+
if (rangePart === "*") {
|
|
82
|
+
lo = min;
|
|
83
|
+
hi = max;
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
const dash = rangePart.indexOf("-");
|
|
87
|
+
if (dash === -1) {
|
|
88
|
+
const single = parseCronInt(rangePart, name, "value");
|
|
89
|
+
if (single < min || single > max)
|
|
90
|
+
throw new Error(`cron ${name} value ${single} out of range ${min}..${max}`);
|
|
91
|
+
// A bare single value with a step (`5/10`) = "from value through max stepping by N".
|
|
92
|
+
if (slash !== -1) {
|
|
93
|
+
lo = single;
|
|
94
|
+
hi = max;
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
out.add(single);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
const loStr = rangePart.slice(0, dash);
|
|
103
|
+
const hiStr = rangePart.slice(dash + 1);
|
|
104
|
+
lo = parseCronInt(loStr, name, "range lower bound");
|
|
105
|
+
hi = parseCronInt(hiStr, name, "range upper bound");
|
|
106
|
+
if (lo < min || hi > max || lo > hi) {
|
|
107
|
+
throw new Error(`cron ${name} range ${lo}-${hi} out of bounds (must be ${min}..${max}, ascending)`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
for (let v = lo; v <= hi; v += step)
|
|
112
|
+
out.add(v);
|
|
113
|
+
}
|
|
114
|
+
export function computeNextCronRun(expr, fromMs) {
|
|
115
|
+
return nextRunWithinMinutes(expr, fromMs, 5 * 366 * 24 * 60);
|
|
116
|
+
}
|
|
117
|
+
export function hasFireWithinYears(expr, years, fromMs) {
|
|
118
|
+
const cap = Math.max(1, Math.floor(years * 366 * 24 * 60));
|
|
119
|
+
return nextRunWithinMinutes(expr, fromMs, cap) !== null;
|
|
120
|
+
}
|
|
121
|
+
function nextRunWithinMinutes(expr, fromMs, capMinutes) {
|
|
122
|
+
// Seek strictly into the next minute so we never return `fromMs` itself.
|
|
123
|
+
const start = new Date(fromMs);
|
|
124
|
+
start.setSeconds(0, 0);
|
|
125
|
+
const date = new Date(start.getTime() + MS_PER_MINUTE);
|
|
126
|
+
const deadlineMs = fromMs + capMinutes * MS_PER_MINUTE;
|
|
127
|
+
let iterations = 0;
|
|
128
|
+
const HARD_ITERATION_CAP = 10_000_000;
|
|
129
|
+
while (date.getTime() <= deadlineMs && iterations++ < HARD_ITERATION_CAP) {
|
|
130
|
+
if (!expr.months.has(date.getMonth() + 1)) {
|
|
131
|
+
advanceMonth(date);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (!dayMatches(expr, date)) {
|
|
135
|
+
advanceDay(date);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (!expr.hours.has(date.getHours())) {
|
|
139
|
+
advanceHour(date);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (!expr.minutes.has(date.getMinutes())) {
|
|
143
|
+
advanceMinute(date);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
return date.getTime();
|
|
147
|
+
}
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
function dayMatches(expr, date) {
|
|
151
|
+
const domOk = expr.daysOfMonth.has(date.getDate());
|
|
152
|
+
const dowOk = expr.daysOfWeek.has(date.getDay());
|
|
153
|
+
if (expr.daysOfMonthWildcard && expr.daysOfWeekWildcard)
|
|
154
|
+
return true;
|
|
155
|
+
if (expr.daysOfMonthWildcard)
|
|
156
|
+
return dowOk;
|
|
157
|
+
if (expr.daysOfWeekWildcard)
|
|
158
|
+
return domOk;
|
|
159
|
+
return domOk || dowOk; // both restricted → cron-style OR
|
|
160
|
+
}
|
|
161
|
+
function advanceMonth(date) {
|
|
162
|
+
date.setDate(1);
|
|
163
|
+
date.setHours(0, 0, 0, 0);
|
|
164
|
+
date.setMonth(date.getMonth() + 1);
|
|
165
|
+
}
|
|
166
|
+
function advanceDay(date) {
|
|
167
|
+
date.setHours(0, 0, 0, 0);
|
|
168
|
+
date.setDate(date.getDate() + 1);
|
|
169
|
+
}
|
|
170
|
+
function advanceHour(date) {
|
|
171
|
+
date.setMinutes(0, 0, 0);
|
|
172
|
+
date.setHours(date.getHours() + 1);
|
|
173
|
+
}
|
|
174
|
+
function advanceMinute(date) {
|
|
175
|
+
date.setSeconds(0, 0);
|
|
176
|
+
date.setMinutes(date.getMinutes() + 1);
|
|
177
|
+
}
|
|
178
|
+
const MONTH_NAMES = [
|
|
179
|
+
"January", "February", "March", "April", "May", "June",
|
|
180
|
+
"July", "August", "September", "October", "November", "December",
|
|
181
|
+
];
|
|
182
|
+
const DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
|
183
|
+
export function cronToHuman(expr) {
|
|
184
|
+
const allMin = isFullRange(expr.minutes, 0, 59);
|
|
185
|
+
const allHour = isFullRange(expr.hours, 0, 23);
|
|
186
|
+
const allDom = expr.daysOfMonthWildcard;
|
|
187
|
+
const allMonth = isFullRange(expr.months, 1, 12);
|
|
188
|
+
const allDow = expr.daysOfWeekWildcard;
|
|
189
|
+
if (allHour && allDom && allMonth && allDow) {
|
|
190
|
+
const step = detectStep(expr.minutes, 0, 59);
|
|
191
|
+
if (step !== null && step > 1)
|
|
192
|
+
return `every ${step} minutes`;
|
|
193
|
+
if (allMin)
|
|
194
|
+
return "every minute";
|
|
195
|
+
if (expr.minutes.size === 1)
|
|
196
|
+
return `at minute ${[...expr.minutes][0]} of every hour`;
|
|
197
|
+
}
|
|
198
|
+
if (expr.minutes.size === 1 && allDom && allMonth && allDow) {
|
|
199
|
+
const m = [...expr.minutes][0];
|
|
200
|
+
const step = detectStep(expr.hours, 0, 23);
|
|
201
|
+
if (step !== null && step > 1)
|
|
202
|
+
return `every ${step} hours at minute ${pad(m)}`;
|
|
203
|
+
}
|
|
204
|
+
if (expr.minutes.size === 1 && expr.hours.size === 1 && allDom && allMonth) {
|
|
205
|
+
const h = [...expr.hours][0];
|
|
206
|
+
const m = [...expr.minutes][0];
|
|
207
|
+
if (allDow)
|
|
208
|
+
return `at ${pad(h)}:${pad(m)} every day`;
|
|
209
|
+
const dowStr = formatDows(expr.daysOfWeek);
|
|
210
|
+
if (dowStr !== null)
|
|
211
|
+
return `at ${pad(h)}:${pad(m)} on ${dowStr}`;
|
|
212
|
+
}
|
|
213
|
+
if (expr.minutes.size === 1 &&
|
|
214
|
+
expr.hours.size === 1 &&
|
|
215
|
+
expr.daysOfMonth.size === 1 &&
|
|
216
|
+
!expr.daysOfMonthWildcard &&
|
|
217
|
+
expr.months.size === 1 &&
|
|
218
|
+
allDow) {
|
|
219
|
+
const h = [...expr.hours][0];
|
|
220
|
+
const m = [...expr.minutes][0];
|
|
221
|
+
const d = [...expr.daysOfMonth][0];
|
|
222
|
+
const mo = [...expr.months][0];
|
|
223
|
+
return `at ${pad(h)}:${pad(m)} on day ${d} of ${MONTH_NAMES[mo - 1]}`;
|
|
224
|
+
}
|
|
225
|
+
return expr.raw;
|
|
226
|
+
}
|
|
227
|
+
function isFullRange(set, min, max) {
|
|
228
|
+
if (set.size !== max - min + 1)
|
|
229
|
+
return false;
|
|
230
|
+
for (let v = min; v <= max; v++)
|
|
231
|
+
if (!set.has(v))
|
|
232
|
+
return false;
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
235
|
+
function detectStep(set, min, max) {
|
|
236
|
+
const values = [...set].toSorted((a, b) => a - b);
|
|
237
|
+
if (values.length < 2)
|
|
238
|
+
return null;
|
|
239
|
+
if (values[0] !== min)
|
|
240
|
+
return null;
|
|
241
|
+
const step = values[1] - values[0];
|
|
242
|
+
if (step <= 0)
|
|
243
|
+
return null;
|
|
244
|
+
let expected = min;
|
|
245
|
+
for (const v of values) {
|
|
246
|
+
if (v !== expected)
|
|
247
|
+
return null;
|
|
248
|
+
expected += step;
|
|
249
|
+
}
|
|
250
|
+
if (expected - step > max)
|
|
251
|
+
return null;
|
|
252
|
+
return step;
|
|
253
|
+
}
|
|
254
|
+
function formatDows(set) {
|
|
255
|
+
const values = [...set].toSorted((a, b) => a - b);
|
|
256
|
+
if (values.length === 0)
|
|
257
|
+
return null;
|
|
258
|
+
if (values.length === 5 && values.every((v, i) => v === i + 1))
|
|
259
|
+
return "weekdays";
|
|
260
|
+
if (values.length === 2 && values[0] === 0 && values[1] === 6)
|
|
261
|
+
return "weekends";
|
|
262
|
+
return values.map((v) => DAY_NAMES[v]).join(", ");
|
|
263
|
+
}
|
|
264
|
+
function pad(n) {
|
|
265
|
+
return n < 10 ? `0${n}` : String(n);
|
|
266
|
+
}
|
|
267
|
+
//# sourceMappingURL=cron-expr.js.map
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cron as an EXTENSION — the first capability migrated out of the engine. It rides the four
|
|
3
|
+
* generic seams (registerCommand / emitEvent / expose / state, plus steer metadata), so a host
|
|
4
|
+
* gets `/cron`, cron events and a programmatic handle by ATTACHING it — no wiring. The local
|
|
5
|
+
* deployment profile attaches it; a server host simply doesn't, which makes Invariant 7
|
|
6
|
+
* ("cron is local-only") structural instead of an option someone must remember to pass.
|
|
7
|
+
*/
|
|
8
|
+
import type { ExtensionDefinition } from "../extensions/types.ts";
|
|
9
|
+
import { CronManager, type CronManagerOptions } from "./manager.ts";
|
|
10
|
+
import type { SessionCronTaskInit } from "./session-store.ts";
|
|
11
|
+
import type { CronTask } from "./types.ts";
|
|
12
|
+
/** The host-facing control surface (`session.extensionHandle<CronHandle>("cron")`) — what the
|
|
13
|
+
* old `session.createCronTask/…` facade became. */
|
|
14
|
+
export interface CronHandle {
|
|
15
|
+
addTask(input: SessionCronTaskInit): CronTask;
|
|
16
|
+
listTasks(): readonly CronTask[];
|
|
17
|
+
removeTask(id: string): boolean;
|
|
18
|
+
tick(): void;
|
|
19
|
+
getNextFireTime(taskId?: string): number | null;
|
|
20
|
+
}
|
|
21
|
+
/** Host-path validation (the tools carry their own): trims/caps the input, refuses expressions
|
|
22
|
+
* that never fire within 5 years, enforces the per-session job cap. */
|
|
23
|
+
export declare function normalizeCronTaskInput(manager: CronManager, input: SessionCronTaskInit): SessionCronTaskInit;
|
|
24
|
+
export declare function cronExtension(options?: CronManagerOptions): ExtensionDefinition;
|
|
25
|
+
//# sourceMappingURL=extension.d.ts.map
|