@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 +21 -0
- package/README.md +92 -0
- package/dist/daemon.d.ts +65 -0
- package/dist/daemon.js +156 -0
- package/dist/daemon.js.map +1 -0
- package/dist/handlers/index.d.ts +33 -0
- package/dist/handlers/index.js +61 -0
- package/dist/handlers/index.js.map +1 -0
- package/dist/handlers/oneoff.d.ts +17 -0
- package/dist/handlers/oneoff.js +50 -0
- package/dist/handlers/oneoff.js.map +1 -0
- package/dist/handlers/recurring.d.ts +18 -0
- package/dist/handlers/recurring.js +20 -0
- package/dist/handlers/recurring.js.map +1 -0
- package/dist/handlers/reembed.d.ts +10 -0
- package/dist/handlers/reembed.js +19 -0
- package/dist/handlers/reembed.js.map +1 -0
- package/dist/heartbeat.d.ts +12 -0
- package/dist/heartbeat.js +25 -0
- package/dist/heartbeat.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +67 -0
- package/dist/index.js.map +1 -0
- package/dist/notifier.d.ts +15 -0
- package/dist/notifier.js +34 -0
- package/dist/notifier.js.map +1 -0
- package/dist/registry.d.ts +2 -0
- package/dist/registry.js +15 -0
- package/dist/registry.js.map +1 -0
- package/dist/seed.d.ts +25 -0
- package/dist/seed.js +28 -0
- package/dist/seed.js.map +1 -0
- package/dist/tick.d.ts +17 -0
- package/dist/tick.js +41 -0
- package/dist/tick.js.map +1 -0
- package/dist/types.d.ts +32 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +58 -0
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
|
package/dist/daemon.d.ts
ADDED
|
@@ -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"}
|
package/dist/index.d.ts
ADDED
|
@@ -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;
|
package/dist/notifier.js
ADDED
|
@@ -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"}
|
package/dist/registry.js
ADDED
|
@@ -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
|
package/dist/seed.js.map
ADDED
|
@@ -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
|
package/dist/tick.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tick.js","sourceRoot":"","sources":["../src/tick.ts"],"names":[],"mappings":"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"}
|
package/dist/types.d.ts
ADDED
|
@@ -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 @@
|
|
|
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
|
+
}
|