@stonyx/cron 0.2.1-beta.13 → 0.2.1-beta.130

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/dist/main.d.ts ADDED
@@ -0,0 +1,66 @@
1
+ import MinHeap, { type HeapItem } from './min-heap.js';
2
+ interface CronJob extends HeapItem {
3
+ callback: () => void | Promise<void>;
4
+ interval: string;
5
+ key: string;
6
+ /**
7
+ * Timestamp (ms) at which the current invocation started; `undefined` when the
8
+ * job is idle. Optional so the emitted `CronJob` stays assignable from a job
9
+ * object built by a consumer — `jobs`, `heap` and `setNextTrigger` all expose
10
+ * this interface structurally, so a required field is a breaking type change.
11
+ *
12
+ * A timestamp rather than a boolean, mirroring `job.state.runningAtMs` in the
13
+ * service tier (`markRunning` / `applyResult` / `isDue` in `src/job.ts`), and
14
+ * carrying the one fact a stuck-job warning needs: how long it has been stuck.
15
+ * `CronService.running` is a class-level re-entrancy flag and a different
16
+ * concept; reusing that word here would collide.
17
+ */
18
+ runningAtMs?: number;
19
+ /**
20
+ * True once a skip has been reported for the *current* invocation. Bounds the
21
+ * still-running warning to one line per stuck run instead of one per tick.
22
+ */
23
+ skipReported?: boolean;
24
+ }
25
+ export default class Cron {
26
+ static instance: Cron | null;
27
+ jobs: Record<string, CronJob>;
28
+ heap: MinHeap<CronJob>;
29
+ timer: ReturnType<typeof setTimeout> | null;
30
+ constructor();
31
+ init(): Promise<void>;
32
+ scheduleNextRun(): void;
33
+ runDueJobs(): Promise<void>;
34
+ register(key: string, callback: () => void | Promise<void>, interval: string, runOnInit?: boolean): void;
35
+ unregister(key: string): void;
36
+ /**
37
+ * The one place this class invokes a consumer callback.
38
+ *
39
+ * Never blocks the caller, catches synchronous throws and asynchronous
40
+ * rejections identically, and skips the invocation entirely while the job's
41
+ * previous invocation has not settled (fire-and-forget would otherwise let a
42
+ * slow job stack invocations on itself).
43
+ *
44
+ * Everything that touches the callback — including the thenable probe and the
45
+ * handler attachment — is inside the `try`. A callback may return an object
46
+ * whose `then` is a throwing getter, and reading it outside the guard would
47
+ * abort the drain loop before `scheduleNextRun()`, which is defect #36 again.
48
+ */
49
+ invokeJob(job: CronJob, runOnInit?: boolean): void;
50
+ /**
51
+ * Report a scheduler-level message without ever letting the logger's own
52
+ * failure reach the caller.
53
+ *
54
+ * `@stonyx/logs` convenience methods return a promise and write to disk
55
+ * through an unguarded `mkdirSync` + `fsp.appendFile`. On a read-only or full
56
+ * log volume that promise rejects; an unobserved rejection raised from inside
57
+ * the handler that exists to prevent unhandled rejections would re-create
58
+ * exactly the defect this class was fixed for.
59
+ */
60
+ report(level: 'error' | 'warn', message: string): void;
61
+ /** Release a job's in-flight guard. Only ever called for the job it belongs to. */
62
+ release(job: CronJob): void;
63
+ setNextTrigger(job: CronJob): void;
64
+ log(text: string, key?: string | null): void;
65
+ }
66
+ export {};
package/dist/main.js ADDED
@@ -0,0 +1,292 @@
1
+ /*
2
+ * Copyright 2025 Stone Costa
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the 'License');
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+ import config from 'stonyx/config';
17
+ import log from 'stonyx/log';
18
+ import { getTimestamp } from '@stonyx/utils/date';
19
+ import MinHeap from './min-heap.js';
20
+ /** Longest error text that may reach a log line. Anything past this is truncated. */
21
+ const MAX_LOGGED_ERROR_LENGTH = 512;
22
+ /**
23
+ * Flatten a value for interpolation into a single log line.
24
+ *
25
+ * `@stonyx/logs` writes `${timestamp} ${content}\n` to a newline-delimited
26
+ * file, so any `\r` or `\n` inside `content` ends the record early and
27
+ * everything after it is read back as a separate entry — including a forged
28
+ * `[timestamp] ...` prefix that is indistinguishable from a real one. Newlines
29
+ * become the literal two characters so the content survives for a reader, and
30
+ * the length cap keeps one pathological value from swamping the file.
31
+ *
32
+ * DIVERGED from the `forLog` in `src/service.ts`, deliberately — this copy is
33
+ * NOT byte-identical to it and must not be folded into it by assuming it is.
34
+ * (An earlier version of this docblock claimed byte-identity; #34's `fac09cb`
35
+ * falsified that, and the two bodies now measure unequal.) `service.ts`'s is
36
+ * TOTAL: it wraps the coercion in `String(value)` and a `try`, returning
37
+ * `'<unrenderable value>'` rather than throwing. This one is not.
38
+ *
39
+ * The divergence is correct on the merits, and the reason is the call site, not
40
+ * the helper. Both of this copy's callers are the two `forLog(...)` calls in
41
+ * `describeError` directly below (`:82`, `:85`), and both are INSIDE that
42
+ * function's own `try`, so a `TypeError` from `value.replace` on a
43
+ * non-string degrades to `'<thrown value could not be rendered>'` and the log
44
+ * record is still produced. `service.ts`'s callers are not so contained: `:700`
45
+ * sits in a bare `catch` with nothing above it, and `:579`'s enclosing `catch`
46
+ * has nothing left to report to — so a throw there destroyed the failure
47
+ * record outright (measured: 0 records for a job that failed). That is why
48
+ * totality was a live defect there and is not one here.
49
+ *
50
+ * Duplicated rather than shared because the two landed on separate branches.
51
+ * The fold is still wanted, but whoever performs it MUST adopt `service.ts`'s
52
+ * total version as the survivor: folding to this one would silently revert
53
+ * `fac09cb`. See #66, which carries the consolidation.
54
+ */
55
+ function forLog(value, maxLength) {
56
+ const flattened = value.replace(/\r\n|[\r\n\u2028\u2029]/g, '\\n');
57
+ return flattened.length > maxLength ? `${flattened.slice(0, maxLength)}...` : flattened;
58
+ }
59
+ /**
60
+ * Render an unknown thrown value as log text. Total by construction.
61
+ *
62
+ * `@stonyx/logs` reads a second argument as `logToFile`, not as a format
63
+ * argument, so `log.error(message, err)` discards the error entirely *and*
64
+ * forces a disk write on every failure. The error has to be interpolated into
65
+ * the message instead — the shape `CronService.executeJob` already uses.
66
+ *
67
+ * Every read below touches a consumer-controlled value and can therefore throw:
68
+ * `instanceof` runs a proxy's `getPrototypeOf` trap, `stack`/`name`/`message`
69
+ * can be accessor properties, and `String(Object.create(null))` throws outright.
70
+ * This function runs *inside* `invokeJob`'s catch — the one place whose job is
71
+ * to stop a callback failure from reaching the scheduler — so a throw here
72
+ * escapes that catch and skips `scheduleNextRun()`, which is defect #36.
73
+ */
74
+ function describeError(err) {
75
+ try {
76
+ if (err instanceof Error) {
77
+ return forLog(err.stack ?? `${err.name}: ${err.message}`, MAX_LOGGED_ERROR_LENGTH);
78
+ }
79
+ return forLog(String(err), MAX_LOGGED_ERROR_LENGTH);
80
+ }
81
+ catch {
82
+ // Deliberately not re-entrant: describing the failure to describe the error
83
+ // would be the same read that just threw.
84
+ return '<thrown value could not be rendered>';
85
+ }
86
+ }
87
+ /**
88
+ * Render a consumer-supplied job key safely for log output.
89
+ *
90
+ * Keys reach the log verbatim, so a key containing a newline can forge a
91
+ * complete, well-formed log line (`'a:\n[FORGED] Cron::admin - all jobs
92
+ * healthy'`). `JSON.stringify` quotes the value and escapes the control
93
+ * characters, which is also how the key is rendered one tier up.
94
+ */
95
+ function describeKey(key) {
96
+ return JSON.stringify(key);
97
+ }
98
+ export default class Cron {
99
+ static instance;
100
+ jobs = {};
101
+ heap = new MinHeap();
102
+ timer = null;
103
+ constructor() {
104
+ if (Cron.instance)
105
+ return Cron.instance;
106
+ Cron.instance = this;
107
+ }
108
+ async init() {
109
+ // Self-register so log.cron works even when @stonyx/cron is in the
110
+ // consumer's `dependencies` (stonyx loader only merges devDependencies).
111
+ const { logColor = '#888', logMethod = 'cron' } = config.cron ?? {};
112
+ log.defineType(logMethod, logColor);
113
+ }
114
+ scheduleNextRun() {
115
+ if (this.timer)
116
+ clearTimeout(this.timer);
117
+ const { heap } = this;
118
+ if (heap.isEmpty())
119
+ return;
120
+ const nextJob = heap.peek();
121
+ if (!nextJob)
122
+ return;
123
+ const delay = Math.max(0, nextJob.nextTrigger - getTimestamp()) * 1000;
124
+ // Terminal catch: `runDueJobs` is async, so anything that escapes it would
125
+ // otherwise become an unhandled rejection raised from a bare timer callback
126
+ // — the very failure mode this class was fixed for.
127
+ this.timer = setTimeout(() => {
128
+ this.runDueJobs().catch((err) => {
129
+ this.report('error', `Cron scheduler tick failed: ${describeError(err)}`);
130
+ });
131
+ }, delay);
132
+ }
133
+ async runDueJobs() {
134
+ const now = getTimestamp();
135
+ const { heap } = this;
136
+ // `finally`, not a trailing statement: `scheduleNextRun()` running on every
137
+ // exit from the drain loop is the invariant this whole fix is about. If
138
+ // anything in the loop body ever throws, the scheduler must still re-arm
139
+ // rather than stopping silently while `timer` still holds a fired handle.
140
+ try {
141
+ while (!heap.isEmpty()) {
142
+ const next = heap.peek();
143
+ if (!next || next.nextTrigger > now)
144
+ break;
145
+ const job = heap.pop();
146
+ if (config.debug)
147
+ this.log('job has been triggered', job.key);
148
+ // Reschedule before invoking: a consumer callback is never awaited here,
149
+ // so a callback that hangs or rejects can no longer starve the drain loop
150
+ // or leave the job orphaned outside the heap.
151
+ this.setNextTrigger(job);
152
+ heap.push(job);
153
+ this.invokeJob(job);
154
+ }
155
+ }
156
+ finally {
157
+ this.scheduleNextRun();
158
+ }
159
+ }
160
+ register(key, callback, interval, runOnInit = false) {
161
+ const job = { callback, interval, key, nextTrigger: 0 };
162
+ this.jobs[key] = job;
163
+ this.setNextTrigger(job);
164
+ this.heap.push(job);
165
+ if (config.debug) {
166
+ this.log(`job has been registered with interval: ${interval}`, key);
167
+ }
168
+ // `finally`, not a trailing statement, for the same reason as `runDueJobs`:
169
+ // a job that is registered but never scheduled is defect #36's terminal
170
+ // state reached through the other entry point. `invokeJob` is total, so
171
+ // this guard should be unreachable — which is exactly why it is a guard and
172
+ // not an assumption.
173
+ try {
174
+ if (runOnInit)
175
+ this.invokeJob(job, true);
176
+ }
177
+ finally {
178
+ this.scheduleNextRun();
179
+ }
180
+ }
181
+ unregister(key) {
182
+ const { heap, jobs } = this;
183
+ const job = jobs[key];
184
+ if (!job)
185
+ return;
186
+ delete jobs[key];
187
+ heap.remove(job);
188
+ if (config.debug)
189
+ this.log('job has been unregistered', key);
190
+ this.scheduleNextRun();
191
+ }
192
+ /**
193
+ * The one place this class invokes a consumer callback.
194
+ *
195
+ * Never blocks the caller, catches synchronous throws and asynchronous
196
+ * rejections identically, and skips the invocation entirely while the job's
197
+ * previous invocation has not settled (fire-and-forget would otherwise let a
198
+ * slow job stack invocations on itself).
199
+ *
200
+ * Everything that touches the callback — including the thenable probe and the
201
+ * handler attachment — is inside the `try`. A callback may return an object
202
+ * whose `then` is a throwing getter, and reading it outside the guard would
203
+ * abort the drain loop before `scheduleNextRun()`, which is defect #36 again.
204
+ */
205
+ invokeJob(job, runOnInit = false) {
206
+ const { key } = job;
207
+ const context = runOnInit ? 'failed on init:' : 'failed:';
208
+ // The in-flight guard lives on the job object, not in a module-level set
209
+ // keyed by string. Object identity is invocation identity: the only thing
210
+ // that clears the guard is the settle handler of the invocation that set it,
211
+ // and that handler closes over this exact job object, so a stale handler can
212
+ // never release a later invocation's guard.
213
+ if (job.runningAtMs !== undefined) {
214
+ // Bounded to one line per stuck run, not one per tick. A permanently hung
215
+ // job is re-pushed and re-skipped every interval forever; at the 1s
216
+ // interval this class's own tests use that measures 43,200 lines/day per
217
+ // job — a disk-fill and ingest-cost vector whose natural operator response
218
+ // is to silence the only signal that the job is dead.
219
+ if (!job.skipReported) {
220
+ job.skipReported = true;
221
+ const runningForSeconds = Math.max(0, Math.round((Date.now() - job.runningAtMs) / 1000));
222
+ // Ungated, deliberately, matching the sibling `CronService` handler. A
223
+ // skipped run is a *lost* execution, and `runDueJobs`/`register` both
224
+ // return `void`, so this is the legacy class's only wedged-job channel.
225
+ // Routing it through `this.log` would put it behind `config.cron.log`,
226
+ // where a permanently dead job is indistinguishable from a healthy one.
227
+ this.report('warn', `Cron job ${describeKey(key)} is still running after ${runningForSeconds}s; skipping this `
228
+ + 'tick and any further ticks until it settles (this warning is not repeated for this run)');
229
+ }
230
+ return;
231
+ }
232
+ job.runningAtMs = Date.now();
233
+ job.skipReported = false;
234
+ try {
235
+ const result = job.callback();
236
+ if (result && typeof result.then === 'function') {
237
+ Promise.resolve(result)
238
+ .catch((err) => {
239
+ // Braces matter: returning `report`'s value would put it back into
240
+ // the chain, and `.finally` passes a rejection straight through.
241
+ this.report('error', `Cron job ${describeKey(key)} ${context} ${describeError(err)}`);
242
+ })
243
+ .finally(() => { this.release(job); })
244
+ // Backstop: a throw inside the error handler or the release must not
245
+ // re-create the unhandled rejection this helper exists to prevent.
246
+ .catch(() => { });
247
+ return;
248
+ }
249
+ this.release(job);
250
+ }
251
+ catch (err) {
252
+ this.release(job);
253
+ this.report('error', `Cron job ${describeKey(key)} ${context} ${describeError(err)}`);
254
+ }
255
+ }
256
+ /**
257
+ * Report a scheduler-level message without ever letting the logger's own
258
+ * failure reach the caller.
259
+ *
260
+ * `@stonyx/logs` convenience methods return a promise and write to disk
261
+ * through an unguarded `mkdirSync` + `fsp.appendFile`. On a read-only or full
262
+ * log volume that promise rejects; an unobserved rejection raised from inside
263
+ * the handler that exists to prevent unhandled rejections would re-create
264
+ * exactly the defect this class was fixed for.
265
+ */
266
+ report(level, message) {
267
+ try {
268
+ const result = level === 'error' ? log.error(message) : log.warn(message);
269
+ void Promise.resolve(result).catch(() => { });
270
+ }
271
+ catch {
272
+ // Nowhere left to report to; the logger must never stop the scheduler.
273
+ }
274
+ }
275
+ /** Release a job's in-flight guard. Only ever called for the job it belongs to. */
276
+ release(job) {
277
+ job.runningAtMs = undefined;
278
+ job.skipReported = false;
279
+ }
280
+ setNextTrigger(job) {
281
+ job.nextTrigger = getTimestamp() + parseInt(job.interval, 10);
282
+ }
283
+ log(text, key = null) {
284
+ if (!config.cron?.log)
285
+ return;
286
+ // The key is consumer-controlled and reaches the log verbatim. Strip the
287
+ // line terminators so a key cannot forge a second, well-formed log line;
288
+ // the surrounding format is unchanged.
289
+ const tag = key ? `Cron::${key.replace(/[\r\n]+/g, ' ')}` : `Cron`;
290
+ log.cron(`${tag} - ${text}:`);
291
+ }
292
+ }
@@ -0,0 +1,13 @@
1
+ export interface HeapItem {
2
+ nextTrigger: number;
3
+ }
4
+ export default class MinHeap<T extends HeapItem> {
5
+ items: T[];
6
+ push(job: T): void;
7
+ pop(): T | undefined;
8
+ peek(): T | undefined;
9
+ bubbleUp(): void;
10
+ bubbleDown(): void;
11
+ remove(job: T): void;
12
+ isEmpty(): boolean;
13
+ }
@@ -0,0 +1,67 @@
1
+ export default class MinHeap {
2
+ items = [];
3
+ push(job) {
4
+ this.items.push(job);
5
+ this.bubbleUp();
6
+ }
7
+ pop() {
8
+ if (this.items.length <= 1)
9
+ return this.items.pop();
10
+ const top = this.items[0];
11
+ const last = this.items.pop();
12
+ if (last === undefined)
13
+ return top;
14
+ this.items[0] = last;
15
+ this.bubbleDown();
16
+ return top;
17
+ }
18
+ peek() {
19
+ return this.items[0];
20
+ }
21
+ bubbleUp() {
22
+ let idx = this.items.length - 1;
23
+ while (idx > 0) {
24
+ const parentIdx = Math.floor((idx - 1) / 2);
25
+ if (this.items[idx].nextTrigger >= this.items[parentIdx].nextTrigger)
26
+ break;
27
+ [this.items[idx], this.items[parentIdx]] = [this.items[parentIdx], this.items[idx]];
28
+ idx = parentIdx;
29
+ }
30
+ }
31
+ bubbleDown() {
32
+ let idx = 0;
33
+ const length = this.items.length;
34
+ while (true) {
35
+ const leftIdx = 2 * idx + 1;
36
+ const rightIdx = 2 * idx + 2;
37
+ let swapIdx = null;
38
+ if (leftIdx < length && this.items[leftIdx].nextTrigger < this.items[idx].nextTrigger) {
39
+ swapIdx = leftIdx;
40
+ }
41
+ if (rightIdx < length &&
42
+ this.items[rightIdx].nextTrigger < (swapIdx === null ? this.items[idx].nextTrigger : this.items[leftIdx].nextTrigger)) {
43
+ swapIdx = rightIdx;
44
+ }
45
+ if (swapIdx === null)
46
+ break;
47
+ [this.items[idx], this.items[swapIdx]] = [this.items[swapIdx], this.items[idx]];
48
+ idx = swapIdx;
49
+ }
50
+ }
51
+ remove(job) {
52
+ const idx = this.items.indexOf(job);
53
+ if (idx === -1)
54
+ return;
55
+ const end = this.items.pop();
56
+ if (end === undefined)
57
+ return;
58
+ if (idx < this.items.length) {
59
+ this.items[idx] = end;
60
+ this.bubbleUp();
61
+ this.bubbleDown();
62
+ }
63
+ }
64
+ isEmpty() {
65
+ return this.items.length === 0;
66
+ }
67
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Input normalization for AI-generated job definitions.
3
+ * Handles imperfect JSON from AI models: wrong casing, missing fields,
4
+ * flat-param recovery, type coercion.
5
+ */
6
+ interface RawSchedule {
7
+ kind?: string;
8
+ at?: string | number;
9
+ atMs?: number;
10
+ everyMs?: string | number;
11
+ expr?: string;
12
+ tz?: string;
13
+ }
14
+ interface RawPayload {
15
+ kind?: string;
16
+ message?: string;
17
+ text?: string;
18
+ [key: string]: unknown;
19
+ }
20
+ interface RawJobInput {
21
+ name?: string;
22
+ description?: string;
23
+ schedule?: RawSchedule;
24
+ payload?: RawPayload;
25
+ delivery?: Record<string, unknown>;
26
+ sessionTarget?: string;
27
+ wakeMode?: string;
28
+ enabled?: boolean;
29
+ deleteAfterRun?: boolean;
30
+ job?: RawJobInput;
31
+ message?: string;
32
+ text?: string;
33
+ [key: string]: unknown;
34
+ }
35
+ /**
36
+ * Normalize a schedule object. Infers kind from fields if missing.
37
+ */
38
+ export declare function normalizeSchedule(raw: unknown): RawSchedule;
39
+ /**
40
+ * Normalize a payload object. Infers kind from fields if missing.
41
+ */
42
+ export declare function normalizePayload(raw: unknown): RawPayload;
43
+ export declare function recoverFlatParams(params: RawJobInput): RawJobInput;
44
+ /**
45
+ * Normalize a complete job input for creation.
46
+ * Applies all normalization: schedule, payload, defaults.
47
+ */
48
+ export declare function normalizeJobInput(raw: RawJobInput): RawJobInput;
49
+ export {};
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Input normalization for AI-generated job definitions.
3
+ * Handles imperfect JSON from AI models: wrong casing, missing fields,
4
+ * flat-param recovery, type coercion.
5
+ */
6
+ /**
7
+ * Normalize a schedule object. Infers kind from fields if missing.
8
+ */
9
+ export function normalizeSchedule(raw) {
10
+ if (!raw || typeof raw !== 'object')
11
+ return raw;
12
+ const schedule = { ...raw };
13
+ // Infer kind from fields if missing
14
+ if (!schedule.kind) {
15
+ if (schedule.at || schedule.atMs)
16
+ schedule.kind = 'at';
17
+ else if (schedule.everyMs)
18
+ schedule.kind = 'every';
19
+ else if (schedule.expr)
20
+ schedule.kind = 'cron';
21
+ }
22
+ // Case normalization
23
+ if (typeof schedule.kind === 'string') {
24
+ schedule.kind = schedule.kind.toLowerCase();
25
+ }
26
+ // Legacy: atMs (number) -> at (ISO string)
27
+ if (schedule.atMs && !schedule.at) {
28
+ schedule.at = new Date(schedule.atMs).toISOString();
29
+ delete schedule.atMs;
30
+ }
31
+ // Coerce string everyMs to number
32
+ if (typeof schedule.everyMs === 'string') {
33
+ schedule.everyMs = Number(schedule.everyMs);
34
+ }
35
+ return schedule;
36
+ }
37
+ /**
38
+ * Normalize a payload object. Infers kind from fields if missing.
39
+ */
40
+ export function normalizePayload(raw) {
41
+ if (!raw || typeof raw !== 'object')
42
+ return raw;
43
+ const payload = { ...raw };
44
+ // Infer kind from fields
45
+ if (!payload.kind) {
46
+ if (payload.message)
47
+ payload.kind = 'agentTurn';
48
+ else if (payload.text)
49
+ payload.kind = 'systemEvent';
50
+ }
51
+ // Case normalization
52
+ if (typeof payload.kind === 'string') {
53
+ const lower = payload.kind.toLowerCase();
54
+ if (lower === 'agentturn')
55
+ payload.kind = 'agentTurn';
56
+ else if (lower === 'systemevent')
57
+ payload.kind = 'systemEvent';
58
+ }
59
+ return payload;
60
+ }
61
+ /**
62
+ * Recover a job object from flat parameters.
63
+ * AI models sometimes flatten nested fields to the top level.
64
+ */
65
+ const JOB_KEYS = new Set([
66
+ 'name', 'description', 'schedule', 'sessionTarget', 'payload',
67
+ 'delivery', 'enabled', 'deleteAfterRun', 'wakeMode',
68
+ ]);
69
+ export function recoverFlatParams(params) {
70
+ if (params.job && typeof params.job === 'object' && Object.keys(params.job).length > 0) {
71
+ return params.job;
72
+ }
73
+ const synthetic = {};
74
+ for (const key of Object.keys(params)) {
75
+ if (JOB_KEYS.has(key)) {
76
+ synthetic[key] = params[key];
77
+ }
78
+ }
79
+ // message/text are not JOB_KEYS but need to be recovered for payload wrapping
80
+ const message = params.message;
81
+ const text = params.text;
82
+ if (synthetic.schedule || synthetic.payload || message || text) {
83
+ // If message/text are at top level, wrap into payload
84
+ if (!synthetic.payload) {
85
+ if (message) {
86
+ synthetic.payload = { kind: 'agentTurn', message };
87
+ }
88
+ else if (text) {
89
+ synthetic.payload = { kind: 'systemEvent', text };
90
+ }
91
+ }
92
+ return synthetic;
93
+ }
94
+ return params;
95
+ }
96
+ /**
97
+ * Normalize a complete job input for creation.
98
+ * Applies all normalization: schedule, payload, defaults.
99
+ */
100
+ export function normalizeJobInput(raw) {
101
+ const job = { ...raw };
102
+ if (job.schedule) {
103
+ job.schedule = normalizeSchedule(job.schedule);
104
+ }
105
+ if (job.payload) {
106
+ job.payload = normalizePayload(job.payload);
107
+ }
108
+ // Default: enabled
109
+ if (job.enabled === undefined)
110
+ job.enabled = true;
111
+ // Default: wakeMode
112
+ if (!job.wakeMode)
113
+ job.wakeMode = 'now';
114
+ // Default: sessionTarget inferred from payload
115
+ if (!job.sessionTarget && job.payload) {
116
+ job.sessionTarget = job.payload.kind === 'systemEvent' ? 'main' : 'isolated';
117
+ }
118
+ // Default: deleteAfterRun for one-shot
119
+ if (job.deleteAfterRun === undefined && job.schedule?.kind === 'at') {
120
+ job.deleteAfterRun = true;
121
+ }
122
+ // Default: delivery for isolated agentTurn
123
+ if (!job.delivery && job.sessionTarget === 'isolated' && job.payload?.kind === 'agentTurn') {
124
+ job.delivery = { mode: 'announce' };
125
+ }
126
+ // Auto-generate name if missing
127
+ if (!job.name) {
128
+ job.name = inferName(job);
129
+ }
130
+ return job;
131
+ }
132
+ /**
133
+ * Infer a job name from schedule and payload.
134
+ */
135
+ function inferName(job) {
136
+ const parts = [];
137
+ if (job.schedule?.kind === 'at')
138
+ parts.push('One-shot');
139
+ else if (job.schedule?.kind === 'every')
140
+ parts.push('Recurring');
141
+ else if (job.schedule?.kind === 'cron')
142
+ parts.push('Scheduled');
143
+ if (job.payload?.kind === 'agentTurn')
144
+ parts.push('agent task');
145
+ else if (job.payload?.kind === 'systemEvent')
146
+ parts.push('system event');
147
+ return parts.join(' ') || 'Unnamed job';
148
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * In-memory run log for job execution history.
3
+ * Stores recent execution results per job with auto-pruning.
4
+ */
5
+ export interface RunLogEntry {
6
+ ts: number;
7
+ jobId: string;
8
+ status: string;
9
+ error?: string;
10
+ summary?: string;
11
+ runAtMs?: number;
12
+ durationMs?: number;
13
+ nextRunAtMs?: number;
14
+ }
15
+ export interface RunLogInput {
16
+ jobId: string;
17
+ status: string;
18
+ error?: string;
19
+ summary?: string;
20
+ runAtMs?: number;
21
+ durationMs?: number;
22
+ nextRunAtMs?: number;
23
+ }
24
+ export default class RunLog {
25
+ maxEntries: number;
26
+ entries: Map<string, RunLogEntry[]>;
27
+ constructor(maxEntriesPerJob?: number);
28
+ /**
29
+ * Record a job execution result.
30
+ */
31
+ record(entry: RunLogInput): void;
32
+ /**
33
+ * Get run history for a job.
34
+ */
35
+ get(jobId: string, limit?: number): RunLogEntry[];
36
+ /**
37
+ * Remove all entries for a job.
38
+ */
39
+ removeJob(jobId: string): void;
40
+ /**
41
+ * Clear all entries.
42
+ */
43
+ clear(): void;
44
+ }