@justfortytwo/scheduler 0.1.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Enrico Deleo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,92 @@
1
+ # @justfortytwo/scheduler
2
+
3
+ Durable job scheduler daemon for justfortytwo. Drains a `jobs` queue stored in `@justfortytwo/memory` and fires LLM turns only behind a cheap "is there work?" gate.
4
+
5
+ ## Overview
6
+
7
+ - **Handler registry** — maps job kinds to handlers
8
+ - **`tick()`** — pure, fully injectable function: claim due jobs, gate check, enqueue run, notify, reschedule/complete, backoff on error
9
+ - **Daemon** (Phase 3) — croner + p-queue wiring around `tick()`
10
+
11
+ ## Usage
12
+
13
+ ```ts
14
+ import { createRegistry, tick } from '@justfortytwo/scheduler';
15
+
16
+ const registry = createRegistry([myHandler]);
17
+
18
+ await tick({
19
+ claimDue,
20
+ complete,
21
+ fail,
22
+ registry,
23
+ recurrenceNext,
24
+ enqueueRun,
25
+ notify,
26
+ }, new Date().toISOString());
27
+ ```
28
+
29
+ ## Run
30
+
31
+ The scheduler daemon is a long-running process — run it under a restart loop.
32
+
33
+ ### tmux (development / quick start)
34
+
35
+ ```sh
36
+ while true; do
37
+ DB_PATH=/path/to/fortytwo.db \
38
+ EMBED_MODEL=qwen3-embedding:0.6b \
39
+ OLLAMA_BASE_URL=http://localhost:11434 \
40
+ CLAUDE_BIN=$(which claude) \
41
+ FORTYTWO_TURN_TIMEOUT=300 \
42
+ npx fortytwo-scheduler
43
+ echo "scheduler exited ($?); restarting in 3 s …"
44
+ sleep 3
45
+ done
46
+ ```
47
+
48
+ ### systemd (production)
49
+
50
+ Create `/etc/systemd/system/fortytwo-scheduler.service`:
51
+
52
+ ```ini
53
+ [Unit]
54
+ Description=fortytwo scheduler daemon
55
+ After=network.target
56
+
57
+ [Service]
58
+ Type=simple
59
+ Restart=always
60
+ RestartSec=3s
61
+ Environment=DB_PATH=/path/to/fortytwo/db/fortytwo.db
62
+ Environment=EMBED_MODEL=qwen3-embedding:0.6b
63
+ Environment=OLLAMA_BASE_URL=http://localhost:11434
64
+ Environment=CLAUDE_BIN=/usr/local/bin/claude
65
+ Environment=FORTYTWO_TURN_TIMEOUT=300
66
+ ExecStart=npx fortytwo-scheduler
67
+
68
+ [Install]
69
+ WantedBy=multi-user.target
70
+ ```
71
+
72
+ Then: `systemctl enable --now fortytwo-scheduler`
73
+
74
+ ### Environment variables
75
+
76
+ | Variable | Required | Description |
77
+ |----------|----------|-------------|
78
+ | `DB_PATH` | yes | Absolute path to `fortytwo.db` |
79
+ | `EMBED_MODEL` | recommended | Ollama model name for semantic embeddings (falls back to FakeEmbedder) |
80
+ | `OLLAMA_BASE_URL` | no | Ollama API base URL (default: `http://localhost:11434`) |
81
+ | `CLAUDE_BIN` | no | Path to the `claude` CLI binary the runner spawns (default: `claude` on `PATH`) |
82
+ | `FORTYTWO_TURN_TIMEOUT` | no | Per-turn timeout in seconds for a spawned `claude` run (default: `300`) |
83
+ | `TELEGRAM_BOT_TOKEN` | optional | Telegram bot token — enables push notifications to Telegram |
84
+ | `ALLOWED_CHAT_IDS` | optional | Comma-separated Telegram chat IDs to push notifications to |
85
+
86
+ ### Liveness / `fortytwo doctor`
87
+
88
+ On boot and on every 30-second poll tick — even while a previous tick is still in flight — the daemon writes a heartbeat file at `$(dirname $DB_PATH)/scheduler.heartbeat` containing `{ "pid": <number>, "ts": "<ISO>" }`. The write is outside the poll's overlap guard so a long-running turn never starves it. `fortytwo doctor` reads this file and warns (non-fatal) if it is missing or older than 90 seconds.
89
+
90
+ ## License
91
+
92
+ MIT © 2026 Enrico Deleo
@@ -0,0 +1,65 @@
1
+ import type { Notifier, Registry } from './types.js';
2
+ /** Poll interval: how often `tick` is invoked to drain due jobs. */
3
+ export declare const POLL_MS = 30000;
4
+ /**
5
+ * Stale-running threshold: jobs stuck in 'running' for longer than this are
6
+ * reset to 'pending' on boot (crash recovery).
7
+ */
8
+ export declare const STALE_RUNNING_MS: number;
9
+ /**
10
+ * Returns the next ISO run time for a cron expression, given an ISO `now`.
11
+ *
12
+ * Called by `tick` OUTSIDE its per-job try/catch for recurrence calculation.
13
+ * Built-in crons are validated at seed time (Phase 4), so a malformed
14
+ * expression here is a programming error that surfaces correctly at seed time.
15
+ * If `new Cron` throws on a malformed expression, that is acceptable — it is
16
+ * the seed step's responsibility to validate cron strings before persisting them.
17
+ */
18
+ export declare function recurrenceNext(cron: string, now: string): string;
19
+ /**
20
+ * Returns a `{ poll, isRunning }` pair. `poll()` calls `tickFn()` at most once
21
+ * at a time — a second concurrent call returns immediately without running
22
+ * `tickFn` again. This prevents tick() pileup if a slow job causes a poll
23
+ * interval to fire before the previous one completes.
24
+ */
25
+ export declare function makePoll(tickFn: () => Promise<void>): {
26
+ poll: () => Promise<void>;
27
+ isRunning: () => boolean;
28
+ };
29
+ export interface DaemonOptions {
30
+ /** Absolute path to the SQLite database file. */
31
+ dbPath: string;
32
+ /**
33
+ * Handler registry. Phase 4 supplies the real registry with job handlers.
34
+ * Defaults to an empty registry so Phase 3 builds + runs standalone.
35
+ */
36
+ registry?: Registry;
37
+ /**
38
+ * Additional notifiers beyond the journal (e.g. Telegram). Phase 5 wires
39
+ * these in. Defaults to [] so only the journal notifier is active.
40
+ */
41
+ extraNotifiers?: Notifier[];
42
+ }
43
+ /**
44
+ * Boot the scheduler daemon.
45
+ *
46
+ * 1. Opens the SQLite DB and runs pending migrations.
47
+ * 2. Requeues any stale 'running' jobs (crash recovery).
48
+ * 3. Builds the TickDeps: claimDue / complete / fail bound to the DB handle,
49
+ * recurrenceNext (croner), enqueueRun (PQueue concurrency-1),
50
+ * notify (fanout: journal + optional extras), and the injected registry.
51
+ * 4. Starts a 30-second setInterval poll loop with an overlap guard.
52
+ *
53
+ * Journal write: each notification is stored via memory's generic `store` fn
54
+ * with source='scheduler:wake'. The embedder is OllamaEmbedder when
55
+ * EMBED_MODEL is set, else FakeEmbedder (same pattern as telegram/bridge.ts).
56
+ *
57
+ * CONCERN: using FakeEmbedder in production means scheduler-emitted journal
58
+ * entries are not semantically searchable (fake vectors). This matches the
59
+ * bridge's current pattern for channel events and is acceptable for Phase 3.
60
+ * It should be revisited when a persistent embedder is always available (e.g.
61
+ * Phase 4+ where EMBED_MODEL is part of the required env).
62
+ *
63
+ * @returns A dispose fn that clears the poll interval.
64
+ */
65
+ export declare function startDaemon(opts: DaemonOptions): Promise<() => void>;
package/dist/daemon.js ADDED
@@ -0,0 +1,156 @@
1
+ import { Cron } from 'croner';
2
+ import PQueue from 'p-queue';
3
+ import { openDb, runMigrations, claimDue, complete, fail, requeueStale, FakeEmbedder, OllamaEmbedder, store, query, reembed, listActive, enqueue, setRecurrence, countPendingApprovals, } from '@justfortytwo/memory';
4
+ import { createRunner } from '@justfortytwo/runner';
5
+ import { tick } from './tick.js';
6
+ import { heartbeatPath, writeHeartbeat } from './heartbeat.js';
7
+ import { createJournalNotifier, createFanoutNotifier } from './notifier.js';
8
+ import { buildRegistry, RECURRING_DEFS } from './handlers/index.js';
9
+ import { seedRecurring } from './seed.js';
10
+ /** Poll interval: how often `tick` is invoked to drain due jobs. */
11
+ export const POLL_MS = 30_000;
12
+ /**
13
+ * Stale-running threshold: jobs stuck in 'running' for longer than this are
14
+ * reset to 'pending' on boot (crash recovery).
15
+ */
16
+ export const STALE_RUNNING_MS = 15 * 60_000; // 15 minutes
17
+ // ---------------------------------------------------------------------------
18
+ // recurrenceNext
19
+ // ---------------------------------------------------------------------------
20
+ /**
21
+ * Returns the next ISO run time for a cron expression, given an ISO `now`.
22
+ *
23
+ * Called by `tick` OUTSIDE its per-job try/catch for recurrence calculation.
24
+ * Built-in crons are validated at seed time (Phase 4), so a malformed
25
+ * expression here is a programming error that surfaces correctly at seed time.
26
+ * If `new Cron` throws on a malformed expression, that is acceptable — it is
27
+ * the seed step's responsibility to validate cron strings before persisting them.
28
+ */
29
+ export function recurrenceNext(cron, now) {
30
+ // timezone: 'UTC' ensures cron expressions are always interpreted in UTC,
31
+ // matching the ISO-string run_at values stored in the jobs table.
32
+ const next = new Cron(cron, { timezone: 'UTC' }).nextRun(new Date(now));
33
+ if (!next)
34
+ throw new Error(`recurrenceNext: no next run for cron '${cron}' after ${now}`);
35
+ return next.toISOString();
36
+ }
37
+ // ---------------------------------------------------------------------------
38
+ // makePoll — overlap-guarded poll factory (exported for tests)
39
+ // ---------------------------------------------------------------------------
40
+ /**
41
+ * Returns a `{ poll, isRunning }` pair. `poll()` calls `tickFn()` at most once
42
+ * at a time — a second concurrent call returns immediately without running
43
+ * `tickFn` again. This prevents tick() pileup if a slow job causes a poll
44
+ * interval to fire before the previous one completes.
45
+ */
46
+ export function makePoll(tickFn) {
47
+ let running = false;
48
+ const poll = async () => {
49
+ if (running)
50
+ return;
51
+ running = true;
52
+ try {
53
+ await tickFn();
54
+ }
55
+ finally {
56
+ running = false;
57
+ }
58
+ };
59
+ return { poll, isRunning: () => running };
60
+ }
61
+ // ---------------------------------------------------------------------------
62
+ // startDaemon
63
+ // ---------------------------------------------------------------------------
64
+ /**
65
+ * Boot the scheduler daemon.
66
+ *
67
+ * 1. Opens the SQLite DB and runs pending migrations.
68
+ * 2. Requeues any stale 'running' jobs (crash recovery).
69
+ * 3. Builds the TickDeps: claimDue / complete / fail bound to the DB handle,
70
+ * recurrenceNext (croner), enqueueRun (PQueue concurrency-1),
71
+ * notify (fanout: journal + optional extras), and the injected registry.
72
+ * 4. Starts a 30-second setInterval poll loop with an overlap guard.
73
+ *
74
+ * Journal write: each notification is stored via memory's generic `store` fn
75
+ * with source='scheduler:wake'. The embedder is OllamaEmbedder when
76
+ * EMBED_MODEL is set, else FakeEmbedder (same pattern as telegram/bridge.ts).
77
+ *
78
+ * CONCERN: using FakeEmbedder in production means scheduler-emitted journal
79
+ * entries are not semantically searchable (fake vectors). This matches the
80
+ * bridge's current pattern for channel events and is acceptable for Phase 3.
81
+ * It should be revisited when a persistent embedder is always available (e.g.
82
+ * Phase 4+ where EMBED_MODEL is part of the required env).
83
+ *
84
+ * @returns A dispose fn that clears the poll interval.
85
+ */
86
+ export async function startDaemon(opts) {
87
+ const h = openDb(opts.dbPath);
88
+ await runMigrations(h.k);
89
+ // Crash recovery: reset jobs stuck in 'running' for > 15 minutes.
90
+ const staleThreshold = new Date(Date.now() - STALE_RUNNING_MS).toISOString();
91
+ requeueStale(h, staleThreshold);
92
+ // Embedder: real Ollama when EMBED_MODEL is present, deterministic fake otherwise.
93
+ const embedder = process.env['EMBED_MODEL']
94
+ ? new OllamaEmbedder(process.env['EMBED_MODEL'], process.env['OLLAMA_BASE_URL'])
95
+ : new FakeEmbedder();
96
+ // Journal notifier: persists each Notification as a memory entry.
97
+ const journalWrite = async (n) => {
98
+ await store(h, embedder, {
99
+ content: `[scheduler:${n.kind}] ${n.text}`,
100
+ source: 'scheduler:wake',
101
+ observed: 'internal',
102
+ tags: ['scheduler', n.kind],
103
+ });
104
+ };
105
+ const journalNotifier = createJournalNotifier(journalWrite);
106
+ const allNotifiers = [journalNotifier, ...(opts.extraNotifiers ?? [])];
107
+ const fanout = createFanoutNotifier(allNotifiers);
108
+ // Build the real handler registry (or accept the injected one for tests).
109
+ const registry = opts.registry ?? (() => {
110
+ const runner = createRunner();
111
+ const reembedOne = (id) => reembed(h, embedder, id);
112
+ return buildRegistry({
113
+ runner,
114
+ countPendingApprovals: () => countPendingApprovals(h),
115
+ // Compute the 7-day lookback per call so a long-running daemon's window
116
+ // does not drift forward from the boot-time instant.
117
+ recentCount: async () => (await query(h, { since: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString() })).length,
118
+ reembedOne,
119
+ });
120
+ })();
121
+ // Seed recurring job rows from the in-code definitions (idempotent on every boot).
122
+ seedRecurring({
123
+ listActive: () => listActive(h),
124
+ enqueue: (j) => enqueue(h, j),
125
+ setRecurrence: (id, rec, at) => setRecurrence(h, id, rec, at),
126
+ }, RECURRING_DEFS, new Date().toISOString(), recurrenceNext);
127
+ // Write liveness heartbeat on boot.
128
+ const hbPath = heartbeatPath(opts.dbPath);
129
+ writeHeartbeat(hbPath, process.pid, new Date().toISOString());
130
+ // Concurrency-1 PQueue: at most one handler run at a time.
131
+ const queue = new PQueue({ concurrency: 1 });
132
+ const deps = {
133
+ claimDue: (now) => claimDue(h, now),
134
+ complete: (id, rescheduleOpts) => complete(h, id, rescheduleOpts),
135
+ fail: (id, error, retryOpts) => fail(h, id, error, retryOpts),
136
+ recurrenceNext,
137
+ enqueueRun: (fn) => queue.add(fn),
138
+ notify: (notification) => fanout.notify(notification),
139
+ registry,
140
+ };
141
+ const { poll } = makePoll(async () => {
142
+ await tick(deps, new Date().toISOString());
143
+ });
144
+ // Refresh the heartbeat on EVERY poll tick — outside the overlap guard — so a
145
+ // long-running `tick` (e.g. a multi-minute `claude` turn) does not starve the
146
+ // heartbeat. If the write were inside `tickFn`, a tick exceeding the poll
147
+ // interval would make every subsequent poll hit `if (running) return` and skip
148
+ // the refresh, and `fortytwo doctor` would falsely report the daemon stale.
149
+ const interval = setInterval(() => {
150
+ writeHeartbeat(hbPath, process.pid, new Date().toISOString());
151
+ void poll();
152
+ }, POLL_MS);
153
+ // Return dispose function so callers (tests, signal handlers) can clean up.
154
+ return () => clearInterval(interval);
155
+ }
156
+ //# sourceMappingURL=daemon.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"daemon.js","sourceRoot":"","sources":["../src/daemon.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,33 @@
1
+ import type { Registry } from '../types.js';
2
+ import type { RunnerFn } from './recurring.js';
3
+ import type { ReembedOneFn } from './reembed.js';
4
+ export interface RecurringDef {
5
+ kind: string;
6
+ /** UTC cron expression (croner format). */
7
+ cron: string;
8
+ /** Prompt sent to the runner when the job fires and hasWork is true. */
9
+ trigger: string;
10
+ }
11
+ /**
12
+ * Canonical set of recurring jobs. Seeds are idempotent: the daemon calls
13
+ * `seedRecurring(store, RECURRING_DEFS, ...)` on every boot and only inserts
14
+ * or updates rows when the definition changes.
15
+ */
16
+ export declare const RECURRING_DEFS: RecurringDef[];
17
+ export interface RegistryDeps {
18
+ /** Bound `createRunner()` return value. */
19
+ runner: RunnerFn;
20
+ /** Returns the count of pending approvals (injected so tests can fake it). */
21
+ countPendingApprovals: () => number;
22
+ /**
23
+ * Returns the count of memories written in the last 7 days
24
+ * (injected so tests can fake it).
25
+ */
26
+ recentCount: () => Promise<number>;
27
+ /** Re-embeds one memory by id in-place (no LLM). */
28
+ reembedOne: ReembedOneFn;
29
+ }
30
+ export declare function buildRegistry(deps: RegistryDeps): Registry;
31
+ export { makeRecurringHandler } from './recurring.js';
32
+ export { makeNotifyPendingHandler, makeReplyHandler, makeReminderHandler } from './oneoff.js';
33
+ export { makeReembedHandler } from './reembed.js';
@@ -0,0 +1,61 @@
1
+ import { createRegistry } from '../registry.js';
2
+ import { makeRecurringHandler } from './recurring.js';
3
+ import { makeNotifyPendingHandler, makeReplyHandler, makeReminderHandler } from './oneoff.js';
4
+ import { makeReembedHandler } from './reembed.js';
5
+ /**
6
+ * Canonical set of recurring jobs. Seeds are idempotent: the daemon calls
7
+ * `seedRecurring(store, RECURRING_DEFS, ...)` on every boot and only inserts
8
+ * or updates rows when the definition changes.
9
+ */
10
+ export const RECURRING_DEFS = [
11
+ {
12
+ kind: 'daily_briefing',
13
+ cron: '57 7 * * *',
14
+ trigger: 'Run the daily-briefing skill and send me the rundown for today.',
15
+ },
16
+ {
17
+ kind: 'sweep',
18
+ cron: '3 13,18 * * *',
19
+ trigger: 'Open-thread sweep: check pending approvals and threads awaiting a reply; give me a one-line status.',
20
+ },
21
+ {
22
+ kind: 'learn_review',
23
+ cron: '17 9 * * 0',
24
+ trigger: 'Run the learn-review skill: scan the recent Journal for patterns worth promoting. Propose-only — nothing installed.',
25
+ },
26
+ ];
27
+ export function buildRegistry(deps) {
28
+ const { runner, countPendingApprovals, recentCount, reembedOne } = deps;
29
+ return createRegistry([
30
+ // --- Recurring: ungated ---
31
+ makeRecurringHandler({
32
+ kind: 'daily_briefing',
33
+ trigger: RECURRING_DEFS.find((d) => d.kind === 'daily_briefing').trigger,
34
+ runner,
35
+ }),
36
+ // --- Recurring: gated on pending approvals > 0 ---
37
+ makeRecurringHandler({
38
+ kind: 'sweep',
39
+ trigger: RECURRING_DEFS.find((d) => d.kind === 'sweep').trigger,
40
+ runner,
41
+ hasWork: async () => countPendingApprovals() > 0,
42
+ }),
43
+ // --- Recurring: gated on recent memories > 0 ---
44
+ makeRecurringHandler({
45
+ kind: 'learn_review',
46
+ trigger: RECURRING_DEFS.find((d) => d.kind === 'learn_review').trigger,
47
+ runner,
48
+ hasWork: async () => (await recentCount()) > 0,
49
+ }),
50
+ // --- One-off: no gate ---
51
+ makeNotifyPendingHandler(runner),
52
+ makeReplyHandler(runner),
53
+ makeReminderHandler(runner),
54
+ // --- Maintenance: no LLM, no notification ---
55
+ makeReembedHandler(reembedOne),
56
+ ]);
57
+ }
58
+ export { makeRecurringHandler } from './recurring.js';
59
+ export { makeNotifyPendingHandler, makeReplyHandler, makeReminderHandler } from './oneoff.js';
60
+ export { makeReembedHandler } from './reembed.js';
61
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/handlers/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAIhD,OAAO,EAAE,oBAAoB,EAAE,MAAM,gBAAgB,CAAC;AACtD,OAAO,EAAE,wBAAwB,EAAE,gBAAgB,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AAC9F,OAAO,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAclD;;;;GAIG;AACH,MAAM,CAAC,MAAM,cAAc,GAAmB;IAC5C;QACE,IAAI,EAAE,gBAAgB;QACtB,IAAI,EAAE,YAAY;QAClB,OAAO,EAAE,iEAAiE;KAC3E;IACD;QACE,IAAI,EAAE,OAAO;QACb,IAAI,EAAE,eAAe;QACrB,OAAO,EAAE,qGAAqG;KAC/G;IACD;QACE,IAAI,EAAE,cAAc;QACpB,IAAI,EAAE,YAAY;QAClB,OAAO,EAAE,qHAAqH;KAC/H;CACF,CAAC;AAoBF,MAAM,UAAU,aAAa,CAAC,IAAkB;IAC9C,MAAM,EAAE,MAAM,EAAE,qBAAqB,EAAE,WAAW,EAAE,UAAU,EAAE,GAAG,IAAI,CAAC;IAExE,OAAO,cAAc,CAAC;QACpB,6BAA6B;QAC7B,oBAAoB,CAAC;YACnB,IAAI,EAAE,gBAAgB;YACtB,OAAO,EAAE,cAAc,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,gBAAgB,CAAE,CAAC,OAAO;YACzE,MAAM;SACP,CAAC;QAEF,oDAAoD;QACpD,oBAAoB,CAAC;YACnB,IAAI,EAAE,OAAO;YACb,OAAO,EAAE,cAAc,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,OAAO,CAAE,CAAC,OAAO;YAChE,MAAM;YACN,OAAO,EAAE,KAAK,IAAI,EAAE,CAAC,qBAAqB,EAAE,GAAG,CAAC;SACjD,CAAC;QAEF,kDAAkD;QAClD,oBAAoB,CAAC;YACnB,IAAI,EAAE,cAAc;YACpB,OAAO,EAAE,cAAc,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,cAAc,CAAE,CAAC,OAAO;YACvE,MAAM;YACN,OAAO,EAAE,KAAK,IAAI,EAAE,CAAC,CAAC,MAAM,WAAW,EAAE,CAAC,GAAG,CAAC;SAC/C,CAAC;QAEF,2BAA2B;QAC3B,wBAAwB,CAAC,MAAM,CAAC;QAChC,gBAAgB,CAAC,MAAM,CAAC;QACxB,mBAAmB,CAAC,MAAM,CAAC;QAE3B,+CAA+C;QAC/C,kBAAkB,CAAC,UAAU,CAAC;KAC/B,CAAC,CAAC;AACL,CAAC;AAED,OAAO,EAAE,oBAAoB,EAAE,MAAM,gBAAgB,CAAC;AACtD,OAAO,EAAE,wBAAwB,EAAE,gBAAgB,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AAC9F,OAAO,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC"}
@@ -0,0 +1,17 @@
1
+ import type { Handler } from '../types.js';
2
+ import type { RunnerFn } from './recurring.js';
3
+ /**
4
+ * `notify_pending` — fires when a tool-call approval is staged and awaiting
5
+ * the owner's decision. Summarises the pending approval(s) via LLM.
6
+ */
7
+ export declare function makeNotifyPendingHandler(runner: RunnerFn): Handler;
8
+ /**
9
+ * `reply` — fires when an inbound message needs a reply. Prompt is constructed
10
+ * from `ctx.payload.text`.
11
+ */
12
+ export declare function makeReplyHandler(runner: RunnerFn): Handler;
13
+ /**
14
+ * `reminder` — fires at a scheduled reminder time. Prompt is constructed from
15
+ * `ctx.payload.text`.
16
+ */
17
+ export declare function makeReminderHandler(runner: RunnerFn): Handler;
@@ -0,0 +1,50 @@
1
+ function textPayload(ctx) {
2
+ if (ctx.payload && typeof ctx.payload === 'object') {
3
+ return ctx.payload;
4
+ }
5
+ return {};
6
+ }
7
+ /**
8
+ * `notify_pending` — fires when a tool-call approval is staged and awaiting
9
+ * the owner's decision. Summarises the pending approval(s) via LLM.
10
+ */
11
+ export function makeNotifyPendingHandler(runner) {
12
+ return {
13
+ kind: 'notify_pending',
14
+ async run(ctx) {
15
+ const { id } = textPayload(ctx);
16
+ const idClause = id != null ? ` (approval id: ${id})` : '';
17
+ const r = await runner(`A tool call is awaiting your approval${idClause}. Summarize the pending approval(s) and ask me to approve or deny.`);
18
+ return { ok: r.ok, text: r.text };
19
+ },
20
+ };
21
+ }
22
+ /**
23
+ * `reply` — fires when an inbound message needs a reply. Prompt is constructed
24
+ * from `ctx.payload.text`.
25
+ */
26
+ export function makeReplyHandler(runner) {
27
+ return {
28
+ kind: 'reply',
29
+ async run(ctx) {
30
+ const { text = '' } = textPayload(ctx);
31
+ const r = await runner(`Handle this pending message: ${text}`);
32
+ return { ok: r.ok, text: r.text };
33
+ },
34
+ };
35
+ }
36
+ /**
37
+ * `reminder` — fires at a scheduled reminder time. Prompt is constructed from
38
+ * `ctx.payload.text`.
39
+ */
40
+ export function makeReminderHandler(runner) {
41
+ return {
42
+ kind: 'reminder',
43
+ async run(ctx) {
44
+ const { text = '' } = textPayload(ctx);
45
+ const r = await runner(`Reminder: ${text}`);
46
+ return { ok: r.ok, text: r.text };
47
+ },
48
+ };
49
+ }
50
+ //# sourceMappingURL=oneoff.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"oneoff.js","sourceRoot":"","sources":["../../src/handlers/oneoff.ts"],"names":[],"mappings":"AASA,SAAS,WAAW,CAAC,GAAW;IAC9B,IAAI,GAAG,CAAC,OAAO,IAAI,OAAO,GAAG,CAAC,OAAO,KAAK,QAAQ,EAAE,CAAC;QACnD,OAAO,GAAG,CAAC,OAAsB,CAAC;IACpC,CAAC;IACD,OAAO,EAAE,CAAC;AACZ,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,wBAAwB,CAAC,MAAgB;IACvD,OAAO;QACL,IAAI,EAAE,gBAAgB;QACtB,KAAK,CAAC,GAAG,CAAC,GAAW;YACnB,MAAM,EAAE,EAAE,EAAE,GAAG,WAAW,CAAC,GAAG,CAAC,CAAC;YAChC,MAAM,QAAQ,GAAG,EAAE,IAAI,IAAI,CAAC,CAAC,CAAC,kBAAkB,EAAE,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC;YAC3D,MAAM,CAAC,GAAG,MAAM,MAAM,CACpB,wCAAwC,QAAQ,oEAAoE,CACrH,CAAC;YACF,OAAO,EAAE,EAAE,EAAE,CAAC,CAAC,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;QACpC,CAAC;KACF,CAAC;AACJ,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,gBAAgB,CAAC,MAAgB;IAC/C,OAAO;QACL,IAAI,EAAE,OAAO;QACb,KAAK,CAAC,GAAG,CAAC,GAAW;YACnB,MAAM,EAAE,IAAI,GAAG,EAAE,EAAE,GAAG,WAAW,CAAC,GAAG,CAAC,CAAC;YACvC,MAAM,CAAC,GAAG,MAAM,MAAM,CAAC,gCAAgC,IAAI,EAAE,CAAC,CAAC;YAC/D,OAAO,EAAE,EAAE,EAAE,CAAC,CAAC,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;QACpC,CAAC;KACF,CAAC;AACJ,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,mBAAmB,CAAC,MAAgB;IAClD,OAAO;QACL,IAAI,EAAE,UAAU;QAChB,KAAK,CAAC,GAAG,CAAC,GAAW;YACnB,MAAM,EAAE,IAAI,GAAG,EAAE,EAAE,GAAG,WAAW,CAAC,GAAG,CAAC,CAAC;YACvC,MAAM,CAAC,GAAG,MAAM,MAAM,CAAC,aAAa,IAAI,EAAE,CAAC,CAAC;YAC5C,OAAO,EAAE,EAAE,EAAE,CAAC,CAAC,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;QACpC,CAAC;KACF,CAAC;AACJ,CAAC"}
@@ -0,0 +1,18 @@
1
+ import type { Handler } from '../types.js';
2
+ export interface RunnerFn {
3
+ (prompt: string): Promise<{
4
+ ok: boolean;
5
+ text: string;
6
+ }>;
7
+ }
8
+ /**
9
+ * Factory for recurring LLM-backed handlers. Optionally gated by a `hasWork`
10
+ * predicate — if the gate returns false the tick skips the run (job is
11
+ * rescheduled without consuming LLM quota).
12
+ */
13
+ export declare function makeRecurringHandler(opts: {
14
+ kind: string;
15
+ trigger: string;
16
+ runner: RunnerFn;
17
+ hasWork?: () => Promise<boolean>;
18
+ }): Handler;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Factory for recurring LLM-backed handlers. Optionally gated by a `hasWork`
3
+ * predicate — if the gate returns false the tick skips the run (job is
4
+ * rescheduled without consuming LLM quota).
5
+ */
6
+ export function makeRecurringHandler(opts) {
7
+ return {
8
+ kind: opts.kind,
9
+ async hasWork(_ctx) {
10
+ if (!opts.hasWork)
11
+ return true;
12
+ return opts.hasWork();
13
+ },
14
+ async run(_ctx) {
15
+ const r = await opts.runner(opts.trigger);
16
+ return { ok: r.ok, text: r.text };
17
+ },
18
+ };
19
+ }
20
+ //# sourceMappingURL=recurring.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"recurring.js","sourceRoot":"","sources":["../../src/handlers/recurring.ts"],"names":[],"mappings":"AAMA;;;;GAIG;AACH,MAAM,UAAU,oBAAoB,CAAC,IAKpC;IACC,OAAO;QACL,IAAI,EAAE,IAAI,CAAC,IAAI;QACf,KAAK,CAAC,OAAO,CAAC,IAAY;YACxB,IAAI,CAAC,IAAI,CAAC,OAAO;gBAAE,OAAO,IAAI,CAAC;YAC/B,OAAO,IAAI,CAAC,OAAO,EAAE,CAAC;QACxB,CAAC;QACD,KAAK,CAAC,GAAG,CAAC,IAAY;YACpB,MAAM,CAAC,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;YAC1C,OAAO,EAAE,EAAE,EAAE,CAAC,CAAC,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;QACpC,CAAC;KACF,CAAC;AACJ,CAAC"}
@@ -0,0 +1,10 @@
1
+ import type { Handler } from '../types.js';
2
+ export interface ReembedOneFn {
3
+ (id: number): Promise<boolean>;
4
+ }
5
+ /**
6
+ * `reembed_memory` — re-embeds a stored memory in-place after an
7
+ * embedder/model change. Enqueued by `memory.store()` on every write;
8
+ * consumed here with no LLM call (no notify — returns null).
9
+ */
10
+ export declare function makeReembedHandler(reembedOne: ReembedOneFn): Handler;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * `reembed_memory` — re-embeds a stored memory in-place after an
3
+ * embedder/model change. Enqueued by `memory.store()` on every write;
4
+ * consumed here with no LLM call (no notify — returns null).
5
+ */
6
+ export function makeReembedHandler(reembedOne) {
7
+ return {
8
+ kind: 'reembed_memory',
9
+ async run(ctx) {
10
+ const payload = ctx.payload;
11
+ const id = typeof payload?.id === 'number' ? payload.id : undefined;
12
+ if (id == null)
13
+ return null; // malformed payload — skip silently
14
+ await reembedOne(id);
15
+ return null; // no notification: pure background maintenance
16
+ },
17
+ };
18
+ }
19
+ //# sourceMappingURL=reembed.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reembed.js","sourceRoot":"","sources":["../../src/handlers/reembed.ts"],"names":[],"mappings":"AAMA;;;;GAIG;AACH,MAAM,UAAU,kBAAkB,CAAC,UAAwB;IACzD,OAAO;QACL,IAAI,EAAE,gBAAgB;QACtB,KAAK,CAAC,GAAG,CAAC,GAAW;YACnB,MAAM,OAAO,GAAG,GAAG,CAAC,OAA2B,CAAC;YAChD,MAAM,EAAE,GAAG,OAAO,OAAO,EAAE,EAAE,KAAK,QAAQ,CAAC,CAAC,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC;YACpE,IAAI,EAAE,IAAI,IAAI;gBAAE,OAAO,IAAI,CAAC,CAAC,oCAAoC;YACjE,MAAM,UAAU,CAAC,EAAE,CAAC,CAAC;YACrB,OAAO,IAAI,CAAC,CAAC,+CAA+C;QAC9D,CAAC;KACF,CAAC;AACJ,CAAC"}
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Returns the heartbeat file path for a given dbPath.
3
+ * Convention: sibling of the DB file, named `scheduler.heartbeat`.
4
+ * Both the daemon (writer) and `fortytwo doctor` (reader) derive the path
5
+ * from the same dbPath / DB_PATH env var using this function.
6
+ */
7
+ export declare function heartbeatPath(dbPath: string): string;
8
+ /**
9
+ * Write a liveness heartbeat JSON file `{ pid, ts }` at `path`.
10
+ * Best-effort: a write failure NEVER crashes the daemon — errors are swallowed.
11
+ */
12
+ export declare function writeHeartbeat(path: string, pid: number, nowIso: string): void;
@@ -0,0 +1,25 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ /**
4
+ * Returns the heartbeat file path for a given dbPath.
5
+ * Convention: sibling of the DB file, named `scheduler.heartbeat`.
6
+ * Both the daemon (writer) and `fortytwo doctor` (reader) derive the path
7
+ * from the same dbPath / DB_PATH env var using this function.
8
+ */
9
+ export function heartbeatPath(dbPath) {
10
+ return join(dirname(dbPath), 'scheduler.heartbeat');
11
+ }
12
+ /**
13
+ * Write a liveness heartbeat JSON file `{ pid, ts }` at `path`.
14
+ * Best-effort: a write failure NEVER crashes the daemon — errors are swallowed.
15
+ */
16
+ export function writeHeartbeat(path, pid, nowIso) {
17
+ try {
18
+ mkdirSync(dirname(path), { recursive: true });
19
+ writeFileSync(path, JSON.stringify({ pid, ts: nowIso }), 'utf-8');
20
+ }
21
+ catch {
22
+ // Best-effort — never crash the daemon on a heartbeat write failure.
23
+ }
24
+ }
25
+ //# sourceMappingURL=heartbeat.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"heartbeat.js","sourceRoot":"","sources":["../src/heartbeat.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AACnD,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAE1C;;;;;GAKG;AACH,MAAM,UAAU,aAAa,CAAC,MAAc;IAC1C,OAAO,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,qBAAqB,CAAC,CAAC;AACtD,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,cAAc,CAAC,IAAY,EAAE,GAAW,EAAE,MAAc;IACtE,IAAI,CAAC;QACH,SAAS,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;QAC9C,aAAa,CAAC,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,EAAE,GAAG,EAAE,EAAE,EAAE,MAAM,EAAE,CAAC,EAAE,OAAO,CAAC,CAAC;IACpE,CAAC;IAAC,MAAM,CAAC;QACP,qEAAqE;IACvE,CAAC;AACH,CAAC"}
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+ export type { Job, RunCtx, RunResult, Handler, Notification, Notifier, Registry, } from './types.js';
3
+ export { createRegistry } from './registry.js';
4
+ export { tick, backoffAt, RETRY_BACKOFF_MIN } from './tick.js';
5
+ export type { TickDeps } from './tick.js';
6
+ export { createJournalNotifier, createFanoutNotifier } from './notifier.js';
7
+ export { startDaemon, recurrenceNext, makePoll, POLL_MS, STALE_RUNNING_MS } from './daemon.js';
8
+ export type { DaemonOptions } from './daemon.js';
9
+ export { RECURRING_DEFS, buildRegistry } from './handlers/index.js';
10
+ export type { RecurringDef, RegistryDeps } from './handlers/index.js';
11
+ export { seedRecurring } from './seed.js';
12
+ export type { SeedStore } from './seed.js';
13
+ export { heartbeatPath, writeHeartbeat } from './heartbeat.js';
package/dist/index.js ADDED
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env node
2
+ import { realpathSync } from 'node:fs';
3
+ import { fileURLToPath } from 'node:url';
4
+ export { createRegistry } from './registry.js';
5
+ export { tick, backoffAt, RETRY_BACKOFF_MIN } from './tick.js';
6
+ // Phase 3 re-exports.
7
+ export { createJournalNotifier, createFanoutNotifier } from './notifier.js';
8
+ export { startDaemon, recurrenceNext, makePoll, POLL_MS, STALE_RUNNING_MS } from './daemon.js';
9
+ // Phase 4 re-exports.
10
+ export { RECURRING_DEFS, buildRegistry } from './handlers/index.js';
11
+ export { seedRecurring } from './seed.js';
12
+ // Phase 6 re-exports.
13
+ export { heartbeatPath, writeHeartbeat } from './heartbeat.js';
14
+ // ---------------------------------------------------------------------------
15
+ // Bin guard — boot the daemon ONLY when this file is the direct entrypoint.
16
+ // Mirrors telegram/src/bridge.ts `invokedAsBin()` exactly so importing this
17
+ // package as a library does not open a DB, run migrations, or start an interval.
18
+ // ---------------------------------------------------------------------------
19
+ function invokedAsBin() {
20
+ const argv1 = process.argv[1];
21
+ if (!argv1)
22
+ return false;
23
+ try {
24
+ return realpathSync(argv1) === realpathSync(fileURLToPath(import.meta.url));
25
+ }
26
+ catch {
27
+ return false;
28
+ }
29
+ }
30
+ if (invokedAsBin()) {
31
+ const { startDaemon: boot } = await import('./daemon.js');
32
+ const dbPath = process.env['DB_PATH'] ?? 'fortytwo.db';
33
+ // Optional Telegram push: when TELEGRAM_BOT_TOKEN + ALLOWED_CHAT_IDS are
34
+ // set, construct a Telegram sender and wire it as an extra notifier so the
35
+ // scheduler daemon can push notifications to Telegram in addition to the
36
+ // journal write.
37
+ //
38
+ // FLAG: ALLOWED_CHAT_IDS is expected to be a comma-separated list of chat
39
+ // ids (same format as the bridge); we use the first id as the target chat.
40
+ // If neither env var is set, extraNotifiers defaults to [] and only the
41
+ // journal notifier is active.
42
+ const extraNotifiers = await (async () => {
43
+ const token = process.env['TELEGRAM_BOT_TOKEN'];
44
+ const chatIdsRaw = process.env['ALLOWED_CHAT_IDS'];
45
+ if (!token || !chatIdsRaw)
46
+ return [];
47
+ try {
48
+ // Lazy import so scheduler can run without @justfortytwo/telegram installed.
49
+ const { Telegram, parseAllowed, telegramNotifier } = await import('@justfortytwo/telegram');
50
+ const chatIds = parseAllowed(chatIdsRaw);
51
+ if (chatIds.size === 0)
52
+ return [];
53
+ const chatId = [...chatIds][0];
54
+ const tg = new Telegram(token, chatIds);
55
+ return [telegramNotifier({ chatId, send: (id, text) => tg.sendMessage(id, text).then(() => undefined) })];
56
+ }
57
+ catch (e) {
58
+ process.stderr.write(`fortytwo-scheduler: Telegram notifier wiring failed: ${e?.message ?? e} — journal-only\n`);
59
+ return [];
60
+ }
61
+ })();
62
+ boot({ dbPath, extraNotifiers }).catch((err) => {
63
+ process.stderr.write(`fortytwo-scheduler: ${err?.stack ?? err}\n`);
64
+ process.exit(1);
65
+ });
66
+ }
67
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,15 @@
1
+ import type { Notification, Notifier } from './types.js';
2
+ /**
3
+ * Creates a notifier that forwards every notification to the injected `write`
4
+ * callback. The daemon supplies the real write that persists entries to the
5
+ * memory journal. Using DI keeps the journalling logic testable without
6
+ * opening a real database.
7
+ */
8
+ export declare function createJournalNotifier(write: (n: Notification) => Promise<void>): Notifier;
9
+ /**
10
+ * Creates a notifier that fans out to every notifier in the list. Each
11
+ * notifier is wrapped in its own try/catch so a failing notifier (e.g. a
12
+ * Telegram push) does NOT prevent the others (e.g. the journal write) from
13
+ * running. The returned promise always resolves.
14
+ */
15
+ export declare function createFanoutNotifier(notifiers: Notifier[]): Notifier;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Creates a notifier that forwards every notification to the injected `write`
3
+ * callback. The daemon supplies the real write that persists entries to the
4
+ * memory journal. Using DI keeps the journalling logic testable without
5
+ * opening a real database.
6
+ */
7
+ export function createJournalNotifier(write) {
8
+ return {
9
+ async notify(n) {
10
+ await write(n);
11
+ },
12
+ };
13
+ }
14
+ /**
15
+ * Creates a notifier that fans out to every notifier in the list. Each
16
+ * notifier is wrapped in its own try/catch so a failing notifier (e.g. a
17
+ * Telegram push) does NOT prevent the others (e.g. the journal write) from
18
+ * running. The returned promise always resolves.
19
+ */
20
+ export function createFanoutNotifier(notifiers) {
21
+ return {
22
+ async notify(n) {
23
+ for (const notifier of notifiers) {
24
+ try {
25
+ await notifier.notify(n);
26
+ }
27
+ catch {
28
+ // Intentionally swallowed: one failing notifier must not block others.
29
+ }
30
+ }
31
+ },
32
+ };
33
+ }
34
+ //# sourceMappingURL=notifier.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"notifier.js","sourceRoot":"","sources":["../src/notifier.ts"],"names":[],"mappings":"AAEA;;;;;GAKG;AACH,MAAM,UAAU,qBAAqB,CACnC,KAAyC;IAEzC,OAAO;QACL,KAAK,CAAC,MAAM,CAAC,CAAe;YAC1B,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC;QACjB,CAAC;KACF,CAAC;AACJ,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,oBAAoB,CAAC,SAAqB;IACxD,OAAO;QACL,KAAK,CAAC,MAAM,CAAC,CAAe;YAC1B,KAAK,MAAM,QAAQ,IAAI,SAAS,EAAE,CAAC;gBACjC,IAAI,CAAC;oBACH,MAAM,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;gBAC3B,CAAC;gBAAC,MAAM,CAAC;oBACP,uEAAuE;gBACzE,CAAC;YACH,CAAC;QACH,CAAC;KACF,CAAC;AACJ,CAAC"}
@@ -0,0 +1,2 @@
1
+ import type { Handler, Registry } from './types.js';
2
+ export declare function createRegistry(handlers: Handler[]): Registry;
@@ -0,0 +1,15 @@
1
+ export function createRegistry(handlers) {
2
+ const map = new Map();
3
+ for (const handler of handlers) {
4
+ if (map.has(handler.kind)) {
5
+ throw new Error(`Duplicate handler kind: '${handler.kind}'`);
6
+ }
7
+ map.set(handler.kind, handler);
8
+ }
9
+ return {
10
+ get(kind) {
11
+ return map.get(kind);
12
+ },
13
+ };
14
+ }
15
+ //# sourceMappingURL=registry.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"registry.js","sourceRoot":"","sources":["../src/registry.ts"],"names":[],"mappings":"AAEA,MAAM,UAAU,cAAc,CAAC,QAAmB;IAChD,MAAM,GAAG,GAAG,IAAI,GAAG,EAAmB,CAAC;IACvC,KAAK,MAAM,OAAO,IAAI,QAAQ,EAAE,CAAC;QAC/B,IAAI,GAAG,CAAC,GAAG,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC;YAC1B,MAAM,IAAI,KAAK,CAAC,4BAA4B,OAAO,CAAC,IAAI,GAAG,CAAC,CAAC;QAC/D,CAAC;QACD,GAAG,CAAC,GAAG,CAAC,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC;IACjC,CAAC;IACD,OAAO;QACL,GAAG,CAAC,IAAY;YACd,OAAO,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QACvB,CAAC;KACF,CAAC;AACJ,CAAC"}
package/dist/seed.d.ts ADDED
@@ -0,0 +1,25 @@
1
+ import type { RecurringDef } from './handlers/index.js';
2
+ export interface SeedStore {
3
+ listActive(): Array<{
4
+ id: number;
5
+ kind: string;
6
+ recurrence: string | null;
7
+ }>;
8
+ enqueue(j: {
9
+ kind: string;
10
+ run_at: string;
11
+ recurrence: string;
12
+ }): number;
13
+ setRecurrence(id: number, recurrence: string, runAt: string): void;
14
+ }
15
+ /**
16
+ * Seeds recurring job rows from the authoritative in-code definitions.
17
+ *
18
+ * For each def:
19
+ * - If no active row with that kind + a recurrence exists → INSERT (first boot).
20
+ * - If the existing row's cron differs from the def → UPDATE (cron changed in code).
21
+ * - Otherwise → leave it alone (idempotent on subsequent boots).
22
+ *
23
+ * `recurrenceNext` is injected so seed is testable without croner.
24
+ */
25
+ export declare function seedRecurring(store: SeedStore, defs: RecurringDef[], now: string, recurrenceNext: (cron: string, now: string) => string): void;
package/dist/seed.js ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Seeds recurring job rows from the authoritative in-code definitions.
3
+ *
4
+ * For each def:
5
+ * - If no active row with that kind + a recurrence exists → INSERT (first boot).
6
+ * - If the existing row's cron differs from the def → UPDATE (cron changed in code).
7
+ * - Otherwise → leave it alone (idempotent on subsequent boots).
8
+ *
9
+ * `recurrenceNext` is injected so seed is testable without croner.
10
+ */
11
+ export function seedRecurring(store, defs, now, recurrenceNext) {
12
+ const active = store.listActive();
13
+ for (const def of defs) {
14
+ const existing = active.find((j) => j.kind === def.kind && j.recurrence != null);
15
+ if (!existing) {
16
+ store.enqueue({
17
+ kind: def.kind,
18
+ run_at: recurrenceNext(def.cron, now),
19
+ recurrence: def.cron,
20
+ });
21
+ }
22
+ else if (existing.recurrence !== def.cron) {
23
+ store.setRecurrence(existing.id, def.cron, recurrenceNext(def.cron, now));
24
+ }
25
+ // else: unchanged — leave the existing row as-is
26
+ }
27
+ }
28
+ //# sourceMappingURL=seed.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"seed.js","sourceRoot":"","sources":["../src/seed.ts"],"names":[],"mappings":"AASA;;;;;;;;;GASG;AACH,MAAM,UAAU,aAAa,CAC3B,KAAgB,EAChB,IAAoB,EACpB,GAAW,EACX,cAAqD;IAErD,MAAM,MAAM,GAAG,KAAK,CAAC,UAAU,EAAE,CAAC;IAElC,KAAK,MAAM,GAAG,IAAI,IAAI,EAAE,CAAC;QACvB,MAAM,QAAQ,GAAG,MAAM,CAAC,IAAI,CAC1B,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,GAAG,CAAC,IAAI,IAAI,CAAC,CAAC,UAAU,IAAI,IAAI,CACnD,CAAC;QAEF,IAAI,CAAC,QAAQ,EAAE,CAAC;YACd,KAAK,CAAC,OAAO,CAAC;gBACZ,IAAI,EAAE,GAAG,CAAC,IAAI;gBACd,MAAM,EAAE,cAAc,CAAC,GAAG,CAAC,IAAI,EAAE,GAAG,CAAC;gBACrC,UAAU,EAAE,GAAG,CAAC,IAAI;aACrB,CAAC,CAAC;QACL,CAAC;aAAM,IAAI,QAAQ,CAAC,UAAU,KAAK,GAAG,CAAC,IAAI,EAAE,CAAC;YAC5C,KAAK,CAAC,aAAa,CAAC,QAAQ,CAAC,EAAE,EAAE,GAAG,CAAC,IAAI,EAAE,cAAc,CAAC,GAAG,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC,CAAC;QAC5E,CAAC;QACD,iDAAiD;IACnD,CAAC;AACH,CAAC"}
package/dist/tick.d.ts ADDED
@@ -0,0 +1,17 @@
1
+ import type { Job, Notification, Registry } from './types.js';
2
+ export interface TickDeps {
3
+ claimDue(now: string): Job[];
4
+ complete(id: number, opts: {
5
+ reschedule?: string;
6
+ }): void;
7
+ fail(id: number, error: string, opts?: {
8
+ retryAt?: string;
9
+ }): void;
10
+ registry: Registry;
11
+ recurrenceNext(cron: string, now: string): string;
12
+ enqueueRun(fn: () => Promise<void>): Promise<void>;
13
+ notify(n: Notification): Promise<void>;
14
+ }
15
+ export declare const RETRY_BACKOFF_MIN: readonly [1, 5, 30];
16
+ export declare function backoffAt(now: string, attempts: number): string;
17
+ export declare function tick(deps: TickDeps, now: string): Promise<void>;
package/dist/tick.js ADDED
@@ -0,0 +1,41 @@
1
+ export const RETRY_BACKOFF_MIN = [1, 5, 30];
2
+ export function backoffAt(now, attempts) {
3
+ const mins = RETRY_BACKOFF_MIN[Math.min(attempts, RETRY_BACKOFF_MIN.length - 1)];
4
+ return new Date(Date.parse(now) + mins * 60_000).toISOString();
5
+ }
6
+ export async function tick(deps, now) {
7
+ for (const job of deps.claimDue(now)) {
8
+ const handler = deps.registry.get(job.kind);
9
+ if (!handler) {
10
+ deps.fail(job.id, `no handler for kind '${job.kind}'`);
11
+ continue;
12
+ }
13
+ const reschedule = job.recurrence
14
+ ? { reschedule: deps.recurrenceNext(job.recurrence, now) }
15
+ : {};
16
+ let ctx;
17
+ try {
18
+ ctx = { now, payload: job.payload ? JSON.parse(job.payload) : undefined };
19
+ if (handler.hasWork && !(await handler.hasWork(ctx))) {
20
+ deps.complete(job.id, reschedule);
21
+ continue;
22
+ }
23
+ }
24
+ catch (e) {
25
+ deps.fail(job.id, e instanceof Error ? e.message : String(e), { retryAt: backoffAt(now, job.attempts) });
26
+ continue;
27
+ }
28
+ await deps.enqueueRun(async () => {
29
+ try {
30
+ const res = await handler.run(ctx);
31
+ if (res && res.text)
32
+ await deps.notify({ kind: job.kind, text: res.text });
33
+ deps.complete(job.id, reschedule);
34
+ }
35
+ catch (e) {
36
+ deps.fail(job.id, e instanceof Error ? e.message : String(e), { retryAt: backoffAt(now, job.attempts) });
37
+ }
38
+ });
39
+ }
40
+ }
41
+ //# sourceMappingURL=tick.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tick.js","sourceRoot":"","sources":["../src/tick.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,32 @@
1
+ export interface Job {
2
+ id: number;
3
+ kind: string;
4
+ payload: string | null;
5
+ run_at: string;
6
+ recurrence: string | null;
7
+ status: string;
8
+ attempts: number;
9
+ }
10
+ export interface RunCtx {
11
+ now: string;
12
+ payload: unknown;
13
+ }
14
+ export interface RunResult {
15
+ ok: boolean;
16
+ text: string;
17
+ }
18
+ export interface Handler {
19
+ kind: string;
20
+ hasWork?(ctx: RunCtx): Promise<boolean>;
21
+ run(ctx: RunCtx): Promise<RunResult | null>;
22
+ }
23
+ export interface Notification {
24
+ kind: string;
25
+ text: string;
26
+ }
27
+ export interface Notifier {
28
+ notify(n: Notification): Promise<void>;
29
+ }
30
+ export interface Registry {
31
+ get(kind: string): Handler | undefined;
32
+ }
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@justfortytwo/scheduler",
3
+ "version": "0.1.0",
4
+ "description": "Durable job scheduler daemon for justfortytwo — drains a jobs queue and fires LLM turns behind a cheap gate.",
5
+ "license": "MIT",
6
+ "author": {
7
+ "name": "Enrico Deleo",
8
+ "url": "https://enricodeleo.com"
9
+ },
10
+ "homepage": "https://forty-two.it",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/justfortytwo/scheduler.git"
14
+ },
15
+ "type": "module",
16
+ "engines": {
17
+ "node": ">=20"
18
+ },
19
+ "exports": {
20
+ ".": "./dist/index.js"
21
+ },
22
+ "files": [
23
+ "dist",
24
+ "README.md",
25
+ "LICENSE"
26
+ ],
27
+ "scripts": {
28
+ "build": "tsc -p tsconfig.json",
29
+ "test": "vitest run",
30
+ "test:watch": "vitest",
31
+ "prepublishOnly": "npm run build"
32
+ },
33
+ "bin": {
34
+ "fortytwo-scheduler": "dist/index.js"
35
+ },
36
+ "dependencies": {
37
+ "@justfortytwo/memory": "^0.1.5",
38
+ "@justfortytwo/runner": "^0.1.0",
39
+ "croner": "^10.0.0",
40
+ "p-queue": "^9.0.0"
41
+ },
42
+ "devDependencies": {
43
+ "@types/node": "^20.0.0",
44
+ "typescript": "^5.4.0",
45
+ "vitest": "^1.6.0"
46
+ },
47
+ "publishConfig": {
48
+ "access": "public"
49
+ },
50
+ "peerDependencies": {
51
+ "@justfortytwo/telegram": "^0.1.5"
52
+ },
53
+ "peerDependenciesMeta": {
54
+ "@justfortytwo/telegram": {
55
+ "optional": true
56
+ }
57
+ }
58
+ }