@outcrawl/sdk 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.
@@ -0,0 +1,219 @@
1
+ /**
2
+ * The harness a customer names, and the models behind it that they never see.
3
+ *
4
+ * ── One name on the wire ────────────────────────────────────────────────────
5
+ *
6
+ * A customer asks for `Scout 1` or `Voyager 1`. That is the entire vocabulary:
7
+ * {@link AgentAlias} has two members, `AgentRequest.agent` accepts nothing else,
8
+ * and `AgentRequest.model` — which documented itself as "`'default'` for the
9
+ * model we host, or a BYOK model id" and was read by exactly one function whose
10
+ * only behaviour was to refuse it — is gone. The harness is the product name;
11
+ * which model runs underneath it is ours to change without telling anyone,
12
+ * including on the failover branch, mid-run.
13
+ *
14
+ * That is not a preference, it is the only shape that survives the arithmetic.
15
+ * A published per-minute price and a customer-chosen model cannot coexist: the
16
+ * customer picks the model, we absorb the difference, and the difference between
17
+ * the cheapest and the most expensive credible driver measured on one real
18
+ * production run is a factor of 126. So the customer picks the *capability* and
19
+ * we pick what delivers it.
20
+ *
21
+ * ── What the indirection buys, stated so nobody removes it ──────────────────
22
+ *
23
+ * If a model in a chain changes — a cheaper planner clears the Voyager bar, a
24
+ * preview slug is withdrawn, a rank is reordered — that is a ONE-LINE EDIT to
25
+ * {@link AGENT_MODEL_CHAINS} and nothing else moves: no wire type, no
26
+ * accept-list, no error string, no test fixture, no customer-visible byte, no
27
+ * migration. That property is the entire reason this file exists rather than a
28
+ * model id sitting on `AgentRequest`, and it is the thing somebody will be
29
+ * tempted to "simplify" away in six months. It is not indirection for its own
30
+ * sake; it is what makes the model a decision we can revisit weekly against a
31
+ * price we published once.
32
+ *
33
+ * ── Why this file is not `types.ts` ─────────────────────────────────────────
34
+ *
35
+ * `types.ts` is the wire contract — the thing every surface is checked against,
36
+ * with no dependency and no secret in it. {@link AGENT_MODEL_CHAINS} is the
37
+ * opposite: it is the one table in `@outcrawl/core` that must never reach a
38
+ * customer, and it drags a second concern (which vendor bills us, in what order)
39
+ * that has no business sitting beside `AgentResult`. Keeping it here means the
40
+ * audit question "can a slug reach a projector" has a one-file answer.
41
+ *
42
+ * Nothing in this file is serialized. `AgentAlias` crosses the wire; every other
43
+ * export is consumed by `@outcrawl/model`, which resolves a chain into clients,
44
+ * and by the cost gate, which prices it. No wire type, no projector and no error
45
+ * string reads a slug from here — which is a property that was audited across
46
+ * `Usage`, `aggregateCache`, `Meter.tokens`, `StepTrace`, the replay metadata and
47
+ * `webhookBody` before it was written down.
48
+ *
49
+ * ── `role` and `rank`, and why not one flat union ───────────────────────────
50
+ *
51
+ * A slot is what a model DOES ({@link AgentModelRole}) and where it sits in that
52
+ * role's failover order ({@link AgentModelChoice.rank}). The obvious alternative
53
+ * — a single union of `primary | failover | reasoner | actuator` — makes the two
54
+ * aliases two different data structures, because Scout is one role with three
55
+ * ranks and Voyager is two roles with one rank each, and a consumer then needs a
56
+ * branch per alias. Split this way both are the same list and the cost gate has
57
+ * exactly one formula:
58
+ *
59
+ * cost/min(alias) = SUM over roles ( MAX over ranks ( perCall x callsPerStep ) x steps/min )
60
+ *
61
+ * `MAX` within a role prices the failover branch, because only one rank is ever
62
+ * live at a time and the expensive branch is the one that must not go underwater
63
+ * unnoticed. `SUM` across roles prices Voyager's pair, because both run in the
64
+ * same run. Adding a third role forces that switch to be re-decided before the
65
+ * build goes green, which is the point.
66
+ *
67
+ * ── No prices here, and no measured numbers ─────────────────────────────────
68
+ *
69
+ * {@link AgentModelChoice} carries no `$/MTok` and no tokens-per-call. Prices
70
+ * live in the cost gate's committed OpenRouter snapshot, keyed by slug, and the
71
+ * seam is: **topology here, measurement there**. `callsPerStep` is on this side
72
+ * because it is a harness decision — how often our loop chooses to invoke a slot
73
+ * — and it moves only when the loop changes. Tokens per call is on the other
74
+ * side because it is measured off production and moves whenever traffic does.
75
+ * One source of truth each, and a weight in here would have forced an edit to
76
+ * this file every time somebody re-measured.
77
+ *
78
+ * Every slug below is a literal string constant. Not composed, not interpolated,
79
+ * not read from the environment — so the gate can enumerate them offline with no
80
+ * network and fail the build on any slug it has no price for. Adding a model
81
+ * therefore forces the cost calculation to be redone, which is the whole reason
82
+ * the gate exists.
83
+ */
84
+ /**
85
+ * The harness names, as they appear on the wire.
86
+ *
87
+ * Lower-case and hyphenated because this is a JSON string a customer types, and
88
+ * `Scout 1` is the name in the documentation. There is no `'default'` member:
89
+ * absence means {@link DEFAULT_AGENT_ALIAS}, and a third spelling of the same
90
+ * request is a third thing to keep consistent.
91
+ */
92
+ export type AgentAlias = 'scout-1' | 'voyager-1';
93
+ /**
94
+ * Every alias, for validation and for iterating the table.
95
+ *
96
+ * `satisfies` rather than a bare annotation, so the array is checked against the
97
+ * union in both directions: a member missing here does not compile, and a
98
+ * misspelled one does not either.
99
+ */
100
+ export declare const AGENT_ALIASES: readonly ["scout-1", "voyager-1"];
101
+ /**
102
+ * The harness a request gets when it names none.
103
+ *
104
+ * `scout-1`, because it is the cheap one. A default that silently selected the
105
+ * expensive pair would put an unasked-for bill on every caller who omitted a
106
+ * field, and the omission is the common case — the SDK, the CLI and every MCP
107
+ * client will reach this path far more often than they name a harness.
108
+ */
109
+ export declare const DEFAULT_AGENT_ALIAS: AgentAlias;
110
+ /** Whether a value is one of the two names. Narrows, for use at a boundary. */
111
+ export declare function isAgentAlias(value: unknown): value is AgentAlias;
112
+ /**
113
+ * The alias, or a throw naming the whole closed set.
114
+ *
115
+ * The message lists both names rather than saying "invalid", because a caller
116
+ * who guessed wrong has no documentation open and one round trip should teach
117
+ * them the entire vocabulary. `undefined` is NOT accepted here: absence is a
118
+ * decision the boundary makes with {@link DEFAULT_AGENT_ALIAS}, and folding it
119
+ * in would make `parseAgentAlias(undefined)` silently mean `scout-1` at call
120
+ * sites that meant to require a value.
121
+ */
122
+ export declare function parseAgentAlias(value: unknown): AgentAlias;
123
+ /**
124
+ * What a model does in a harness.
125
+ *
126
+ * `actuator` reads the page and emits the action: it receives the rendered
127
+ * accessibility tree and it runs on every step. `planner` decides where the run
128
+ * is going: it receives a distilled digest, never a tree, and it runs
129
+ * occasionally.
130
+ *
131
+ * The distinction is economic before it is architectural. Input dominates cost
132
+ * and input IS the snapshot — a measured 529,164 input tokens per minute on a
133
+ * content-heavy production run, which is the tree re-sent every step. A planner
134
+ * that saw that tree would cost 69x the per-minute price at frontier rates, so
135
+ * the pair only exists at all because the planner does not see it. Consumers may
136
+ * rely on that: the planner seam takes a digest type and has no tree parameter.
137
+ */
138
+ export type AgentModelRole = 'planner' | 'actuator';
139
+ /** One model, in one role, at one position in that role's failover order. */
140
+ export interface AgentModelChoice {
141
+ /**
142
+ * The provider's own id, exactly as it goes on the wire to them — so
143
+ * `vendor/slug` for OpenRouter. The same string keys the rate card and the
144
+ * action cache, and a second spelling anywhere silently misses both.
145
+ */
146
+ readonly slug: string;
147
+ readonly role: AgentModelRole;
148
+ /**
149
+ * `0` is the model this role uses. `1` and up are tried in ascending order
150
+ * when a call to a lower rank fails in a way that another model could survive.
151
+ *
152
+ * Rank is the ordering contract, not array position. Do not sort this list and
153
+ * do not infer order from it.
154
+ */
155
+ readonly rank: number;
156
+ /**
157
+ * How many times this slot is invoked per agent step, as a harness decision.
158
+ *
159
+ * `1` for a slot the loop calls every step. A fraction for one it calls
160
+ * periodically: `0.2` is once every five steps. This is topology and not a
161
+ * measurement — it changes when the loop changes, never when traffic does —
162
+ * which is why it lives here and why no token count or price does.
163
+ */
164
+ readonly callsPerStep: number;
165
+ }
166
+ /**
167
+ * Which models each harness runs, and in what order it falls back.
168
+ *
169
+ * ── scout-1 ─────────────────────────────────────────────────────────────────
170
+ *
171
+ * One actuator, three ranks. Mercury drives; the two Gemini Flash models exist
172
+ * so that a rate limit or a decommissioned preview slug does not end a run.
173
+ *
174
+ * The ranks are not equally affordable and that is a live risk rather than a
175
+ * footnote. On the measured content-heavy burn Mercury costs roughly 1.4x the
176
+ * per-minute price and `google/gemini-3-flash-preview` costs roughly 17x, so the
177
+ * failover branch is where this alias loses money. The cost gate prices
178
+ * max-over-ranks precisely so that this is visible, and every demotion is
179
+ * logged, because failover share is the number nobody measures yet.
180
+ *
181
+ * ── voyager-1 ───────────────────────────────────────────────────────────────
182
+ *
183
+ * A pair, run concurrently: a reasoner that plans and an actuator that acts. The
184
+ * planner is deliberately the most capable model on the board and deliberately
185
+ * the one that never sees a page — it reads a digest, at one call per five
186
+ * steps. That combination is what makes hours-long autonomy priceable at all,
187
+ * and it is still planner-dominated: the planner is roughly three quarters of
188
+ * this alias's cost per minute on a content-heavy run and nearly all of it on an
189
+ * app-shaped one.
190
+ *
191
+ * The actuator is the same slug as scout-1's rank 0, which is not a coincidence
192
+ * to be tidied away — turning a plan into a click is the same job in both
193
+ * harnesses, and the cheapest model that does it well should do it in both.
194
+ *
195
+ * Both voyager slugs are OpenRouter `vendor/slug` ids, so both resolve to one
196
+ * provider. That satisfies the constraint `modelConfigFrom` already enforces for
197
+ * `OUTCRAWL_EXTRACT_MODEL` — one client holds one provider — without loosening
198
+ * it: the pair is two clients over one shared ledger, one per slot.
199
+ */
200
+ export declare const AGENT_MODEL_CHAINS: Readonly<Record<AgentAlias, readonly AgentModelChoice[]>>;
201
+ /** One harness's slots. Pure; the typed accessor for {@link AGENT_MODEL_CHAINS}. */
202
+ export declare function agentModelChain(alias: AgentAlias): readonly AgentModelChoice[];
203
+ /**
204
+ * The slots for one role, in failover order, or an empty list when the harness
205
+ * has no model in that role.
206
+ *
207
+ * Sorted by {@link AgentModelChoice.rank} here rather than trusting the table's
208
+ * order, so the ordering contract is enforced in one function instead of relied
209
+ * upon at every reader.
210
+ */
211
+ export declare function agentModelsInRole(alias: AgentAlias, role: AgentModelRole): readonly AgentModelChoice[];
212
+ /**
213
+ * Every distinct slug any harness can reach.
214
+ *
215
+ * The cost gate's enumeration point: each of these must have a price in the
216
+ * committed snapshot or the build fails, so adding a model to a chain forces the
217
+ * per-minute cost to be recalculated. Sorted for a stable diff.
218
+ */
219
+ export declare function allAgentModelSlugs(): readonly string[];
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The brand icon set, as bytes.
3
+ *
4
+ * GENERATED by `node packages/browser/scripts/build-brand-icons.mjs` from
5
+ * `packages/browser/branding/logo.png` (512x512 RGBA). Do not hand-edit: run
6
+ * the script, which is also where the reasoning lives — why these sizes, why
7
+ * PNG payloads inside the .ico, why there is no SVG, and why the whole image
8
+ * pipeline is hand-written.
9
+ *
10
+ * WHY BYTES IN THE BUNDLE and not an R2 object, which was the other candidate.
11
+ * `/favicon.ico` is the single most requested path on any origin and the one a
12
+ * browser asks for without being told to. Fetching it from R2 puts a network
13
+ * call, an availability dependency and a cold-start latency spike underneath a
14
+ * request that must never fail — and the failure mode is a 404, which is the
15
+ * exact symptom this change exists to remove. Embedded, the answer is a slice
16
+ * of memory the isolate already holds: no round trip, no binding, nothing to be
17
+ * down, and correct on the first request after a deploy.
18
+ *
19
+ * The cost is bundle size, so it was measured rather than waved at:
20
+ * 28012 bytes of image data across 5 files
21
+ * — 27.4 KB against a 1 MB Worker limit. Decoded
22
+ * ONCE at module scope, not per request, so serving an icon allocates nothing
23
+ * beyond the `Response`.
24
+ */
25
+ /** 32x32 PNG — the desktop tab, and the ICO fallback. */
26
+ export declare const BRAND_ICON_32_BASE64: string;
27
+ /** @see BRAND_ICON_32_BASE64 */
28
+ export declare const BRAND_ICON_32: Uint8Array<ArrayBufferLike>;
29
+ /** 180x180 PNG — apple-touch-icon: iOS home screen, the size Apple reads. */
30
+ export declare const BRAND_ICON_180_BASE64: string;
31
+ /** @see BRAND_ICON_180_BASE64 */
32
+ export declare const BRAND_ICON_180: Uint8Array<ArrayBufferLike>;
33
+ /** 192x192 PNG — the web app manifest, Android home screen. */
34
+ export declare const BRAND_ICON_192_BASE64: string;
35
+ /** @see BRAND_ICON_192_BASE64 */
36
+ export declare const BRAND_ICON_192: Uint8Array<ArrayBufferLike>;
37
+ /** 512x512 PNG — the manifest splash entry, and the MCP client icon. */
38
+ export declare const BRAND_ICON_512_BASE64: string;
39
+ /** @see BRAND_ICON_512_BASE64 */
40
+ export declare const BRAND_ICON_512: Uint8Array<ArrayBufferLike>;
41
+ /**
42
+ * `favicon.ico`, carrying 16, 32, 48px PNG entries.
43
+ *
44
+ * Still shipped, because the path is hardcoded in the client: a browser
45
+ * requests `/favicon.ico` from the root of an origin whether or not any page
46
+ * mentions it. That request is the one that was 404ing.
47
+ */
48
+ export declare const BRAND_FAVICON_ICO_BASE64: string;
49
+ /** @see BRAND_FAVICON_ICO_BASE64 */
50
+ export declare const BRAND_FAVICON_ICO: Uint8Array<ArrayBufferLike>;
51
+ /**
52
+ * The 32px icon as a `data:` URI, for an HTML surface to inline.
53
+ *
54
+ * A URI and not a path, and the reason is an invariant rather than a
55
+ * preference: a replay is a frozen artefact, and a self-contained export of one
56
+ * is a single HTML file that may be opened from disk with no origin at all. A
57
+ * `<link rel=icon href=/favicon.ico>` in that file resolves against wherever
58
+ * it happens to be opened — a 404 at best, somebody else's icon at worst.
59
+ * Inline bytes cannot miss, cost no request, and are already covered by the
60
+ * player's `img-src 'self' blob: data:` without widening it.
61
+ */
62
+ export declare const BRAND_ICON_DATA_URI = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAMAAABEpIrGAAAAwFBMVEUAAABLG/NLG/hNG/pMG/VLG/U2API6BfI8CPI+C/JBDvJCEPJEEvNFE/NLG/RLG/RHFvNLG/NLG/NNHPlOHPxNHvNQIvNPHf9UJ/RYK/RgNvRnPvVtRfVvSfVxS/V5VfZ+XfaFZPeLbPeOb/eWeviehPihiPiljfmpkvmslvmvmvmxnPmzn/m2ovq6qPrBsfrIufvKvPvNwPvQw/vZz/zc0/zf1/zi2vzl3v3q5P3t6f328/749v75+P77+v7////JaF8sAAAAQHRSTlMABA8PQYL//////////9fm//r/////////////////////////////////////////////////////////////zvITygAAAaxJREFUeNqFU11v2kAQnL019hkMTS2gEkkeouT//5z2uSIPtJg0iQ3Gvo/tA8a20ot6T6ududnZ0R2BhFXKhI9HXO0dCZNE2fxfGCCeKeeJEN3AEQAwXIfxpRDGq2WV8QVH00YdofYMAOQVG8W6u3++e6g7/HFTXxhOs0ptZ4DM8sEwM5un3KiuZ1OeXk0nhd+4wtvz5tvPXXa1Q5T3tvm4bJGgoegwc8MyAwFc3a8i2N/PmRs106E8Pa4VoObxPpG+q0Z4ngNVBazyyxIAgGgQ81O47R7re5UeEFAAFF5/ab17+9Ac/J5hvdbeoqGQSYmcfr+9zdOjOrAER8h3nRMtkx8Ij4CbfoEIFvozAh9beA9T0ycEs9ob5rZYtQiZ5Gb+ZHen9+LrXVXHEgjK3WC9eMM6weIFoSRpAq814FVMQYIYkAAgtBI0qUqAiAioOERw0+IPecDTyyEdHgRrGrI+zmIv6vQ8Slo4HuT4XGcTqrdt1BPIsp+5q4bEdZU021PSD5BJSWo+6Rlg48CTAWdTsjitfK9BcQQZcJSOybc8eqMio+Da0hL97/v/BWW4vDuRYfz0AAAAAElFTkSuQmCC";
@@ -0,0 +1,100 @@
1
+ /**
2
+ * What a certificate says about itself, and the one function that reads it.
3
+ *
4
+ * WHY THIS IS IN `core` AND NOT BESIDE EITHER CALLER. Two packages must agree
5
+ * on this to the millisecond, for a reason that is the whole point of the
6
+ * feature:
7
+ *
8
+ * `@outcrawl/worker` parses the PEM bytes it is about to hand to
9
+ * `Bun.serve` and reports the result, so what it reports is provably the
10
+ * certificate ON THE WIRE.
11
+ *
12
+ * `@outcrawl/agent` parses the PEM on disk to decide whether to renew.
13
+ *
14
+ * Those two answers DIVERGE on purpose — a renewal that has landed on disk but
15
+ * whose worker has not been respawned yet shows a new file and an old wire —
16
+ * and that divergence is only legible if both sides derive their answer the
17
+ * same way. Two copies of "read notAfter out of a PEM" that disagreed by a
18
+ * timezone would make the interesting case unreadable.
19
+ *
20
+ * `node:crypto`'s `X509Certificate` does the parsing. It is in Bun and in
21
+ * workerd-free Node alike, and it means no ASN.1 parser here: this module
22
+ * consumes attacker-adjacent input (a PEM file), so unlike the DER *encoder*
23
+ * in `@outcrawl/agent`'s `acme.ts` it must not be hand-rolled.
24
+ */
25
+ /**
26
+ * The leaf's own claims. Deliberately small: every field here is something an
27
+ * operator reads in `admin fleet` or an alert quotes.
28
+ */
29
+ export interface CertificateFacts {
30
+ /** ISO 8601. The instant customers start failing handshakes. */
31
+ readonly notAfter: string;
32
+ readonly notBefore: string;
33
+ /** e.g. `CN=mac-01.fleet.outcrawl.ai`. */
34
+ readonly subject: string;
35
+ /**
36
+ * e.g. `C=US, O=Let's Encrypt, CN=R11`. The field that distinguishes a real
37
+ * certificate from a STAGING one, which is the mistake that looks correct
38
+ * from the machine and fails every client — see the same warning in
39
+ * `scripts/issue-fleet-cert.sh`.
40
+ */
41
+ readonly issuer: string;
42
+ /** Uppercase hex, no separators. Changes on every renewal, so it is the proof a renewal actually replaced anything. */
43
+ readonly serial: string;
44
+ /** DNS names this certificate is valid for, `subjectAltName` only. */
45
+ readonly dnsNames: readonly string[];
46
+ }
47
+ export declare class CertificateReadError extends Error {
48
+ readonly name = "CertificateReadError";
49
+ }
50
+ /**
51
+ * Reads the LEAF of a PEM chain.
52
+ *
53
+ * The leaf and not the whole chain: `fullchain.pem` starts with the leaf and
54
+ * continues with intermediates whose `notAfter` is years away, so a reader
55
+ * that took the last or the longest-lived block would report a comfortable
56
+ * date for an expired certificate. `X509Certificate` given a multi-block PEM
57
+ * parses the FIRST block, which is the leaf — asserted by the staging
58
+ * end-to-end run rather than assumed.
59
+ */
60
+ export declare function readCertificateFacts(pem: string): CertificateFacts;
61
+ /**
62
+ * Does this certificate cover `hostname`?
63
+ *
64
+ * Wildcard matching is included because the fleet is MID-MIGRATION: the live
65
+ * certificate is `*.fleet.outcrawl.ai` and the replacement is
66
+ * `mac-01.fleet.outcrawl.ai`, and during the cutover a machine must be able to
67
+ * recognise that the wildcard it already holds is still serving it and NOT
68
+ * treat that as "no usable certificate" and issue on every tick.
69
+ *
70
+ * One label only, and never the parent: `*.fleet.outcrawl.ai` matches
71
+ * `mac-01.fleet.outcrawl.ai` but not `a.b.fleet.outcrawl.ai` and not
72
+ * `fleet.outcrawl.ai`. That is RFC 6125 §6.4.3, and it is the reason
73
+ * `scripts/issue-fleet-cert.sh` orders the bare name alongside the wildcard.
74
+ */
75
+ export declare function certificateCovers(facts: CertificateFacts, hostname: string): boolean;
76
+ /**
77
+ * Whole days until `notAfter`, floored, and NEGATIVE once expired.
78
+ *
79
+ * Floored rather than rounded so "1 day left" never reads as 2, and signed
80
+ * rather than clamped at zero because "expired 3 days ago" and "expires in 3
81
+ * days" must not print identically in `admin fleet`.
82
+ */
83
+ export declare function daysUntilExpiry(notAfter: string, now: number): number;
84
+ /**
85
+ * Does this private key belong to this certificate?
86
+ *
87
+ * Compares the two SPKI encodings — the certificate's public key, and the
88
+ * public key derived from the private key. Equal DER means the pair matches.
89
+ *
90
+ * WORTH ITS OWN FUNCTION because a mismatched pair is a REAL failure class on
91
+ * this fleet, not a hypothetical. Today's certificate and key were issued on
92
+ * one machine and hand-copied to another, and a copy that picked up a new
93
+ * `fleet.crt` beside a stale `fleet.key` produces precisely the outage this
94
+ * whole ticket exists to prevent: the worker refuses to bind, or binds and
95
+ * fails every handshake, while the agent keeps dialling out and the fleet
96
+ * reports the machine healthy. Renewal writes two files, so it can introduce
97
+ * the same mismatch if it is ever interrupted between them — this is the check
98
+ * that makes that detectable instead of silent.
99
+ */
100
+ export declare function certificateMatchesPrivateKey(certificatePem: string, privateKeyPem: string): boolean;
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Five-field cron expressions, and the only question anything asks of one:
3
+ * when is this next due?
4
+ *
5
+ * This lives in core because three packages need the same answer and any second
6
+ * implementation is a disagreement waiting to happen. The API refuses an
7
+ * unparseable schedule at `monitors.create`, the worker's scheduler computes the
8
+ * next run after each check, and the server's control plane clamps what a worker
9
+ * reports. If those three parsed cron differently, a monitor could be accepted
10
+ * by one, never selected by another, and rejected by the third — the silent
11
+ * never-fires failure the monitors work exists to end.
12
+ *
13
+ * **Everything is UTC.** `MonitorSpec.schedule` has no timezone field, the
14
+ * monitors table has no timezone column and the API takes no timezone
15
+ * parameter, so there is no tenant-local zone to resolve against. Inventing one
16
+ * here would be inventing a product feature in a parser, and the customer would
17
+ * have no way to see or set it. UTC is therefore explicit in every signature and
18
+ * every returned instant rather than left to whatever `TZ` the process booted
19
+ * with — a scheduler and a control plane that disagree about local midnight are
20
+ * the same class of bug as two cron parsers.
21
+ *
22
+ * No clock is read here. The instant is always a parameter, for the reason
23
+ * `packages/persistence/src/lease.ts` gives: the clock that matters belongs to
24
+ * the caller — the database's `now()` for a real decision, a fixed instant for a
25
+ * test or a replay.
26
+ *
27
+ * No dependencies and no `node:` imports: this runs on workerd as well as Bun.
28
+ */
29
+ /**
30
+ * How many candidate days `nextRunAfter` examines before returning `null`.
31
+ *
32
+ * The bound is the point of the function. `0 0 30 2 *` is well-formed and can
33
+ * never match, and a scheduler that scanned for it forever would hang the
34
+ * process holding the lease — strictly worse than one monitor that never fires.
35
+ *
36
+ * Ten years, not the four you would guess. The longest legitimate gap between
37
+ * consecutive matches of any well-formed expression is `0 0 29 2 *`, and leap
38
+ * days are eight years apart across a skipped century — 2096-02-29 to
39
+ * 2104-02-29, because 2100 is not a leap year. A five-year horizon would report
40
+ * `null` for a valid expression, which is the silent-failure mode this whole
41
+ * bound exists to avoid. Ten years clears the worst real gap with room, and
42
+ * nothing that fails to match inside it can match at all.
43
+ *
44
+ * The cost of the bound is a loop over days, not minutes: 3660 iterations of
45
+ * three membership tests, which is microseconds.
46
+ */
47
+ export declare const CRON_HORIZON_DAYS = 3660;
48
+ /** Thrown by {@link parseCron}. Never thrown for a well-formed expression. */
49
+ export declare class CronSyntaxError extends Error {
50
+ /**
51
+ * The offending field — `minute`, `hour`, `day-of-month`, `month`,
52
+ * `day-of-week` — or `expression` when the fault is the expression as a whole.
53
+ * Callers turning this into a 400 can point the customer at one token.
54
+ */
55
+ readonly field: string;
56
+ constructor(field: string, message: string);
57
+ }
58
+ /**
59
+ * A parsed expression: every field expanded to the exact set of values it
60
+ * matches, sorted ascending, with no duplicates.
61
+ *
62
+ * Expanded rather than kept as syntax because matching a day then becomes three
63
+ * membership tests, and because two expressions that mean the same thing —
64
+ * `@daily` and `0 0 * * *`, `SUN` and `0` and `7` — become the same object.
65
+ */
66
+ export interface CronSchedule {
67
+ /** 0-59. */
68
+ readonly minutes: readonly number[];
69
+ /** 0-23. */
70
+ readonly hours: readonly number[];
71
+ /** 1-31. Values above the length of a given month simply never match it. */
72
+ readonly daysOfMonth: readonly number[];
73
+ /** 1-12, January = 1. */
74
+ readonly months: readonly number[];
75
+ /** 0-6, Sunday = 0. A `7` in the expression is folded to `0` here. */
76
+ readonly daysOfWeek: readonly number[];
77
+ /**
78
+ * The day-of-month field did not begin with `*`. Together with
79
+ * {@link daysOfWeekRestricted} this decides whether the two day fields are
80
+ * combined with OR or with AND — see {@link nextRunAfter}.
81
+ */
82
+ readonly daysOfMonthRestricted: boolean;
83
+ /** The day-of-week field did not begin with `*`. */
84
+ readonly daysOfWeekRestricted: boolean;
85
+ /**
86
+ * The five fields as parsed, single-space separated, macros expanded. What
87
+ * `@daily` actually meant, in a form you can log or show back to a customer.
88
+ */
89
+ readonly expression: string;
90
+ }
91
+ /**
92
+ * Parses a five-field expression, or one of the `@` macros.
93
+ *
94
+ * Throws {@link CronSyntaxError} naming the offending field, what was received
95
+ * and what is allowed. The message reaches a customer through the API's 400, so
96
+ * "invalid cron" would be useless: they cannot fix a field they cannot identify.
97
+ */
98
+ export declare function parseCron(expression: string): CronSchedule;
99
+ /** True when {@link parseCron} would accept the expression. */
100
+ export declare function isValidCron(expression: string): boolean;
101
+ /**
102
+ * The first instant matching `expression` strictly after `after`, in UTC, with
103
+ * seconds and milliseconds zeroed. `null` when nothing matches within
104
+ * {@link CRON_HORIZON_DAYS}.
105
+ *
106
+ * **Strictly after**, and **truncated to the minute**, both for the same
107
+ * consumer. A cron expression's resolution is one minute, so a scheduler that
108
+ * stored a result equal to the instant it asked about would select the same
109
+ * check on the next tick and every tick after: an infinite loop that re-runs one
110
+ * monitor and starves the rest. `nextRunAfter(s, t)` is always greater than `t`.
111
+ *
112
+ * The search walks candidate *days* and only then matches hours and minutes.
113
+ * Minute-by-minute would be ~5.3 million iterations per call for the
114
+ * never-matching case, once per monitor per check.
115
+ */
116
+ export declare function nextRunAfter(expression: string | CronSchedule, after: Date): Date | null;