@gtc6244/immediacy-daemon 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/dist/src/actions.d.ts +22 -0
- package/dist/src/actions.js +44 -0
- package/dist/src/actions.js.map +1 -0
- package/dist/src/agent/agent.d.ts +89 -0
- package/dist/src/agent/agent.js +22 -0
- package/dist/src/agent/agent.js.map +1 -0
- package/dist/src/agent/claude.d.ts +70 -0
- package/dist/src/agent/claude.js +404 -0
- package/dist/src/agent/claude.js.map +1 -0
- package/dist/src/agent/codex.d.ts +44 -0
- package/dist/src/agent/codex.js +303 -0
- package/dist/src/agent/codex.js.map +1 -0
- package/dist/src/agent/mock.d.ts +31 -0
- package/dist/src/agent/mock.js +155 -0
- package/dist/src/agent/mock.js.map +1 -0
- package/dist/src/agent/model-error.d.ts +8 -0
- package/dist/src/agent/model-error.js +25 -0
- package/dist/src/agent/model-error.js.map +1 -0
- package/dist/src/agent/openrouter.d.ts +38 -0
- package/dist/src/agent/openrouter.js +60 -0
- package/dist/src/agent/openrouter.js.map +1 -0
- package/dist/src/agent/workspace-scope.d.ts +25 -0
- package/dist/src/agent/workspace-scope.js +71 -0
- package/dist/src/agent/workspace-scope.js.map +1 -0
- package/dist/src/allowlist.d.ts +56 -0
- package/dist/src/allowlist.js +141 -0
- package/dist/src/allowlist.js.map +1 -0
- package/dist/src/chatlog.d.ts +50 -0
- package/dist/src/chatlog.js +179 -0
- package/dist/src/chatlog.js.map +1 -0
- package/dist/src/cli/paths.d.ts +40 -0
- package/dist/src/cli/paths.js +76 -0
- package/dist/src/cli/paths.js.map +1 -0
- package/dist/src/cli/service.d.ts +20 -0
- package/dist/src/cli/service.js +245 -0
- package/dist/src/cli/service.js.map +1 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +227 -0
- package/dist/src/cli.js.map +1 -0
- package/dist/src/config.d.ts +33 -0
- package/dist/src/config.js +122 -0
- package/dist/src/config.js.map +1 -0
- package/dist/src/credentials.d.ts +38 -0
- package/dist/src/credentials.js +120 -0
- package/dist/src/credentials.js.map +1 -0
- package/dist/src/crypto.d.ts +49 -0
- package/dist/src/crypto.js +202 -0
- package/dist/src/crypto.js.map +1 -0
- package/dist/src/daemon-identity.d.ts +31 -0
- package/dist/src/daemon-identity.js +97 -0
- package/dist/src/daemon-identity.js.map +1 -0
- package/dist/src/envelope.d.ts +14 -0
- package/dist/src/envelope.js +28 -0
- package/dist/src/envelope.js.map +1 -0
- package/dist/src/events.d.ts +40 -0
- package/dist/src/events.js +24 -0
- package/dist/src/events.js.map +1 -0
- package/dist/src/forward.d.ts +51 -0
- package/dist/src/forward.js +170 -0
- package/dist/src/forward.js.map +1 -0
- package/dist/src/fs-scope.d.ts +17 -0
- package/dist/src/fs-scope.js +47 -0
- package/dist/src/fs-scope.js.map +1 -0
- package/dist/src/github.d.ts +70 -0
- package/dist/src/github.js +178 -0
- package/dist/src/github.js.map +1 -0
- package/dist/src/gitops.d.ts +113 -0
- package/dist/src/gitops.js +557 -0
- package/dist/src/gitops.js.map +1 -0
- package/dist/src/hub.d.ts +50 -0
- package/dist/src/hub.js +92 -0
- package/dist/src/hub.js.map +1 -0
- package/dist/src/index.d.ts +22 -0
- package/dist/src/index.js +56 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/jobs/scheduler.d.ts +94 -0
- package/dist/src/jobs/scheduler.js +373 -0
- package/dist/src/jobs/scheduler.js.map +1 -0
- package/dist/src/jobs/store.d.ts +33 -0
- package/dist/src/jobs/store.js +111 -0
- package/dist/src/jobs/store.js.map +1 -0
- package/dist/src/log.d.ts +61 -0
- package/dist/src/log.js +166 -0
- package/dist/src/log.js.map +1 -0
- package/dist/src/migrate-state-dir.d.ts +1 -0
- package/dist/src/migrate-state-dir.js +47 -0
- package/dist/src/migrate-state-dir.js.map +1 -0
- package/dist/src/models.d.ts +36 -0
- package/dist/src/models.js +71 -0
- package/dist/src/models.js.map +1 -0
- package/dist/src/permission-gate.d.ts +48 -0
- package/dist/src/permission-gate.js +93 -0
- package/dist/src/permission-gate.js.map +1 -0
- package/dist/src/permission-scope.d.ts +77 -0
- package/dist/src/permission-scope.js +244 -0
- package/dist/src/permission-scope.js.map +1 -0
- package/dist/src/ports.d.ts +71 -0
- package/dist/src/ports.js +216 -0
- package/dist/src/ports.js.map +1 -0
- package/dist/src/projects.d.ts +83 -0
- package/dist/src/projects.js +205 -0
- package/dist/src/projects.js.map +1 -0
- package/dist/src/protocol.d.ts +1226 -0
- package/dist/src/protocol.js +16 -0
- package/dist/src/protocol.js.map +1 -0
- package/dist/src/repo-sync.d.ts +49 -0
- package/dist/src/repo-sync.js +83 -0
- package/dist/src/repo-sync.js.map +1 -0
- package/dist/src/server.d.ts +33 -0
- package/dist/src/server.js +973 -0
- package/dist/src/server.js.map +1 -0
- package/dist/src/terminal.d.ts +32 -0
- package/dist/src/terminal.js +92 -0
- package/dist/src/terminal.js.map +1 -0
- package/dist/src/titler.d.ts +15 -0
- package/dist/src/titler.js +72 -0
- package/dist/src/titler.js.map +1 -0
- package/dist/src/types.d.ts +99 -0
- package/dist/src/types.js +3 -0
- package/dist/src/types.js.map +1 -0
- package/dist/src/usage-limit.d.ts +49 -0
- package/dist/src/usage-limit.js +83 -0
- package/dist/src/usage-limit.js.map +1 -0
- package/dist/src/usage.d.ts +23 -0
- package/dist/src/usage.js +63 -0
- package/dist/src/usage.js.map +1 -0
- package/dist/src/workspace.d.ts +303 -0
- package/dist/src/workspace.js +1086 -0
- package/dist/src/workspace.js.map +1 -0
- package/dist/src/worktree.d.ts +133 -0
- package/dist/src/worktree.js +561 -0
- package/dist/src/worktree.js.map +1 -0
- package/package.json +67 -0
package/dist/src/hub.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connection hub. Single-user, but more than one device may be attached at once
|
|
3
|
+
* (phone + tablet + a desktop client), so daemon→client events broadcast to all
|
|
4
|
+
* sockets.
|
|
5
|
+
*
|
|
6
|
+
* This is also the single send seam. With E2E on, each device pairs its own
|
|
7
|
+
* keypair, so there is no one global cipher: every socket carries its OWN codec
|
|
8
|
+
* (cleartext until it completes the `hello` handshake, then a NaCl box sealed to
|
|
9
|
+
* that device's key). Sends and broadcasts therefore encode per-socket.
|
|
10
|
+
*/
|
|
11
|
+
import { WebSocket } from "ws";
|
|
12
|
+
import { make } from "./envelope.js";
|
|
13
|
+
import { clearCodec } from "./crypto.js";
|
|
14
|
+
export class Hub {
|
|
15
|
+
requireSealed;
|
|
16
|
+
/** Each attached socket and the codec its frames are (de)coded with. */
|
|
17
|
+
conns = new Map();
|
|
18
|
+
/**
|
|
19
|
+
* Sockets that asked to follow the daemon log (via `log.tail`). Live
|
|
20
|
+
* `log.event` frames go only here, so a phone that never opens a log view
|
|
21
|
+
* isn't fed a firehose. Membership is dropped when the socket detaches.
|
|
22
|
+
*/
|
|
23
|
+
logSubscribers = new Set();
|
|
24
|
+
/**
|
|
25
|
+
* When true (E2E on), a socket that hasn't completed the `hello` handshake is
|
|
26
|
+
* still on the cleartext codec, so fanning a broadcast out to it would leak the
|
|
27
|
+
* payload to the Cloudflare edge in clear. In that mode we skip unsealed
|
|
28
|
+
* sockets on the broadcast paths — a device receives events only once it has
|
|
29
|
+
* sealed its channel. Point-to-point handshake replies still go via `send`.
|
|
30
|
+
*/
|
|
31
|
+
constructor(requireSealed = false) {
|
|
32
|
+
this.requireSealed = requireSealed;
|
|
33
|
+
}
|
|
34
|
+
/** A socket whose channel is sealed to its device key (post-`hello`). */
|
|
35
|
+
sealed(socket) {
|
|
36
|
+
return !this.requireSealed || this.conns.get(socket) !== clearCodec;
|
|
37
|
+
}
|
|
38
|
+
add(socket) {
|
|
39
|
+
// New sockets start in cleartext; the `hello` handshake upgrades them to a
|
|
40
|
+
// per-device sealed codec when E2E is on.
|
|
41
|
+
this.conns.set(socket, clearCodec);
|
|
42
|
+
}
|
|
43
|
+
remove(socket) {
|
|
44
|
+
this.conns.delete(socket);
|
|
45
|
+
this.logSubscribers.delete(socket);
|
|
46
|
+
}
|
|
47
|
+
/** Follow the live daemon log on this socket (idempotent). */
|
|
48
|
+
subscribeLog(socket) {
|
|
49
|
+
if (this.conns.has(socket))
|
|
50
|
+
this.logSubscribers.add(socket);
|
|
51
|
+
}
|
|
52
|
+
/** Push a daemon→client message to just the log subscribers. */
|
|
53
|
+
broadcastLog(type, ws, payload) {
|
|
54
|
+
if (this.logSubscribers.size === 0)
|
|
55
|
+
return;
|
|
56
|
+
const env = make(type, ws, payload);
|
|
57
|
+
for (const socket of this.logSubscribers) {
|
|
58
|
+
const codec = this.conns.get(socket);
|
|
59
|
+
if (codec && socket.readyState === WebSocket.OPEN && this.sealed(socket)) {
|
|
60
|
+
socket.send(codec.encode(env));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** The codec bound to a socket (cleartext if unknown/unpaired). */
|
|
65
|
+
codecFor(socket) {
|
|
66
|
+
return this.conns.get(socket) ?? clearCodec;
|
|
67
|
+
}
|
|
68
|
+
/** Bind a socket to a codec (called once the device's key is known). */
|
|
69
|
+
setCodec(socket, codec) {
|
|
70
|
+
if (this.conns.has(socket))
|
|
71
|
+
this.conns.set(socket, codec);
|
|
72
|
+
}
|
|
73
|
+
/** Send to one socket, sealed with that socket's own codec. */
|
|
74
|
+
send(socket, env) {
|
|
75
|
+
if (socket.readyState === WebSocket.OPEN)
|
|
76
|
+
socket.send(this.codecFor(socket).encode(env));
|
|
77
|
+
}
|
|
78
|
+
/** Build + send a daemon→client message to ONE socket (per-device streams). */
|
|
79
|
+
sendTo(socket, type, ws, payload) {
|
|
80
|
+
this.send(socket, make(type, ws, payload));
|
|
81
|
+
}
|
|
82
|
+
/** Build + broadcast a daemon→client message to every attached socket. */
|
|
83
|
+
broadcast(type, ws, payload) {
|
|
84
|
+
const env = make(type, ws, payload);
|
|
85
|
+
for (const [socket, codec] of this.conns) {
|
|
86
|
+
if (socket.readyState === WebSocket.OPEN && this.sealed(socket)) {
|
|
87
|
+
socket.send(codec.encode(env));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
//# sourceMappingURL=hub.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"hub.js","sourceRoot":"","sources":["../../src/hub.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon entrypoint.
|
|
3
|
+
*
|
|
4
|
+
* Env config (nothing sensitive in source):
|
|
5
|
+
* DAEMON_PORT default 8787 — local port cloudflared dials
|
|
6
|
+
* DAEMON_HOST default 127.0.0.1 — loopback for the tunnel; 0.0.0.0 to let
|
|
7
|
+
* an Android emulator reach it via 10.0.2.2
|
|
8
|
+
* AGENT_MODE claude | mock — real headless Claude (default) vs scripted
|
|
9
|
+
* Provider API keys — managed live from the app (settings.set, persisted to
|
|
10
|
+
* ~/.immediacy/credentials.json) and also seedable from
|
|
11
|
+
* env: ANTHROPIC_API_KEY (Claude), OPENAI_API_KEY (Codex),
|
|
12
|
+
* OPENROUTER_API_KEY (OpenRouter). A stored key wins over
|
|
13
|
+
* env; with no key, Claude/Codex fall back to their cached
|
|
14
|
+
* subscription login (SSO) and OpenRouter fails auth.
|
|
15
|
+
* OPENROUTER_BASE_URL overrides that endpoint
|
|
16
|
+
* (default https://openrouter.ai/api). CREDENTIALS_FILE
|
|
17
|
+
* overrides the store path.
|
|
18
|
+
* E2E 1 to enable the NaCl box layer (needs a paired peer key)
|
|
19
|
+
* PEER_PUBLIC_KEY base64 of the client's public key (dev pairing shortcut)
|
|
20
|
+
* WORKTREE_ROOT, STATE_FILE, KEYS_FILE, PEER_FILE — storage overrides
|
|
21
|
+
*/
|
|
22
|
+
export {};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon entrypoint.
|
|
3
|
+
*
|
|
4
|
+
* Env config (nothing sensitive in source):
|
|
5
|
+
* DAEMON_PORT default 8787 — local port cloudflared dials
|
|
6
|
+
* DAEMON_HOST default 127.0.0.1 — loopback for the tunnel; 0.0.0.0 to let
|
|
7
|
+
* an Android emulator reach it via 10.0.2.2
|
|
8
|
+
* AGENT_MODE claude | mock — real headless Claude (default) vs scripted
|
|
9
|
+
* Provider API keys — managed live from the app (settings.set, persisted to
|
|
10
|
+
* ~/.immediacy/credentials.json) and also seedable from
|
|
11
|
+
* env: ANTHROPIC_API_KEY (Claude), OPENAI_API_KEY (Codex),
|
|
12
|
+
* OPENROUTER_API_KEY (OpenRouter). A stored key wins over
|
|
13
|
+
* env; with no key, Claude/Codex fall back to their cached
|
|
14
|
+
* subscription login (SSO) and OpenRouter fails auth.
|
|
15
|
+
* OPENROUTER_BASE_URL overrides that endpoint
|
|
16
|
+
* (default https://openrouter.ai/api). CREDENTIALS_FILE
|
|
17
|
+
* overrides the store path.
|
|
18
|
+
* E2E 1 to enable the NaCl box layer (needs a paired peer key)
|
|
19
|
+
* PEER_PUBLIC_KEY base64 of the client's public key (dev pairing shortcut)
|
|
20
|
+
* WORKTREE_ROOT, STATE_FILE, KEYS_FILE, PEER_FILE — storage overrides
|
|
21
|
+
*/
|
|
22
|
+
import { startServer } from "./server.js";
|
|
23
|
+
import { loadOrCreateKeypair, loadPeers } from "./crypto.js";
|
|
24
|
+
import { migrateStateDir } from "./migrate-state-dir.js";
|
|
25
|
+
// Rename legacy ~/.agent-tether → ~/.immediacy before any state path is read.
|
|
26
|
+
migrateStateDir();
|
|
27
|
+
const port = Number(process.env.DAEMON_PORT ?? 8787);
|
|
28
|
+
const host = process.env.DAEMON_HOST ?? "127.0.0.1";
|
|
29
|
+
const agentMode = (process.env.AGENT_MODE ?? "claude").toLowerCase();
|
|
30
|
+
const e2e = process.env.E2E === "1";
|
|
31
|
+
async function resolveCrypto() {
|
|
32
|
+
if (!e2e)
|
|
33
|
+
return undefined;
|
|
34
|
+
const self = await loadOrCreateKeypair();
|
|
35
|
+
console.log(`[daemon] E2E on. Daemon public key (encode this into the pairing QR):\n ${self.publicKey.toString("base64")}`);
|
|
36
|
+
// Devices pair themselves on connect (the `hello` handshake, gated by the
|
|
37
|
+
// Cloudflare Access token). Pre-existing pairings are just logged here.
|
|
38
|
+
const peers = await loadPeers();
|
|
39
|
+
console.log(`[daemon] ${peers.length} paired device(s); each socket seals to its own key on hello.`);
|
|
40
|
+
return { self };
|
|
41
|
+
}
|
|
42
|
+
const crypto = await resolveCrypto();
|
|
43
|
+
const daemon = await startServer({ port, host, crypto });
|
|
44
|
+
console.log(`[daemon] agent mode: ${agentMode}`);
|
|
45
|
+
function shutdown(signal) {
|
|
46
|
+
console.log(`\n[daemon] ${signal} received, closing…`);
|
|
47
|
+
daemon.jobs.stop(); // cancel cron timers so nothing fires mid-teardown
|
|
48
|
+
void daemon.workspaces.shutdown().finally(() => {
|
|
49
|
+
daemon.wss.close();
|
|
50
|
+
daemon.http.close(() => process.exit(0));
|
|
51
|
+
setTimeout(() => process.exit(0), 2000).unref();
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
55
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
56
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAC1C,OAAO,EAAE,mBAAmB,EAAE,SAAS,EAAgB,MAAM,aAAa,CAAC;AAC3E,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAEzD,8EAA8E;AAC9E,eAAe,EAAE,CAAC;AAElB,MAAM,IAAI,GAAG,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,WAAW,IAAI,IAAI,CAAC,CAAC;AACrD,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC,WAAW,IAAI,WAAW,CAAC;AACpD,MAAM,SAAS,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,UAAU,IAAI,QAAQ,CAAC,CAAC,WAAW,EAAE,CAAC;AACrE,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,CAAC,GAAG,KAAK,GAAG,CAAC;AAEpC,KAAK,UAAU,aAAa;IAC1B,IAAI,CAAC,GAAG;QAAE,OAAO,SAAS,CAAC;IAC3B,MAAM,IAAI,GAAG,MAAM,mBAAmB,EAAE,CAAC;IACzC,OAAO,CAAC,GAAG,CACT,4EAA4E,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAChH,CAAC;IACF,0EAA0E;IAC1E,wEAAwE;IACxE,MAAM,KAAK,GAAG,MAAM,SAAS,EAAE,CAAC;IAChC,OAAO,CAAC,GAAG,CACT,YAAY,KAAK,CAAC,MAAM,+DAA+D,CACxF,CAAC;IACF,OAAO,EAAE,IAAI,EAAE,CAAC;AAClB,CAAC;AAED,MAAM,MAAM,GAAG,MAAM,aAAa,EAAE,CAAC;AACrC,MAAM,MAAM,GAAG,MAAM,WAAW,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,CAAC,CAAC;AAEzD,OAAO,CAAC,GAAG,CAAC,wBAAwB,SAAS,EAAE,CAAC,CAAC;AAEjD,SAAS,QAAQ,CAAC,MAAc;IAC9B,OAAO,CAAC,GAAG,CAAC,cAAc,MAAM,qBAAqB,CAAC,CAAC;IACvD,MAAM,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,CAAC,mDAAmD;IACvE,KAAK,MAAM,CAAC,UAAU,CAAC,QAAQ,EAAE,CAAC,OAAO,CAAC,GAAG,EAAE;QAC7C,MAAM,CAAC,GAAG,CAAC,KAAK,EAAE,CAAC;QACnB,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC;QACzC,UAAU,CAAC,GAAG,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC,KAAK,EAAE,CAAC;IAClD,CAAC,CAAC,CAAC;AACL,CAAC;AAED,OAAO,CAAC,EAAE,CAAC,QAAQ,EAAE,GAAG,EAAE,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC,CAAC;AAC/C,OAAO,CAAC,EAAE,CAAC,SAAS,EAAE,GAAG,EAAE,CAAC,QAAQ,CAAC,SAAS,CAAC,CAAC,CAAC"}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JobScheduler — fires jobs on a schedule or reactively after a system event.
|
|
3
|
+
*
|
|
4
|
+
* Two trigger kinds (see protocol.ts):
|
|
5
|
+
* - schedule: a croner Cron per job. If the daemon was down at fire time the
|
|
6
|
+
* occurrence is simply missed (no catch-up).
|
|
7
|
+
* - after: subscribe once to the SystemEvents bus; when a matching event
|
|
8
|
+
* lands, arm a one-shot timer for `delayMinutes`, then run.
|
|
9
|
+
*
|
|
10
|
+
* A job's action is a prompt handed to an agent, either in an existing workspace
|
|
11
|
+
* or in a throwaway ("ephemeral") one created for the run and torn down after —
|
|
12
|
+
* kept for review if the worktree ends dirty. Unattended runs are safe by
|
|
13
|
+
* construction: worktree-scoped tool calls auto-approve, anything escaping the
|
|
14
|
+
* worktree default-denies after ~2min (no phone is present to approve).
|
|
15
|
+
*
|
|
16
|
+
* Loop safety: a job never reacts to the completion of its own run (the run's
|
|
17
|
+
* jobId is suppressed for that specific agent.done), and a hard minimum interval
|
|
18
|
+
* per job backstops any pathological rapid re-trigger.
|
|
19
|
+
*/
|
|
20
|
+
import type { JobInput, JobRecord, JobRun } from "../protocol.js";
|
|
21
|
+
import { SystemEvents } from "../events.js";
|
|
22
|
+
import { JobStore } from "./store.js";
|
|
23
|
+
/** The slice of WorkspaceManager the scheduler needs (keeps it decoupled/testable). */
|
|
24
|
+
export interface JobRunnerHost {
|
|
25
|
+
/** Send a prompt into a workspace's agent (spawns one if needed). */
|
|
26
|
+
prompt(ws: string, text: string): void;
|
|
27
|
+
/** Does a live workspace with this id exist? */
|
|
28
|
+
has(ws: string): boolean;
|
|
29
|
+
/** Create a workspace; returns at least its id + name. */
|
|
30
|
+
create(opts: {
|
|
31
|
+
repo?: string;
|
|
32
|
+
github?: {
|
|
33
|
+
fullName: string;
|
|
34
|
+
defaultBranch?: string;
|
|
35
|
+
};
|
|
36
|
+
name?: string;
|
|
37
|
+
branchBase?: string;
|
|
38
|
+
model?: string;
|
|
39
|
+
}): Promise<{
|
|
40
|
+
id: string;
|
|
41
|
+
name: string;
|
|
42
|
+
}>;
|
|
43
|
+
/** Remove a workspace's worktree (throws if the worktree is dirty). */
|
|
44
|
+
delete(ws: string): Promise<void>;
|
|
45
|
+
/** Ahead/behind/dirty summary, or null if the workspace is gone. */
|
|
46
|
+
gitStatus(ws: string): Promise<{
|
|
47
|
+
changed: string[];
|
|
48
|
+
} | null>;
|
|
49
|
+
}
|
|
50
|
+
/** Broadcast a job-run lifecycle frame to connected clients. */
|
|
51
|
+
export type RunBroadcaster = (run: JobRun) => void;
|
|
52
|
+
export declare class JobScheduler {
|
|
53
|
+
private store;
|
|
54
|
+
private host;
|
|
55
|
+
private events;
|
|
56
|
+
private broadcast;
|
|
57
|
+
private crons;
|
|
58
|
+
/** One-shot reactive timers, so disarming a job cancels its pending fires. */
|
|
59
|
+
private timers;
|
|
60
|
+
/** Runs dispatched to a workspace, awaiting that workspace's next agent.done. */
|
|
61
|
+
private pending;
|
|
62
|
+
private lastDispatchAt;
|
|
63
|
+
private started;
|
|
64
|
+
constructor(store: JobStore, host: JobRunnerHost, events: SystemEvents, broadcast: RunBroadcaster);
|
|
65
|
+
/** Arm every enabled job and begin listening for reactive triggers. */
|
|
66
|
+
start(): void;
|
|
67
|
+
list(): JobRecord[];
|
|
68
|
+
runsFor(jobId?: string, limit?: number): JobRun[];
|
|
69
|
+
create(input: JobInput): Promise<JobRecord>;
|
|
70
|
+
update(id: string, patch: Partial<JobInput>): Promise<JobRecord>;
|
|
71
|
+
delete(id: string): Promise<boolean>;
|
|
72
|
+
/** Fire a job immediately, ignoring its trigger (job.run / CLI). */
|
|
73
|
+
runNow(id: string): Promise<void>;
|
|
74
|
+
/** Cancel every timer/cron and let the process exit cleanly. */
|
|
75
|
+
stop(): void;
|
|
76
|
+
/** (Re)arm a single job: clears any prior wiring, then sets it up if enabled. */
|
|
77
|
+
private arm;
|
|
78
|
+
/**
|
|
79
|
+
* Disable a schedule job that has no further occurrence (a spent one-shot),
|
|
80
|
+
* so it stays in the list as a completed job (with its run history) instead of
|
|
81
|
+
* lingering "enabled" and re-arming as a no-op cron on every boot.
|
|
82
|
+
*/
|
|
83
|
+
private retireSpentSchedule;
|
|
84
|
+
private disarm;
|
|
85
|
+
private trackTimer;
|
|
86
|
+
private onSystemEvent;
|
|
87
|
+
/** Pop the oldest run awaiting `ws`'s completion and finalize it. */
|
|
88
|
+
private finishOldestPending;
|
|
89
|
+
private finalizeRun;
|
|
90
|
+
private runJob;
|
|
91
|
+
private resolveTarget;
|
|
92
|
+
private emitRun;
|
|
93
|
+
private recordFinal;
|
|
94
|
+
}
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JobScheduler — fires jobs on a schedule or reactively after a system event.
|
|
3
|
+
*
|
|
4
|
+
* Two trigger kinds (see protocol.ts):
|
|
5
|
+
* - schedule: a croner Cron per job. If the daemon was down at fire time the
|
|
6
|
+
* occurrence is simply missed (no catch-up).
|
|
7
|
+
* - after: subscribe once to the SystemEvents bus; when a matching event
|
|
8
|
+
* lands, arm a one-shot timer for `delayMinutes`, then run.
|
|
9
|
+
*
|
|
10
|
+
* A job's action is a prompt handed to an agent, either in an existing workspace
|
|
11
|
+
* or in a throwaway ("ephemeral") one created for the run and torn down after —
|
|
12
|
+
* kept for review if the worktree ends dirty. Unattended runs are safe by
|
|
13
|
+
* construction: worktree-scoped tool calls auto-approve, anything escaping the
|
|
14
|
+
* worktree default-denies after ~2min (no phone is present to approve).
|
|
15
|
+
*
|
|
16
|
+
* Loop safety: a job never reacts to the completion of its own run (the run's
|
|
17
|
+
* jobId is suppressed for that specific agent.done), and a hard minimum interval
|
|
18
|
+
* per job backstops any pathological rapid re-trigger.
|
|
19
|
+
*/
|
|
20
|
+
import { randomUUID } from "node:crypto";
|
|
21
|
+
import { Cron } from "croner";
|
|
22
|
+
import { logger } from "../log.js";
|
|
23
|
+
/** Hard floor between two runs of the same job — backstops re-trigger loops. */
|
|
24
|
+
const MIN_RUN_INTERVAL_MS = 10_000;
|
|
25
|
+
export class JobScheduler {
|
|
26
|
+
store;
|
|
27
|
+
host;
|
|
28
|
+
events;
|
|
29
|
+
broadcast;
|
|
30
|
+
crons = new Map();
|
|
31
|
+
/** One-shot reactive timers, so disarming a job cancels its pending fires. */
|
|
32
|
+
timers = new Map();
|
|
33
|
+
/** Runs dispatched to a workspace, awaiting that workspace's next agent.done. */
|
|
34
|
+
pending = new Map();
|
|
35
|
+
lastDispatchAt = new Map();
|
|
36
|
+
started = false;
|
|
37
|
+
constructor(store, host, events, broadcast) {
|
|
38
|
+
this.store = store;
|
|
39
|
+
this.host = host;
|
|
40
|
+
this.events = events;
|
|
41
|
+
this.broadcast = broadcast;
|
|
42
|
+
}
|
|
43
|
+
/** Arm every enabled job and begin listening for reactive triggers. */
|
|
44
|
+
start() {
|
|
45
|
+
if (this.started)
|
|
46
|
+
return;
|
|
47
|
+
this.started = true;
|
|
48
|
+
this.events.onSystem((e) => this.onSystemEvent(e));
|
|
49
|
+
for (const job of this.store.list())
|
|
50
|
+
this.arm(job);
|
|
51
|
+
}
|
|
52
|
+
list() {
|
|
53
|
+
// Surface a live nextRunAt for schedule jobs without persisting on every tick.
|
|
54
|
+
return this.store.list().map((j) => {
|
|
55
|
+
const next = this.crons.get(j.id)?.nextRun();
|
|
56
|
+
return next ? { ...j, nextRunAt: next.getTime() } : j;
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
runsFor(jobId, limit) {
|
|
60
|
+
return this.store.runsFor(jobId, limit);
|
|
61
|
+
}
|
|
62
|
+
async create(input) {
|
|
63
|
+
validateInput(input);
|
|
64
|
+
const now = Date.now();
|
|
65
|
+
const job = {
|
|
66
|
+
id: randomUUID(),
|
|
67
|
+
name: input.name.trim() || "job",
|
|
68
|
+
prompt: input.prompt,
|
|
69
|
+
trigger: input.trigger,
|
|
70
|
+
target: input.target,
|
|
71
|
+
enabled: input.enabled ?? true,
|
|
72
|
+
createdAt: now,
|
|
73
|
+
updatedAt: now,
|
|
74
|
+
};
|
|
75
|
+
await this.store.upsert(job);
|
|
76
|
+
this.arm(job);
|
|
77
|
+
return job;
|
|
78
|
+
}
|
|
79
|
+
async update(id, patch) {
|
|
80
|
+
const existing = this.store.get(id);
|
|
81
|
+
if (!existing)
|
|
82
|
+
throw new Error(`no such job: ${id}`);
|
|
83
|
+
const next = {
|
|
84
|
+
...existing,
|
|
85
|
+
...("name" in patch && patch.name !== undefined ? { name: patch.name.trim() || "job" } : {}),
|
|
86
|
+
...("prompt" in patch && patch.prompt !== undefined ? { prompt: patch.prompt } : {}),
|
|
87
|
+
...("trigger" in patch && patch.trigger ? { trigger: patch.trigger } : {}),
|
|
88
|
+
...("target" in patch && patch.target ? { target: patch.target } : {}),
|
|
89
|
+
...("enabled" in patch && patch.enabled !== undefined ? { enabled: patch.enabled } : {}),
|
|
90
|
+
updatedAt: Date.now(),
|
|
91
|
+
};
|
|
92
|
+
validateInput(next);
|
|
93
|
+
await this.store.upsert(next);
|
|
94
|
+
this.arm(next); // re-arm from scratch (disarms first)
|
|
95
|
+
return next;
|
|
96
|
+
}
|
|
97
|
+
async delete(id) {
|
|
98
|
+
this.disarm(id);
|
|
99
|
+
return this.store.delete(id);
|
|
100
|
+
}
|
|
101
|
+
/** Fire a job immediately, ignoring its trigger (job.run / CLI). */
|
|
102
|
+
async runNow(id) {
|
|
103
|
+
const job = this.store.get(id);
|
|
104
|
+
if (!job)
|
|
105
|
+
throw new Error(`no such job: ${id}`);
|
|
106
|
+
await this.runJob(job, "manual");
|
|
107
|
+
}
|
|
108
|
+
/** Cancel every timer/cron and let the process exit cleanly. */
|
|
109
|
+
stop() {
|
|
110
|
+
for (const c of this.crons.values())
|
|
111
|
+
c.stop();
|
|
112
|
+
this.crons.clear();
|
|
113
|
+
for (const set of this.timers.values())
|
|
114
|
+
for (const t of set)
|
|
115
|
+
clearTimeout(t);
|
|
116
|
+
this.timers.clear();
|
|
117
|
+
}
|
|
118
|
+
// --- arming -------------------------------------------------------------
|
|
119
|
+
/** (Re)arm a single job: clears any prior wiring, then sets it up if enabled. */
|
|
120
|
+
arm(job) {
|
|
121
|
+
this.disarm(job.id);
|
|
122
|
+
if (!job.enabled)
|
|
123
|
+
return;
|
|
124
|
+
if (job.trigger.kind === "schedule") {
|
|
125
|
+
try {
|
|
126
|
+
const cron = new Cron(job.trigger.cron, { timezone: job.trigger.timezone, name: job.id }, () => {
|
|
127
|
+
void this.runJob(job, "schedule");
|
|
128
|
+
// A one-shot (absolute-time) schedule fires once, then has no next
|
|
129
|
+
// occurrence — retire it so it doesn't linger "enabled" with a dead
|
|
130
|
+
// cron and re-arm as a no-op on every daemon boot.
|
|
131
|
+
if (this.crons.get(job.id)?.nextRun() == null) {
|
|
132
|
+
void this.retireSpentSchedule(job.id);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
this.crons.set(job.id, cron);
|
|
136
|
+
// No upcoming occurrence means this schedule will never fire — a one-shot
|
|
137
|
+
// absolute time already in the past (a stale reset time, or the daemon
|
|
138
|
+
// was down at fire time). Surface it as a skipped run and retire it,
|
|
139
|
+
// rather than leaving a silent, permanently-"enabled" dead job.
|
|
140
|
+
if (cron.nextRun() == null) {
|
|
141
|
+
this.emitRun(job, "schedule", "skipped", "scheduled time is already in the past", undefined);
|
|
142
|
+
void this.retireSpentSchedule(job.id);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
logger.error("error", `failed to arm schedule for job`, {
|
|
147
|
+
data: { job: job.name, message: err instanceof Error ? err.message : String(err) },
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// "after" jobs need no arming here — onSystemEvent finds them by scanning
|
|
152
|
+
// the store when an event lands, so they stay correct across updates.
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Disable a schedule job that has no further occurrence (a spent one-shot),
|
|
156
|
+
* so it stays in the list as a completed job (with its run history) instead of
|
|
157
|
+
* lingering "enabled" and re-arming as a no-op cron on every boot.
|
|
158
|
+
*/
|
|
159
|
+
async retireSpentSchedule(jobId) {
|
|
160
|
+
const stored = this.store.get(jobId);
|
|
161
|
+
if (!stored || !stored.enabled)
|
|
162
|
+
return;
|
|
163
|
+
stored.enabled = false;
|
|
164
|
+
await this.store.upsert(stored);
|
|
165
|
+
this.disarm(jobId);
|
|
166
|
+
}
|
|
167
|
+
disarm(jobId) {
|
|
168
|
+
this.crons.get(jobId)?.stop();
|
|
169
|
+
this.crons.delete(jobId);
|
|
170
|
+
const set = this.timers.get(jobId);
|
|
171
|
+
if (set) {
|
|
172
|
+
for (const t of set)
|
|
173
|
+
clearTimeout(t);
|
|
174
|
+
this.timers.delete(jobId);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
trackTimer(jobId, t) {
|
|
178
|
+
let set = this.timers.get(jobId);
|
|
179
|
+
if (!set)
|
|
180
|
+
this.timers.set(jobId, (set = new Set()));
|
|
181
|
+
set.add(t);
|
|
182
|
+
}
|
|
183
|
+
// --- reactive triggers & run completion ---------------------------------
|
|
184
|
+
onSystemEvent(e) {
|
|
185
|
+
// 1) A terminal agent event (done OR error) finalizes the oldest run awaiting
|
|
186
|
+
// that workspace, and yields the jobId to suppress from re-arming on this
|
|
187
|
+
// same event. Finalizing on error too is what stops a failed run — and its
|
|
188
|
+
// ephemeral worktree — from dangling forever (an errored turn emits no
|
|
189
|
+
// agent.done). agent.error is internal-only, so step 2 never matches it.
|
|
190
|
+
let finishedJobId;
|
|
191
|
+
if (e.kind === "agent.done" || e.kind === "agent.error") {
|
|
192
|
+
finishedJobId = this.finishOldestPending(e.ws, e.kind === "agent.done" ? "ok" : "error");
|
|
193
|
+
}
|
|
194
|
+
// 2) Arm any enabled "after" job whose event + workspace filter match, minus
|
|
195
|
+
// the job that just finished here (never react to your own completion).
|
|
196
|
+
for (const job of this.store.list()) {
|
|
197
|
+
if (!job.enabled || job.trigger.kind !== "after")
|
|
198
|
+
continue;
|
|
199
|
+
if (job.trigger.event !== e.kind)
|
|
200
|
+
continue;
|
|
201
|
+
if (job.trigger.ws && job.trigger.ws !== e.ws)
|
|
202
|
+
continue;
|
|
203
|
+
if (job.id === finishedJobId)
|
|
204
|
+
continue;
|
|
205
|
+
const delayMs = Math.max(0, Math.floor(job.trigger.delayMinutes * 60_000));
|
|
206
|
+
const t = setTimeout(() => {
|
|
207
|
+
this.timers.get(job.id)?.delete(t);
|
|
208
|
+
void this.runJob(job, "after", { ...e.context, ws: e.ws });
|
|
209
|
+
}, delayMs);
|
|
210
|
+
this.trackTimer(job.id, t);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/** Pop the oldest run awaiting `ws`'s completion and finalize it. */
|
|
214
|
+
finishOldestPending(ws, outcome) {
|
|
215
|
+
const queue = this.pending.get(ws);
|
|
216
|
+
if (!queue || queue.length === 0)
|
|
217
|
+
return undefined;
|
|
218
|
+
const p = queue.shift();
|
|
219
|
+
if (queue.length === 0)
|
|
220
|
+
this.pending.delete(ws);
|
|
221
|
+
void this.finalizeRun(p, ws, outcome);
|
|
222
|
+
return p.jobId;
|
|
223
|
+
}
|
|
224
|
+
async finalizeRun(p, ws, outcome) {
|
|
225
|
+
const status = outcome === "error" ? "error" : "ok";
|
|
226
|
+
let message = outcome === "error" ? "agent reported an error" : "completed";
|
|
227
|
+
if (p.ephemeral) {
|
|
228
|
+
try {
|
|
229
|
+
const st = await this.host.gitStatus(ws);
|
|
230
|
+
if (st && st.changed.length > 0) {
|
|
231
|
+
message += `; kept workspace for review (${st.changed.length} uncommitted change${st.changed.length === 1 ? "" : "s"})`;
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
await this.host.delete(ws);
|
|
235
|
+
message += "; ephemeral workspace torn down";
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
catch (err) {
|
|
239
|
+
// Teardown is best-effort — keep the workspace rather than lose work.
|
|
240
|
+
message += `; teardown skipped (${errText(err)})`;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
this.recordFinal(p.run, status, message);
|
|
244
|
+
}
|
|
245
|
+
// --- running ------------------------------------------------------------
|
|
246
|
+
async runJob(job, trigger, context) {
|
|
247
|
+
const now = Date.now();
|
|
248
|
+
const last = this.lastDispatchAt.get(job.id) ?? 0;
|
|
249
|
+
if (now - last < MIN_RUN_INTERVAL_MS) {
|
|
250
|
+
this.emitRun(job, trigger, "skipped", "throttled (min interval)", undefined);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
// Resolve where the run executes.
|
|
254
|
+
let ws;
|
|
255
|
+
let ephemeral = false;
|
|
256
|
+
try {
|
|
257
|
+
const resolved = await this.resolveTarget(job, context);
|
|
258
|
+
ws = resolved.ws;
|
|
259
|
+
ephemeral = resolved.ephemeral;
|
|
260
|
+
}
|
|
261
|
+
catch (err) {
|
|
262
|
+
this.emitRun(job, trigger, "skipped", errText(err), undefined);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
this.lastDispatchAt.set(job.id, now);
|
|
266
|
+
const run = this.emitRun(job, trigger, "started", "dispatched", ws);
|
|
267
|
+
// Queue for completion tracking, then dispatch the prompt.
|
|
268
|
+
const queue = this.pending.get(ws) ?? [];
|
|
269
|
+
queue.push({ jobId: job.id, run, ephemeral });
|
|
270
|
+
this.pending.set(ws, queue);
|
|
271
|
+
const prompt = renderPrompt(job.prompt, { ...context, ws });
|
|
272
|
+
this.host.prompt(ws, prompt);
|
|
273
|
+
// Update bookkeeping on the stored record.
|
|
274
|
+
const stored = this.store.get(job.id);
|
|
275
|
+
if (stored) {
|
|
276
|
+
stored.lastRunAt = now;
|
|
277
|
+
stored.lastStatus = "started";
|
|
278
|
+
await this.store.upsert(stored);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
async resolveTarget(job, context) {
|
|
282
|
+
const target = job.target;
|
|
283
|
+
if (target.kind === "workspace") {
|
|
284
|
+
// For an "after" trigger, an omitted ws means "where the event fired".
|
|
285
|
+
const ws = target.ws ?? context?.ws;
|
|
286
|
+
if (!ws)
|
|
287
|
+
throw new Error("workspace target has no workspace id");
|
|
288
|
+
if (!this.host.has(ws))
|
|
289
|
+
throw new Error(`workspace ${ws} no longer exists`);
|
|
290
|
+
return { ws, ephemeral: false };
|
|
291
|
+
}
|
|
292
|
+
if (!target.repo && !target.github?.fullName) {
|
|
293
|
+
throw new Error("ephemeral target needs a repo path or a github source");
|
|
294
|
+
}
|
|
295
|
+
const rec = await this.host.create({
|
|
296
|
+
repo: target.repo,
|
|
297
|
+
github: target.github,
|
|
298
|
+
name: target.name ?? `job-${job.name}`,
|
|
299
|
+
branchBase: target.branchBase,
|
|
300
|
+
model: target.model,
|
|
301
|
+
});
|
|
302
|
+
return { ws: rec.id, ephemeral: true };
|
|
303
|
+
}
|
|
304
|
+
// --- run records --------------------------------------------------------
|
|
305
|
+
emitRun(job, trigger, status, message, ws) {
|
|
306
|
+
const run = {
|
|
307
|
+
id: randomUUID(),
|
|
308
|
+
jobId: job.id,
|
|
309
|
+
jobName: job.name,
|
|
310
|
+
ts: Date.now(),
|
|
311
|
+
status,
|
|
312
|
+
ws,
|
|
313
|
+
trigger,
|
|
314
|
+
message,
|
|
315
|
+
};
|
|
316
|
+
void this.store.appendRun(run);
|
|
317
|
+
this.broadcast(run);
|
|
318
|
+
return run;
|
|
319
|
+
}
|
|
320
|
+
recordFinal(started, status, message) {
|
|
321
|
+
const run = { ...started, id: randomUUID(), ts: Date.now(), status, message };
|
|
322
|
+
void this.store.appendRun(run);
|
|
323
|
+
this.broadcast(run);
|
|
324
|
+
const stored = this.store.get(started.jobId);
|
|
325
|
+
if (stored) {
|
|
326
|
+
stored.lastStatus = status;
|
|
327
|
+
void this.store.upsert(stored);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
// ---------------------------------------------------------------------------
|
|
332
|
+
/** Substitute `{{key}}` placeholders in a prompt from the event context. */
|
|
333
|
+
function renderPrompt(template, ctx) {
|
|
334
|
+
return template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_m, key) => key in ctx ? ctx[key] : `{{${key}}}`);
|
|
335
|
+
}
|
|
336
|
+
/** Throw on a malformed job so create/update reject before persisting. */
|
|
337
|
+
function validateInput(input) {
|
|
338
|
+
if (!input.prompt || !input.prompt.trim())
|
|
339
|
+
throw new Error("job prompt is empty");
|
|
340
|
+
const t = input.trigger;
|
|
341
|
+
if (t.kind === "schedule") {
|
|
342
|
+
validateCron(t);
|
|
343
|
+
}
|
|
344
|
+
else if (t.kind === "after") {
|
|
345
|
+
if (!Number.isFinite(t.delayMinutes) || t.delayMinutes < 0) {
|
|
346
|
+
throw new Error("delayMinutes must be a non-negative number");
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
else {
|
|
350
|
+
throw new Error("trigger.kind must be 'schedule' or 'after'");
|
|
351
|
+
}
|
|
352
|
+
const target = input.target;
|
|
353
|
+
if (target.kind === "ephemeral" && !target.repo && !target.github?.fullName) {
|
|
354
|
+
throw new Error("ephemeral target needs a repo path or a github source");
|
|
355
|
+
}
|
|
356
|
+
if (target.kind !== "workspace" && target.kind !== "ephemeral") {
|
|
357
|
+
throw new Error("target.kind must be 'workspace' or 'ephemeral'");
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
/** croner throws on a bad pattern; construct-and-stop to validate cheaply. */
|
|
361
|
+
function validateCron(t) {
|
|
362
|
+
try {
|
|
363
|
+
const c = new Cron(t.cron, { timezone: t.timezone, paused: true });
|
|
364
|
+
c.stop();
|
|
365
|
+
}
|
|
366
|
+
catch (err) {
|
|
367
|
+
throw new Error(`invalid cron expression: ${errText(err)}`);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
function errText(err) {
|
|
371
|
+
return (err instanceof Error ? err.message : String(err)).slice(0, 300);
|
|
372
|
+
}
|
|
373
|
+
//# sourceMappingURL=scheduler.js.map
|