@alma-harness/schedule 0.3.0 → 0.5.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/README.md +8 -1
- package/dist/index.d.ts +43 -3
- package/dist/index.js +163 -0
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ The clock of [Alma](https://github.com/FabioFernandesCarneiro/alma), as a
|
|
|
4
4
|
tick: what is due, and the call an external scheduler makes to run it. No
|
|
5
5
|
process is kept alive (spec: clock-tick).
|
|
6
6
|
|
|
7
|
-
> **Status: 0.
|
|
7
|
+
> **Status: 0.4.0 on npm, pre-1.0.** The API is still moving; see the
|
|
8
8
|
> [roadmap](../../docs/architecture.md#12-adoption-roadmap) for where it stands.
|
|
9
9
|
|
|
10
10
|
## What it owns
|
|
@@ -27,6 +27,13 @@ process is kept alive (spec: clock-tick).
|
|
|
27
27
|
every tick. Missed fires are not replayed: after downtime a daily routine
|
|
28
28
|
runs once.
|
|
29
29
|
- `latestFire`, `validateSchedule` — the pure halves, exported.
|
|
30
|
+
- `createRoutineRunner({ agent, jobs?, sinks, runs })` — the runner the tick
|
|
31
|
+
calls (§8, spec: routine-runner; here since spec: routines-with-the-clock):
|
|
32
|
+
a `Routine` runs as a triggered turn (its profile or no tools, the goal as
|
|
33
|
+
input, a fresh session per run) or as one job in batch (submitted on one
|
|
34
|
+
fire, collected on the next), refused before any model call at its daily
|
|
35
|
+
ceiling, delivered to the named `OutputSink` once per distinct text, and
|
|
36
|
+
recorded as metadata. It schedules nothing; the tick does.
|
|
30
37
|
|
|
31
38
|
## Wiring
|
|
32
39
|
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,45 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { Routine, RoutineRun, SinkRef, OutputSink, RoutineRunStore, StoredRoutine, Scope, RoutineStore, Schedule } from '@alma-harness/core';
|
|
2
|
+
import { Agent, JobRunner } from '@alma-harness/loop';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The routine runner — §8, spec: routine-runner; lives with the clock since
|
|
6
|
+
* spec: routines-with-the-clock. Takes a `Routine` (data)
|
|
7
|
+
* when a trigger fires and runs it as the loop's triggered turn or as one
|
|
8
|
+
* job, under the routine's own per-run cap and its daily ceiling; delivers
|
|
9
|
+
* the result to the named sink once per distinct text; records every run
|
|
10
|
+
* as metadata. It schedules nothing, holds no registry and stores no text:
|
|
11
|
+
* the trigger seam, the product and the session store own those.
|
|
12
|
+
*/
|
|
13
|
+
interface RoutineRunnerConfig {
|
|
14
|
+
/** Runs turn routines. Everything it can do is fixed at its construction. */
|
|
15
|
+
agent: Agent;
|
|
16
|
+
/** Runs job routines; absent, a job routine is refused at validation. */
|
|
17
|
+
jobs?: JobRunner;
|
|
18
|
+
/** Product-provided destinations, by the name a routine's `outputSink` uses. */
|
|
19
|
+
sinks: Record<SinkRef, OutputSink>;
|
|
20
|
+
runs: RoutineRunStore;
|
|
21
|
+
/** Ceiling on runs per routine per UTC day when the routine names none. Default 20. */
|
|
22
|
+
maxRunsPerDay?: number;
|
|
23
|
+
/** Injected clock (ISO 8601) — tests stay deterministic. */
|
|
24
|
+
now?: () => string;
|
|
25
|
+
}
|
|
26
|
+
interface RunRoutineOptions {
|
|
27
|
+
/**
|
|
28
|
+
* The fire's identity — a schedule instant, a queue message id. The same
|
|
29
|
+
* id twice is ONE run: the record is returned and nothing runs. Default: a
|
|
30
|
+
* fresh UUID, for a trigger that has no natural id.
|
|
31
|
+
*/
|
|
32
|
+
runId?: string;
|
|
33
|
+
/** ISO 8601 of the fire. Default: now. */
|
|
34
|
+
at?: string;
|
|
35
|
+
signal?: AbortSignal;
|
|
36
|
+
}
|
|
37
|
+
interface RoutineRunner {
|
|
38
|
+
/** Refuses a routine this runner cannot run — before, not during, a fire. */
|
|
39
|
+
validate(routine: Routine): void;
|
|
40
|
+
run(routine: Routine, opts?: RunRoutineOptions): Promise<RoutineRun>;
|
|
41
|
+
}
|
|
42
|
+
declare function createRoutineRunner(config: RoutineRunnerConfig): RoutineRunner;
|
|
3
43
|
|
|
4
44
|
/**
|
|
5
45
|
* What is due, and the tick that runs it — spec: clock-tick, hardened by
|
|
@@ -71,4 +111,4 @@ declare function validateSchedule(schedule: Schedule): void;
|
|
|
71
111
|
declare function latestFire(schedule: Schedule, registeredAt: string, nowIso: string): string | undefined;
|
|
72
112
|
declare function createSchedule(config: ScheduleConfig): RoutineSchedule;
|
|
73
113
|
|
|
74
|
-
export { type DueFire, type RoutineSchedule, type ScheduleConfig, type ScheduleError, type TickPlan, type TickReport, createSchedule, latestFire, validateSchedule };
|
|
114
|
+
export { type DueFire, type RoutineRunner, type RoutineRunnerConfig, type RoutineSchedule, type RunRoutineOptions, type ScheduleConfig, type ScheduleError, type TickPlan, type TickReport, createRoutineRunner, createSchedule, latestFire, validateSchedule };
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,165 @@
|
|
|
1
|
+
// src/routines.ts
|
|
2
|
+
import {
|
|
3
|
+
BudgetExceededError,
|
|
4
|
+
scopePath
|
|
5
|
+
} from "@alma-harness/core";
|
|
6
|
+
import { AgentConfigError } from "@alma-harness/loop";
|
|
7
|
+
var DEFAULT_MAX_RUNS_PER_DAY = 20;
|
|
8
|
+
function utcDayStart(at) {
|
|
9
|
+
const ms = Date.parse(at);
|
|
10
|
+
if (Number.isNaN(ms)) throw new AgentConfigError(`run.at is not an ISO 8601 timestamp: ${JSON.stringify(at)}`);
|
|
11
|
+
return `${new Date(ms).toISOString().slice(0, 10)}T00:00:00.000Z`;
|
|
12
|
+
}
|
|
13
|
+
function textOf(msg) {
|
|
14
|
+
return msg.blocks.filter((b) => b.type === "text" && b.origin !== "harness").map((b) => b.text).join("\n").trim();
|
|
15
|
+
}
|
|
16
|
+
async function hashOf(text) {
|
|
17
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
|
|
18
|
+
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
19
|
+
}
|
|
20
|
+
function createRoutineRunner(config) {
|
|
21
|
+
const { agent, jobs, sinks, runs } = config;
|
|
22
|
+
const defaultCeiling = config.maxRunsPerDay ?? DEFAULT_MAX_RUNS_PER_DAY;
|
|
23
|
+
const now = config.now ?? (() => (/* @__PURE__ */ new Date()).toISOString());
|
|
24
|
+
function validate(routine) {
|
|
25
|
+
scopePath(routine.scope);
|
|
26
|
+
const where = `routine ${JSON.stringify(routine.id)}`;
|
|
27
|
+
if (!Object.hasOwn(sinks, routine.outputSink)) {
|
|
28
|
+
throw new AgentConfigError(`${where} names sink ${JSON.stringify(routine.outputSink)}, which this runner does not have \u2014 a result needs a declared destination (\xA78)`);
|
|
29
|
+
}
|
|
30
|
+
const cap = routine.budget.perTurnUsd;
|
|
31
|
+
if (cap === void 0 || !Number.isFinite(cap) || cap < 0) {
|
|
32
|
+
throw new AgentConfigError(`${where} has no finite per-run cap (budget.perTurnUsd) \u2014 nobody is watching an unattended run (\xA78)`);
|
|
33
|
+
}
|
|
34
|
+
if (routine.budget.perSessionUsd !== void 0 || routine.budget.perTenantDayUsd !== void 0) {
|
|
35
|
+
throw new AgentConfigError(`${where} names a persistent cap \u2014 those are the agent's, and already apply to every run (spec: routine-runner)`);
|
|
36
|
+
}
|
|
37
|
+
if ((routine.execution ?? "turn") === "job") {
|
|
38
|
+
if (!jobs) throw new AgentConfigError(`${where} is a job routine, and this runner has no job runner`);
|
|
39
|
+
if (routine.toolProfile !== void 0) throw new AgentConfigError(`${where} is a job routine with a tool profile \u2014 a job has no tools (spec: model-jobs)`);
|
|
40
|
+
}
|
|
41
|
+
const ceiling = routine.maxRunsPerDay;
|
|
42
|
+
if (ceiling !== void 0 && (!Number.isInteger(ceiling) || ceiling < 0)) {
|
|
43
|
+
throw new AgentConfigError(`${where} has a malformed maxRunsPerDay: ${ceiling}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
async function deliver(routine, run, reply) {
|
|
47
|
+
const text = textOf(reply);
|
|
48
|
+
if (text === "") return { ...run, outcome: "failed", reason: "the reply carried no text" };
|
|
49
|
+
const hash = await hashOf(text);
|
|
50
|
+
const [last] = await runs.list(routine.scope, routine.id, { outcome: "delivered", limit: 1 });
|
|
51
|
+
if (last?.deliveryHash === hash) return { ...run, outcome: "duplicate", deliveryHash: hash };
|
|
52
|
+
try {
|
|
53
|
+
await sinks[routine.outputSink].deliver({
|
|
54
|
+
routineId: routine.id,
|
|
55
|
+
scope: routine.scope,
|
|
56
|
+
runId: run.id,
|
|
57
|
+
at: now(),
|
|
58
|
+
reply,
|
|
59
|
+
text,
|
|
60
|
+
hash,
|
|
61
|
+
costUsd: run.costUsd
|
|
62
|
+
});
|
|
63
|
+
} catch (err) {
|
|
64
|
+
return { ...run, outcome: "failed", reason: `delivery to ${JSON.stringify(routine.outputSink)} failed: ${err instanceof Error ? err.message : String(err)}`, deliveryHash: hash };
|
|
65
|
+
}
|
|
66
|
+
return { ...run, outcome: "delivered", deliveryHash: hash };
|
|
67
|
+
}
|
|
68
|
+
async function runTurn(routine, run, signal) {
|
|
69
|
+
const sessionId = `routine/${routine.id}/${run.id}`;
|
|
70
|
+
const result = await agent.runTurn({
|
|
71
|
+
scope: routine.scope,
|
|
72
|
+
sessionId,
|
|
73
|
+
input: { role: "user", blocks: [{ type: "text", text: routine.goal }], meta: { at: run.startedAt } },
|
|
74
|
+
intent: routine.intent,
|
|
75
|
+
trigger: "routine",
|
|
76
|
+
...routine.toolProfile !== void 0 ? { toolProfile: routine.toolProfile } : {},
|
|
77
|
+
perTurnUsd: routine.budget.perTurnUsd,
|
|
78
|
+
idempotencyKey: run.id,
|
|
79
|
+
...signal ? { signal } : {}
|
|
80
|
+
});
|
|
81
|
+
const base = { ...run, sessionId, turnId: result.turnId, costUsd: result.costUsd };
|
|
82
|
+
if (result.terminalReason === "completed") return deliver(routine, base, result.reply);
|
|
83
|
+
if (result.terminalReason === "budget_exceeded" && result.steps === 0) {
|
|
84
|
+
return { ...base, outcome: "refused", reason: `cap ${result.budgetExceeded?.cap} refused the run at preflight` };
|
|
85
|
+
}
|
|
86
|
+
const detail = result.terminalReason === "budget_exceeded" ? `cap ${result.budgetExceeded?.cap} ($${result.budgetExceeded?.capUsd}) stopped the turn at $${result.budgetExceeded?.spentUsd}` : result.failure !== void 0 ? `${result.failure.kind}${result.failure.retryable ? " (retryable)" : ""}: ${result.failure.message}` : result.error ?? result.terminalReason;
|
|
87
|
+
return { ...base, outcome: "failed", reason: `turn ended ${result.terminalReason}: ${detail}` };
|
|
88
|
+
}
|
|
89
|
+
async function openSubmission(routine) {
|
|
90
|
+
if ((routine.execution ?? "turn") !== "job") return void 0;
|
|
91
|
+
const [pending] = await runs.list(routine.scope, routine.id, { outcome: "submitted", limit: 1 });
|
|
92
|
+
return pending?.handle !== void 0 && pending.sessionId !== void 0 ? pending : void 0;
|
|
93
|
+
}
|
|
94
|
+
async function collect(routine, pending) {
|
|
95
|
+
{
|
|
96
|
+
const progress = await jobs.status(pending.handle);
|
|
97
|
+
if (progress.status === "queued" || progress.status === "running") return { ...pending, outcome: "waiting" };
|
|
98
|
+
const settled = { ...pending, finishedAt: now() };
|
|
99
|
+
if (progress.status !== "done") {
|
|
100
|
+
return { ...settled, outcome: "failed", reason: `the provider reports the batch ${progress.status}` };
|
|
101
|
+
}
|
|
102
|
+
const collected = await jobs.collect(pending.handle, { scope: routine.scope, sessionId: pending.sessionId });
|
|
103
|
+
const withCost = { ...settled, costUsd: collected.costUsd };
|
|
104
|
+
const item = collected.results.find((r) => r.id === pending.id);
|
|
105
|
+
if (!item) return { ...withCost, outcome: "failed", reason: "the provider returned no result for this run \u2014 provider drift?" };
|
|
106
|
+
if (item.outcome !== "succeeded" || !item.reply) {
|
|
107
|
+
return { ...withCost, outcome: "failed", reason: `the item ${item.outcome}${item.error ? `: ${item.error}` : ""}` };
|
|
108
|
+
}
|
|
109
|
+
if (collected.costUsd > routine.budget.perTurnUsd) {
|
|
110
|
+
return { ...withCost, outcome: "failed", reason: `the collected result cost $${collected.costUsd}, over the run's cap of $${routine.budget.perTurnUsd}` };
|
|
111
|
+
}
|
|
112
|
+
return deliver(routine, withCost, item.reply);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
async function runJob(routine, run) {
|
|
116
|
+
const sessionId = `routine/${routine.id}/${run.id}`;
|
|
117
|
+
try {
|
|
118
|
+
const submitted = await jobs.submit({
|
|
119
|
+
scope: routine.scope,
|
|
120
|
+
sessionId,
|
|
121
|
+
intent: routine.intent,
|
|
122
|
+
items: [{ id: run.id, messages: [{ role: "user", blocks: [{ type: "text", text: routine.goal }], meta: { at: run.startedAt } }] }]
|
|
123
|
+
});
|
|
124
|
+
return { ...run, outcome: "submitted", sessionId, handle: submitted.handle };
|
|
125
|
+
} catch (err) {
|
|
126
|
+
if (err instanceof BudgetExceededError) return { ...run, outcome: "refused", reason: `cap ${err.cap} refused the run at preflight` };
|
|
127
|
+
throw err;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
validate,
|
|
132
|
+
async run(routine, opts = {}) {
|
|
133
|
+
validate(routine);
|
|
134
|
+
const at = opts.at ?? now();
|
|
135
|
+
const id = opts.runId ?? crypto.randomUUID();
|
|
136
|
+
const seen = await runs.get(routine.scope, routine.id, id);
|
|
137
|
+
if (seen) return seen;
|
|
138
|
+
const pending = await openSubmission(routine);
|
|
139
|
+
if (pending !== void 0) {
|
|
140
|
+
const done2 = await collect(routine, pending);
|
|
141
|
+
if (done2.outcome === "waiting") return done2;
|
|
142
|
+
const final2 = { ...done2, finishedAt: done2.finishedAt ?? now() };
|
|
143
|
+
await runs.record(final2);
|
|
144
|
+
return final2;
|
|
145
|
+
}
|
|
146
|
+
const run = { id, routineId: routine.id, scope: routine.scope, startedAt: at, outcome: "failed", costUsd: 0 };
|
|
147
|
+
const ceiling = routine.maxRunsPerDay ?? defaultCeiling;
|
|
148
|
+
const today = await runs.list(routine.scope, routine.id, { since: utcDayStart(at) });
|
|
149
|
+
if (today.length >= ceiling) {
|
|
150
|
+
const refused = { ...run, finishedAt: at, outcome: "refused", reason: `${today.length} runs today, at the ceiling of ${ceiling}` };
|
|
151
|
+
await runs.record(refused);
|
|
152
|
+
return refused;
|
|
153
|
+
}
|
|
154
|
+
const done = (routine.execution ?? "turn") === "job" ? await runJob(routine, run) : await runTurn(routine, run, opts.signal);
|
|
155
|
+
if (done.outcome === "waiting") return done;
|
|
156
|
+
const final = done.outcome === "submitted" ? done : { ...done, finishedAt: done.finishedAt ?? now() };
|
|
157
|
+
await runs.record(final);
|
|
158
|
+
return final;
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
1
163
|
// src/schedule.ts
|
|
2
164
|
import {
|
|
3
165
|
assertIso8601,
|
|
@@ -136,6 +298,7 @@ function createSchedule(config) {
|
|
|
136
298
|
};
|
|
137
299
|
}
|
|
138
300
|
export {
|
|
301
|
+
createRoutineRunner,
|
|
139
302
|
createSchedule,
|
|
140
303
|
latestFire,
|
|
141
304
|
validateSchedule
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/schedule.ts"],"sourcesContent":["import {\n assertIso8601,\n parseIso8601,\n type Duration,\n type Routine,\n type RoutineRun,\n type RoutineRunStore,\n type RoutineStore,\n type Schedule as ScheduleSpec,\n type Scope,\n type StoredRoutine,\n} from \"@alma-harness/core\";\nimport type { RoutineRunner } from \"@alma-harness/loop\";\nimport { Cron } from \"croner\";\n\n/**\n * What is due, and the tick that runs it — spec: clock-tick, hardened by\n * spec: close-060-064-findings.\n *\n * The scheduler knows one thing: call `tick` every minute or five. The tick\n * knows everything else from the stores, so a tick retried, doubled or\n * missed does no harm: the FIRE's instant is the run id, and the runner\n * treats the same id twice as one run (spec: routine-runner).\n */\n\nexport interface ScheduleConfig {\n routines: RoutineStore;\n runs: RoutineRunStore;\n /**\n * One runner, or one per routine: a deployment with an agent per tenant\n * resolves it from the routine's scope, since `list` crosses every scope.\n */\n runner: RoutineRunner | ((routine: Routine) => RoutineRunner);\n /** Injected clock (ISO 8601) — tests stay deterministic. */\n now?: () => string;\n /** Per-routine store reads in flight at once. Default 8. */\n concurrency?: number;\n}\n\nexport interface DueFire {\n routine: StoredRoutine;\n /** The instant the run is named by: the fire, or the tick's own instant for a pending collection. */\n at: string;\n /** `fire`: the schedule came due. `pending`: a job routine's submission is still open and is collected now. */\n reason: \"fire\" | \"pending\";\n}\n\nexport interface ScheduleError {\n routineId: string;\n scope: Scope;\n message: string;\n /** `plan`: the routine's schedule could not be read. `run`: the runner threw. The others were not affected. */\n stage: \"plan\" | \"run\";\n}\n\nexport interface TickPlan {\n at: string;\n due: DueFire[];\n errors: ScheduleError[];\n}\n\nexport interface TickReport extends TickPlan {\n runs: RoutineRun[];\n}\n\nexport interface RoutineSchedule {\n /**\n * Registers with the store AFTER the runner and the schedule have refused\n * what they cannot run — at registration, not at 8h with nobody watching.\n */\n register(routine: Routine & { registeredAt?: string }): Promise<void>;\n cancel(scope: Scope, routineId: string): Promise<void>;\n /** What would run at `now`, without running it, and which routines could not even be planned. */\n plan(now?: string): Promise<TickPlan>;\n /** Runs what is due, one routine after another, and reports. Never throws for a routine's sake. */\n tick(opts?: { now?: string }): Promise<TickReport>;\n}\n\nconst UNITS: Record<\"s\" | \"m\" | \"h\" | \"d\", number> = { s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 };\n\n/** A compact duration (\"90s\", \"15m\", \"2h\", \"1d\") in milliseconds; zero is refused — it would fire NaN. */\nexport function durationMs(duration: Duration): number {\n const match = /^(\\d+)([smhd])$/.exec(duration);\n const ms = match === null ? 0 : Number(match[1]) * UNITS[match[2] as keyof typeof UNITS];\n if (ms <= 0) throw new Error(`malformed duration: ${JSON.stringify(duration)}`);\n return ms;\n}\n\n/**\n * UTC unless the routine says where it lives: croner would otherwise read\n * the cron in the PROCESS's zone, and a fire would move with the deployment.\n * Constructed without a callback, so nothing is scheduled.\n */\nfunction cronOf(expression: string, tz: string | undefined): Cron {\n return new Cron(expression, { timezone: tz ?? \"UTC\" });\n}\n\n/** Refuses a schedule the tick could not read — the same errors croner or the parser would raise mid-tick. */\nexport function validateSchedule(schedule: ScheduleSpec): void {\n if (\"at\" in schedule) {\n assertIso8601(schedule.at, \"schedule.at\");\n } else if (\"every\" in schedule) {\n durationMs(schedule.every);\n } else {\n // A bad zone surfaces only on the first computation, not at construction.\n cronOf(schedule.cron, schedule.tz).nextRun(new Date());\n }\n}\n\n/** The first fire strictly after `afterMs`, or nothing. */\nfunction nextFire(schedule: ScheduleSpec, registered: number, afterMs: number): number | undefined {\n if (\"at\" in schedule) {\n const at = parseIso8601(schedule.at, \"schedule.at\");\n return at > afterMs ? at : undefined;\n }\n if (\"every\" in schedule) {\n const step = durationMs(schedule.every);\n const k = Math.max(1, Math.floor((afterMs - registered) / step) + 1);\n return registered + k * step;\n }\n const next = cronOf(schedule.cron, schedule.tz).nextRun(new Date(afterMs));\n return next === null ? undefined : next.getTime();\n}\n\n/**\n * The latest occurrence of a cron at or before `now`. Windows widen until\n * one holds an occurrence, then the window is walked forward to the last\n * one — bounded, because a window is only walked when the cron fits it,\n * and only reached once `nextFire` said something may be due.\n */\nfunction latestCron(expression: string, tz: string | undefined, now: number): number | undefined {\n const cron = cronOf(expression, tz);\n for (const window of [2 * UNITS.m, 2 * UNITS.h, 2 * UNITS.d, 40 * UNITS.d, 400 * UNITS.d]) {\n let at = cron.nextRun(new Date(now - window));\n if (at === null || at.getTime() > now) continue;\n for (;;) {\n const next = cron.nextRun(at);\n if (next === null || next.getTime() > now) return at.getTime();\n at = next;\n }\n }\n return undefined;\n}\n\n/**\n * The latest fire of a schedule at or before `now`, as an ISO instant — or\n * nothing: before the first fire, or a fire the routine never saw (earlier\n * than its registration). `{ every }` anchors on the registration stamp.\n */\nexport function latestFire(schedule: ScheduleSpec, registeredAt: string, nowIso: string): string | undefined {\n const now = parseIso8601(nowIso, \"now\");\n const registered = parseIso8601(registeredAt, \"registeredAt\");\n let fire: number | undefined;\n if (\"at\" in schedule) {\n const at = parseIso8601(schedule.at, \"schedule.at\");\n fire = at <= now ? at : undefined;\n } else if (\"every\" in schedule) {\n const step = durationMs(schedule.every);\n fire = now - registered >= step ? registered + Math.floor((now - registered) / step) * step : undefined;\n } else {\n fire = latestCron(schedule.cron, schedule.tz, now);\n }\n if (fire === undefined || fire < registered) return undefined;\n return new Date(fire).toISOString();\n}\n\n/** `fn` over `items`, at most `limit` in flight — the per-routine reads of a tick (spec: close-060-064-findings). */\nasync function mapConcurrent<T, R>(items: readonly T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {\n const out: R[] = new Array<R>(items.length);\n let next = 0;\n const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {\n for (;;) {\n const i = next++;\n if (i >= items.length) return;\n out[i] = await fn(items[i]!);\n }\n });\n await Promise.all(workers);\n return out;\n}\n\nexport function createSchedule(config: ScheduleConfig): RoutineSchedule {\n const { routines, runs } = config;\n const runnerFor = typeof config.runner === \"function\" ? config.runner : () => config.runner as RoutineRunner;\n const clock = config.now ?? (() => new Date().toISOString());\n const concurrency = config.concurrency ?? 8;\n const message = (err: unknown): string => (err instanceof Error ? err.message : String(err));\n\n /** One routine's share of a tick: a fire, nothing, or the error that kept it from being planned. */\n async function planOne(routine: StoredRoutine, nowIso: string, now: number): Promise<{ fire?: DueFire; error?: ScheduleError }> {\n try {\n // A submission still open is collected on EVERY tick, not on the next\n // cron occurrence (spec: model-jobs) — asked the way the runner asks it.\n const [pending] = await runs.list(routine.scope, routine.id, { outcome: \"submitted\", limit: 1 });\n if (pending !== undefined) return { fire: { routine, at: nowIso, reason: \"pending\" } };\n const [last] = await runs.list(routine.scope, routine.id, { limit: 1 });\n const registered = parseIso8601(routine.registeredAt, \"registeredAt\");\n // The common case answered in one step: is there any fire after the\n // later of the registration and the last run, at or before now?\n const after = last === undefined ? registered - 1 : Math.max(registered - 1, parseIso8601(last.startedAt, \"startedAt\"));\n const next = nextFire(routine.schedule, registered, after);\n if (next === undefined || next > now) return {};\n const fire = latestFire(routine.schedule, routine.registeredAt, nowIso);\n // Refusals are runs too: a routine past its ceiling is not re-fired every minute.\n if (fire === undefined || (last !== undefined && parseIso8601(fire) <= parseIso8601(last.startedAt))) return {};\n return { fire: { routine, at: fire, reason: \"fire\" } };\n } catch (err) {\n return { error: { routineId: routine.id, scope: routine.scope, message: message(err), stage: \"plan\" } };\n }\n }\n\n async function plan(nowIso: string = clock()): Promise<TickPlan> {\n const now = parseIso8601(nowIso, \"now\");\n const plans = await mapConcurrent(await routines.list(), concurrency, (routine) => planOne(routine, nowIso, now));\n return {\n at: nowIso,\n due: plans.flatMap((p) => (p.fire === undefined ? [] : [p.fire])),\n errors: plans.flatMap((p) => (p.error === undefined ? [] : [p.error])),\n };\n }\n\n return {\n async register(routine) {\n runnerFor(routine).validate(routine);\n try {\n validateSchedule(routine.schedule);\n } catch (err) {\n throw new Error(`routine ${JSON.stringify(routine.id)} has a schedule the tick cannot read: ${message(err)}`);\n }\n await routines.register(routine);\n },\n cancel: (scope, routineId) => routines.cancel(scope, routineId),\n plan,\n async tick(opts = {}) {\n const planned = await plan(opts.now ?? clock());\n const report: TickReport = { ...planned, runs: [] };\n for (const fire of planned.due) {\n try {\n report.runs.push(await runnerFor(fire.routine).run(fire.routine, { runId: fire.at, at: fire.at }));\n } catch (err) {\n report.errors.push({ routineId: fire.routine.id, scope: fire.routine.scope, message: message(err), stage: \"run\" });\n }\n }\n return report;\n },\n };\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/routines.ts","../src/schedule.ts"],"sourcesContent":["import {\n BudgetExceededError,\n scopePath,\n type Block,\n type JobHandle,\n type Msg,\n type OutputSink,\n type Routine,\n type RoutineRun,\n type RoutineRunStore,\n type SinkRef,\n} from \"@alma-harness/core\";\nimport { AgentConfigError, type Agent, type JobRunner, type TurnResult } from \"@alma-harness/loop\";\n\n/**\n * The routine runner — §8, spec: routine-runner; lives with the clock since\n * spec: routines-with-the-clock. Takes a `Routine` (data)\n * when a trigger fires and runs it as the loop's triggered turn or as one\n * job, under the routine's own per-run cap and its daily ceiling; delivers\n * the result to the named sink once per distinct text; records every run\n * as metadata. It schedules nothing, holds no registry and stores no text:\n * the trigger seam, the product and the session store own those.\n */\n\nexport interface RoutineRunnerConfig {\n /** Runs turn routines. Everything it can do is fixed at its construction. */\n agent: Agent;\n /** Runs job routines; absent, a job routine is refused at validation. */\n jobs?: JobRunner;\n /** Product-provided destinations, by the name a routine's `outputSink` uses. */\n sinks: Record<SinkRef, OutputSink>;\n runs: RoutineRunStore;\n /** Ceiling on runs per routine per UTC day when the routine names none. Default 20. */\n maxRunsPerDay?: number;\n /** Injected clock (ISO 8601) — tests stay deterministic. */\n now?: () => string;\n}\n\nexport interface RunRoutineOptions {\n /**\n * The fire's identity — a schedule instant, a queue message id. The same\n * id twice is ONE run: the record is returned and nothing runs. Default: a\n * fresh UUID, for a trigger that has no natural id.\n */\n runId?: string;\n /** ISO 8601 of the fire. Default: now. */\n at?: string;\n signal?: AbortSignal;\n}\n\nexport interface RoutineRunner {\n /** Refuses a routine this runner cannot run — before, not during, a fire. */\n validate(routine: Routine): void;\n run(routine: Routine, opts?: RunRoutineOptions): Promise<RoutineRun>;\n}\n\nconst DEFAULT_MAX_RUNS_PER_DAY = 20;\n\n/** Midnight UTC of the fire's day — the spend store's day bucket, so the two ceilings agree on what a day is. */\nfunction utcDayStart(at: string): string {\n const ms = Date.parse(at);\n if (Number.isNaN(ms)) throw new AgentConfigError(`run.at is not an ISO 8601 timestamp: ${JSON.stringify(at)}`);\n return `${new Date(ms).toISOString().slice(0, 10)}T00:00:00.000Z`;\n}\n\nfunction textOf(msg: Msg): string {\n return msg.blocks\n .filter((b): b is Extract<Block, { type: \"text\" }> => b.type === \"text\" && b.origin !== \"harness\")\n .map((b) => b.text)\n .join(\"\\n\")\n .trim();\n}\n\n/** SHA-256, hex — the dedupe key a run record may hold without becoming a copy of the text. */\nasync function hashOf(text: string): Promise<string> {\n const digest = await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(text));\n return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, \"0\")).join(\"\");\n}\n\nexport function createRoutineRunner(config: RoutineRunnerConfig): RoutineRunner {\n const { agent, jobs, sinks, runs } = config;\n const defaultCeiling = config.maxRunsPerDay ?? DEFAULT_MAX_RUNS_PER_DAY;\n const now = config.now ?? (() => new Date().toISOString());\n\n function validate(routine: Routine): void {\n scopePath(routine.scope);\n const where = `routine ${JSON.stringify(routine.id)}`;\n if (!Object.hasOwn(sinks, routine.outputSink)) {\n throw new AgentConfigError(`${where} names sink ${JSON.stringify(routine.outputSink)}, which this runner does not have — a result needs a declared destination (§8)`);\n }\n const cap = routine.budget.perTurnUsd;\n if (cap === undefined || !Number.isFinite(cap) || cap < 0) {\n throw new AgentConfigError(`${where} has no finite per-run cap (budget.perTurnUsd) — nobody is watching an unattended run (§8)`);\n }\n if (routine.budget.perSessionUsd !== undefined || routine.budget.perTenantDayUsd !== undefined) {\n throw new AgentConfigError(`${where} names a persistent cap — those are the agent's, and already apply to every run (spec: routine-runner)`);\n }\n if ((routine.execution ?? \"turn\") === \"job\") {\n if (!jobs) throw new AgentConfigError(`${where} is a job routine, and this runner has no job runner`);\n if (routine.toolProfile !== undefined) throw new AgentConfigError(`${where} is a job routine with a tool profile — a job has no tools (spec: model-jobs)`);\n }\n const ceiling = routine.maxRunsPerDay;\n if (ceiling !== undefined && (!Number.isInteger(ceiling) || ceiling < 0)) {\n throw new AgentConfigError(`${where} has a malformed maxRunsPerDay: ${ceiling}`);\n }\n }\n\n /**\n * Delivers once per distinct text, and says which it was. A sink that\n * throws is a failed run, never a rethrow: the turn already happened, the\n * reply is in the session, and the record names the turn to re-deliver.\n */\n async function deliver(routine: Routine, run: RoutineRun, reply: Msg): Promise<RoutineRun> {\n const text = textOf(reply);\n if (text === \"\") return { ...run, outcome: \"failed\", reason: \"the reply carried no text\" };\n const hash = await hashOf(text);\n const [last] = await runs.list(routine.scope, routine.id, { outcome: \"delivered\", limit: 1 });\n if (last?.deliveryHash === hash) return { ...run, outcome: \"duplicate\", deliveryHash: hash };\n try {\n await sinks[routine.outputSink]!.deliver({\n routineId: routine.id,\n scope: routine.scope,\n runId: run.id,\n at: now(),\n reply,\n text,\n hash,\n costUsd: run.costUsd,\n });\n } catch (err) {\n return { ...run, outcome: \"failed\", reason: `delivery to ${JSON.stringify(routine.outputSink)} failed: ${err instanceof Error ? err.message : String(err)}`, deliveryHash: hash };\n }\n return { ...run, outcome: \"delivered\", deliveryHash: hash };\n }\n\n async function runTurn(routine: Routine, run: RoutineRun, signal: AbortSignal | undefined): Promise<RoutineRun> {\n const sessionId = `routine/${routine.id}/${run.id}`;\n const result: TurnResult = await agent.runTurn({\n scope: routine.scope,\n sessionId,\n input: { role: \"user\", blocks: [{ type: \"text\", text: routine.goal }], meta: { at: run.startedAt } },\n intent: routine.intent,\n trigger: \"routine\",\n ...(routine.toolProfile !== undefined ? { toolProfile: routine.toolProfile } : {}),\n perTurnUsd: routine.budget.perTurnUsd!,\n idempotencyKey: run.id,\n ...(signal ? { signal } : {}),\n });\n const base: RoutineRun = { ...run, sessionId, turnId: result.turnId, costUsd: result.costUsd };\n if (result.terminalReason === \"completed\") return deliver(routine, base, result.reply);\n // Nothing spent, nothing routed: a block cap refused it at preflight.\n if (result.terminalReason === \"budget_exceeded\" && result.steps === 0) {\n return { ...base, outcome: \"refused\", reason: `cap ${result.budgetExceeded?.cap} refused the run at preflight` };\n }\n const detail =\n result.terminalReason === \"budget_exceeded\"\n ? `cap ${result.budgetExceeded?.cap} ($${result.budgetExceeded?.capUsd}) stopped the turn at $${result.budgetExceeded?.spentUsd}`\n : result.failure !== undefined\n ? `${result.failure.kind}${result.failure.retryable ? \" (retryable)\" : \"\"}: ${result.failure.message}`\n : (result.error ?? result.terminalReason);\n return { ...base, outcome: \"failed\", reason: `turn ended ${result.terminalReason}: ${detail}` };\n }\n\n /** The submission a job routine has open, if any — the question the tick asks the same way (spec: clock-tick). */\n type Submitted = RoutineRun & { handle: JobHandle; sessionId: string };\n async function openSubmission(routine: Routine): Promise<Submitted | undefined> {\n if ((routine.execution ?? \"turn\") !== \"job\") return undefined;\n const [pending] = await runs.list(routine.scope, routine.id, { outcome: \"submitted\", limit: 1 });\n return pending?.handle !== undefined && pending.sessionId !== undefined ? (pending as Submitted) : undefined;\n }\n\n /**\n * Collects an open submission: it COMPLETES the submitted run, and is not\n * a run of its own — so it is never a fresh record and never counts\n * against the ceiling (spec: close-060-064-findings). Never waits on a batch.\n */\n async function collect(routine: Routine, pending: Submitted): Promise<RoutineRun> {\n {\n const progress = await jobs!.status(pending.handle);\n if (progress.status === \"queued\" || progress.status === \"running\") return { ...pending, outcome: \"waiting\" };\n const settled: RoutineRun = { ...pending, finishedAt: now() };\n if (progress.status !== \"done\") {\n return { ...settled, outcome: \"failed\", reason: `the provider reports the batch ${progress.status}` };\n }\n const collected = await jobs!.collect(pending.handle, { scope: routine.scope, sessionId: pending.sessionId });\n const withCost: RoutineRun = { ...settled, costUsd: collected.costUsd };\n const item = collected.results.find((r) => r.id === pending.id);\n if (!item) return { ...withCost, outcome: \"failed\", reason: \"the provider returned no result for this run — provider drift?\" };\n if (item.outcome !== \"succeeded\" || !item.reply) {\n return { ...withCost, outcome: \"failed\", reason: `the item ${item.outcome}${item.error ? `: ${item.error}` : \"\"}` };\n }\n // The money is gone; what a per-run cap can still do for a job is name\n // the malfunction and withhold the result, exactly as a turn cut by\n // its cap delivers nothing.\n if (collected.costUsd > routine.budget.perTurnUsd!) {\n return { ...withCost, outcome: \"failed\", reason: `the collected result cost $${collected.costUsd}, over the run's cap of $${routine.budget.perTurnUsd}` };\n }\n return deliver(routine, withCost, item.reply);\n }\n }\n\n /** Submits: one fire opens the submission; a later fire collects it (see `collect`). */\n async function runJob(routine: Routine, run: RoutineRun): Promise<RoutineRun> {\n const sessionId = `routine/${routine.id}/${run.id}`;\n try {\n const submitted = await jobs!.submit({\n scope: routine.scope,\n sessionId,\n intent: routine.intent,\n items: [{ id: run.id, messages: [{ role: \"user\", blocks: [{ type: \"text\", text: routine.goal }], meta: { at: run.startedAt } }] }],\n });\n return { ...run, outcome: \"submitted\", sessionId, handle: submitted.handle };\n } catch (err) {\n if (err instanceof BudgetExceededError) return { ...run, outcome: \"refused\", reason: `cap ${err.cap} refused the run at preflight` };\n throw err;\n }\n }\n\n return {\n validate,\n\n async run(routine, opts = {}) {\n validate(routine);\n const at = opts.at ?? now();\n const id = opts.runId ?? crypto.randomUUID();\n // The same fire twice is one run (§10b, one layer up): the record is\n // the answer, and the turn underneath carries the same key anyway.\n const seen = await runs.get(routine.scope, routine.id, id);\n if (seen) return seen;\n // An open submission is collected BEFORE the ceiling and instead of a\n // new run: the refusal that used to land here hid the submission from\n // every later fire (spec: close-060-064-findings).\n const pending = await openSubmission(routine);\n if (pending !== undefined) {\n const done = await collect(routine, pending);\n if (done.outcome === \"waiting\") return done;\n const final: RoutineRun = { ...done, finishedAt: done.finishedAt ?? now() };\n await runs.record(final);\n return final;\n }\n const run: RoutineRun = { id, routineId: routine.id, scope: routine.scope, startedAt: at, outcome: \"failed\", costUsd: 0 };\n const ceiling = routine.maxRunsPerDay ?? defaultCeiling;\n const today = await runs.list(routine.scope, routine.id, { since: utcDayStart(at) });\n // Refusals count too, and are recorded: after the ceiling nothing but\n // refusals happens, and an operator should be able to see them.\n if (today.length >= ceiling) {\n const refused: RoutineRun = { ...run, finishedAt: at, outcome: \"refused\", reason: `${today.length} runs today, at the ceiling of ${ceiling}` };\n await runs.record(refused);\n return refused;\n }\n const done =\n (routine.execution ?? \"turn\") === \"job\" ? await runJob(routine, run) : await runTurn(routine, run, opts.signal);\n if (done.outcome === \"waiting\") return done;\n // A submission is still open; everything else finished now, or when\n // the job path said it did.\n const final: RoutineRun =\n done.outcome === \"submitted\" ? done : { ...done, finishedAt: done.finishedAt ?? now() };\n await runs.record(final);\n return final;\n },\n };\n}\n","import {\n assertIso8601,\n parseIso8601,\n type Duration,\n type Routine,\n type RoutineRun,\n type RoutineRunStore,\n type RoutineStore,\n type Schedule as ScheduleSpec,\n type Scope,\n type StoredRoutine,\n} from \"@alma-harness/core\";\nimport { Cron } from \"croner\";\n\nimport type { RoutineRunner } from \"./routines\";\n\n/**\n * What is due, and the tick that runs it — spec: clock-tick, hardened by\n * spec: close-060-064-findings.\n *\n * The scheduler knows one thing: call `tick` every minute or five. The tick\n * knows everything else from the stores, so a tick retried, doubled or\n * missed does no harm: the FIRE's instant is the run id, and the runner\n * treats the same id twice as one run (spec: routine-runner).\n */\n\nexport interface ScheduleConfig {\n routines: RoutineStore;\n runs: RoutineRunStore;\n /**\n * One runner, or one per routine: a deployment with an agent per tenant\n * resolves it from the routine's scope, since `list` crosses every scope.\n */\n runner: RoutineRunner | ((routine: Routine) => RoutineRunner);\n /** Injected clock (ISO 8601) — tests stay deterministic. */\n now?: () => string;\n /** Per-routine store reads in flight at once. Default 8. */\n concurrency?: number;\n}\n\nexport interface DueFire {\n routine: StoredRoutine;\n /** The instant the run is named by: the fire, or the tick's own instant for a pending collection. */\n at: string;\n /** `fire`: the schedule came due. `pending`: a job routine's submission is still open and is collected now. */\n reason: \"fire\" | \"pending\";\n}\n\nexport interface ScheduleError {\n routineId: string;\n scope: Scope;\n message: string;\n /** `plan`: the routine's schedule could not be read. `run`: the runner threw. The others were not affected. */\n stage: \"plan\" | \"run\";\n}\n\nexport interface TickPlan {\n at: string;\n due: DueFire[];\n errors: ScheduleError[];\n}\n\nexport interface TickReport extends TickPlan {\n runs: RoutineRun[];\n}\n\nexport interface RoutineSchedule {\n /**\n * Registers with the store AFTER the runner and the schedule have refused\n * what they cannot run — at registration, not at 8h with nobody watching.\n */\n register(routine: Routine & { registeredAt?: string }): Promise<void>;\n cancel(scope: Scope, routineId: string): Promise<void>;\n /** What would run at `now`, without running it, and which routines could not even be planned. */\n plan(now?: string): Promise<TickPlan>;\n /** Runs what is due, one routine after another, and reports. Never throws for a routine's sake. */\n tick(opts?: { now?: string }): Promise<TickReport>;\n}\n\nconst UNITS: Record<\"s\" | \"m\" | \"h\" | \"d\", number> = { s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 };\n\n/** A compact duration (\"90s\", \"15m\", \"2h\", \"1d\") in milliseconds; zero is refused — it would fire NaN. */\nexport function durationMs(duration: Duration): number {\n const match = /^(\\d+)([smhd])$/.exec(duration);\n const ms = match === null ? 0 : Number(match[1]) * UNITS[match[2] as keyof typeof UNITS];\n if (ms <= 0) throw new Error(`malformed duration: ${JSON.stringify(duration)}`);\n return ms;\n}\n\n/**\n * UTC unless the routine says where it lives: croner would otherwise read\n * the cron in the PROCESS's zone, and a fire would move with the deployment.\n * Constructed without a callback, so nothing is scheduled.\n */\nfunction cronOf(expression: string, tz: string | undefined): Cron {\n return new Cron(expression, { timezone: tz ?? \"UTC\" });\n}\n\n/** Refuses a schedule the tick could not read — the same errors croner or the parser would raise mid-tick. */\nexport function validateSchedule(schedule: ScheduleSpec): void {\n if (\"at\" in schedule) {\n assertIso8601(schedule.at, \"schedule.at\");\n } else if (\"every\" in schedule) {\n durationMs(schedule.every);\n } else {\n // A bad zone surfaces only on the first computation, not at construction.\n cronOf(schedule.cron, schedule.tz).nextRun(new Date());\n }\n}\n\n/** The first fire strictly after `afterMs`, or nothing. */\nfunction nextFire(schedule: ScheduleSpec, registered: number, afterMs: number): number | undefined {\n if (\"at\" in schedule) {\n const at = parseIso8601(schedule.at, \"schedule.at\");\n return at > afterMs ? at : undefined;\n }\n if (\"every\" in schedule) {\n const step = durationMs(schedule.every);\n const k = Math.max(1, Math.floor((afterMs - registered) / step) + 1);\n return registered + k * step;\n }\n const next = cronOf(schedule.cron, schedule.tz).nextRun(new Date(afterMs));\n return next === null ? undefined : next.getTime();\n}\n\n/**\n * The latest occurrence of a cron at or before `now`. Windows widen until\n * one holds an occurrence, then the window is walked forward to the last\n * one — bounded, because a window is only walked when the cron fits it,\n * and only reached once `nextFire` said something may be due.\n */\nfunction latestCron(expression: string, tz: string | undefined, now: number): number | undefined {\n const cron = cronOf(expression, tz);\n for (const window of [2 * UNITS.m, 2 * UNITS.h, 2 * UNITS.d, 40 * UNITS.d, 400 * UNITS.d]) {\n let at = cron.nextRun(new Date(now - window));\n if (at === null || at.getTime() > now) continue;\n for (;;) {\n const next = cron.nextRun(at);\n if (next === null || next.getTime() > now) return at.getTime();\n at = next;\n }\n }\n return undefined;\n}\n\n/**\n * The latest fire of a schedule at or before `now`, as an ISO instant — or\n * nothing: before the first fire, or a fire the routine never saw (earlier\n * than its registration). `{ every }` anchors on the registration stamp.\n */\nexport function latestFire(schedule: ScheduleSpec, registeredAt: string, nowIso: string): string | undefined {\n const now = parseIso8601(nowIso, \"now\");\n const registered = parseIso8601(registeredAt, \"registeredAt\");\n let fire: number | undefined;\n if (\"at\" in schedule) {\n const at = parseIso8601(schedule.at, \"schedule.at\");\n fire = at <= now ? at : undefined;\n } else if (\"every\" in schedule) {\n const step = durationMs(schedule.every);\n fire = now - registered >= step ? registered + Math.floor((now - registered) / step) * step : undefined;\n } else {\n fire = latestCron(schedule.cron, schedule.tz, now);\n }\n if (fire === undefined || fire < registered) return undefined;\n return new Date(fire).toISOString();\n}\n\n/** `fn` over `items`, at most `limit` in flight — the per-routine reads of a tick (spec: close-060-064-findings). */\nasync function mapConcurrent<T, R>(items: readonly T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {\n const out: R[] = new Array<R>(items.length);\n let next = 0;\n const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {\n for (;;) {\n const i = next++;\n if (i >= items.length) return;\n out[i] = await fn(items[i]!);\n }\n });\n await Promise.all(workers);\n return out;\n}\n\nexport function createSchedule(config: ScheduleConfig): RoutineSchedule {\n const { routines, runs } = config;\n const runnerFor = typeof config.runner === \"function\" ? config.runner : () => config.runner as RoutineRunner;\n const clock = config.now ?? (() => new Date().toISOString());\n const concurrency = config.concurrency ?? 8;\n const message = (err: unknown): string => (err instanceof Error ? err.message : String(err));\n\n /** One routine's share of a tick: a fire, nothing, or the error that kept it from being planned. */\n async function planOne(routine: StoredRoutine, nowIso: string, now: number): Promise<{ fire?: DueFire; error?: ScheduleError }> {\n try {\n // A submission still open is collected on EVERY tick, not on the next\n // cron occurrence (spec: model-jobs) — asked the way the runner asks it.\n const [pending] = await runs.list(routine.scope, routine.id, { outcome: \"submitted\", limit: 1 });\n if (pending !== undefined) return { fire: { routine, at: nowIso, reason: \"pending\" } };\n const [last] = await runs.list(routine.scope, routine.id, { limit: 1 });\n const registered = parseIso8601(routine.registeredAt, \"registeredAt\");\n // The common case answered in one step: is there any fire after the\n // later of the registration and the last run, at or before now?\n const after = last === undefined ? registered - 1 : Math.max(registered - 1, parseIso8601(last.startedAt, \"startedAt\"));\n const next = nextFire(routine.schedule, registered, after);\n if (next === undefined || next > now) return {};\n const fire = latestFire(routine.schedule, routine.registeredAt, nowIso);\n // Refusals are runs too: a routine past its ceiling is not re-fired every minute.\n if (fire === undefined || (last !== undefined && parseIso8601(fire) <= parseIso8601(last.startedAt))) return {};\n return { fire: { routine, at: fire, reason: \"fire\" } };\n } catch (err) {\n return { error: { routineId: routine.id, scope: routine.scope, message: message(err), stage: \"plan\" } };\n }\n }\n\n async function plan(nowIso: string = clock()): Promise<TickPlan> {\n const now = parseIso8601(nowIso, \"now\");\n const plans = await mapConcurrent(await routines.list(), concurrency, (routine) => planOne(routine, nowIso, now));\n return {\n at: nowIso,\n due: plans.flatMap((p) => (p.fire === undefined ? [] : [p.fire])),\n errors: plans.flatMap((p) => (p.error === undefined ? [] : [p.error])),\n };\n }\n\n return {\n async register(routine) {\n runnerFor(routine).validate(routine);\n try {\n validateSchedule(routine.schedule);\n } catch (err) {\n throw new Error(`routine ${JSON.stringify(routine.id)} has a schedule the tick cannot read: ${message(err)}`);\n }\n await routines.register(routine);\n },\n cancel: (scope, routineId) => routines.cancel(scope, routineId),\n plan,\n async tick(opts = {}) {\n const planned = await plan(opts.now ?? clock());\n const report: TickReport = { ...planned, runs: [] };\n for (const fire of planned.due) {\n try {\n report.runs.push(await runnerFor(fire.routine).run(fire.routine, { runId: fire.at, at: fire.at }));\n } catch (err) {\n report.errors.push({ routineId: fire.routine.id, scope: fire.routine.scope, message: message(err), stage: \"run\" });\n }\n }\n return report;\n },\n };\n}\n"],"mappings":"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","names":["done","final"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alma-harness/schedule",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Alma's clock: what is due, and the tick an external scheduler calls to run it — no process kept alive.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -22,8 +22,8 @@
|
|
|
22
22
|
"croner": "^10.0.1"
|
|
23
23
|
},
|
|
24
24
|
"peerDependencies": {
|
|
25
|
-
"@alma-harness/core": "^0.
|
|
26
|
-
"@alma-harness/loop": "^0.
|
|
25
|
+
"@alma-harness/core": "^0.5.0",
|
|
26
|
+
"@alma-harness/loop": "^0.5.0"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
29
|
"@types/node": "^24.1.0",
|