@data-club/ai-hub-server 0.0.1
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/auth/argon2.d.ts +72 -0
- package/dist/auth/argon2.d.ts.map +1 -0
- package/dist/auth/argon2.js +145 -0
- package/dist/auth/argon2.js.map +1 -0
- package/dist/auth/encryption.d.ts +52 -0
- package/dist/auth/encryption.d.ts.map +1 -0
- package/dist/auth/encryption.js +98 -0
- package/dist/auth/encryption.js.map +1 -0
- package/dist/auth/jwt.d.ts +133 -0
- package/dist/auth/jwt.d.ts.map +1 -0
- package/dist/auth/jwt.js +147 -0
- package/dist/auth/jwt.js.map +1 -0
- package/dist/auth/middleware.d.ts +101 -0
- package/dist/auth/middleware.d.ts.map +1 -0
- package/dist/auth/middleware.js +161 -0
- package/dist/auth/middleware.js.map +1 -0
- package/dist/auth/passwordPolicy.d.ts +72 -0
- package/dist/auth/passwordPolicy.d.ts.map +1 -0
- package/dist/auth/passwordPolicy.js +88 -0
- package/dist/auth/passwordPolicy.js.map +1 -0
- package/dist/auth/roleRules.d.ts +200 -0
- package/dist/auth/roleRules.d.ts.map +1 -0
- package/dist/auth/roleRules.js +365 -0
- package/dist/auth/roleRules.js.map +1 -0
- package/dist/auth/userView.d.ts +55 -0
- package/dist/auth/userView.d.ts.map +1 -0
- package/dist/auth/userView.js +77 -0
- package/dist/auth/userView.js.map +1 -0
- package/dist/bootstrap.d.ts +104 -0
- package/dist/bootstrap.d.ts.map +1 -0
- package/dist/bootstrap.js +160 -0
- package/dist/bootstrap.js.map +1 -0
- package/dist/db/connection.d.ts +39 -0
- package/dist/db/connection.d.ts.map +1 -0
- package/dist/db/connection.js +62 -0
- package/dist/db/connection.js.map +1 -0
- package/dist/db/migrate.d.ts +31 -0
- package/dist/db/migrate.d.ts.map +1 -0
- package/dist/db/migrate.js +197 -0
- package/dist/db/migrate.js.map +1 -0
- package/dist/db/schema.d.ts +1902 -0
- package/dist/db/schema.d.ts.map +1 -0
- package/dist/db/schema.js +662 -0
- package/dist/db/schema.js.map +1 -0
- package/dist/db/seed.d.ts +92 -0
- package/dist/db/seed.d.ts.map +1 -0
- package/dist/db/seed.js +160 -0
- package/dist/db/seed.js.map +1 -0
- package/dist/handlers/fetch.d.ts +89 -0
- package/dist/handlers/fetch.d.ts.map +1 -0
- package/dist/handlers/fetch.js +173 -0
- package/dist/handlers/fetch.js.map +1 -0
- package/dist/handlers/nextjs.d.ts +40 -0
- package/dist/handlers/nextjs.d.ts.map +1 -0
- package/dist/handlers/nextjs.js +42 -0
- package/dist/handlers/nextjs.js.map +1 -0
- package/dist/handlers/responses.d.ts +46 -0
- package/dist/handlers/responses.d.ts.map +1 -0
- package/dist/handlers/responses.js +74 -0
- package/dist/handlers/responses.js.map +1 -0
- package/dist/handlers/routeTable.d.ts +21 -0
- package/dist/handlers/routeTable.d.ts.map +1 -0
- package/dist/handlers/routeTable.js +133 -0
- package/dist/handlers/routeTable.js.map +1 -0
- package/dist/handlers/router.d.ts +37 -0
- package/dist/handlers/router.d.ts.map +1 -0
- package/dist/handlers/router.js +82 -0
- package/dist/handlers/router.js.map +1 -0
- package/dist/handlers/sse.d.ts +38 -0
- package/dist/handlers/sse.d.ts.map +1 -0
- package/dist/handlers/sse.js +52 -0
- package/dist/handlers/sse.js.map +1 -0
- package/dist/handlers/types.d.ts +96 -0
- package/dist/handlers/types.d.ts.map +1 -0
- package/dist/handlers/types.js +14 -0
- package/dist/handlers/types.js.map +1 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/letta/client.d.ts +355 -0
- package/dist/letta/client.d.ts.map +1 -0
- package/dist/letta/client.js +529 -0
- package/dist/letta/client.js.map +1 -0
- package/dist/letta/eventTranslator.d.ts +166 -0
- package/dist/letta/eventTranslator.d.ts.map +1 -0
- package/dist/letta/eventTranslator.js +532 -0
- package/dist/letta/eventTranslator.js.map +1 -0
- package/dist/letta/index.d.ts +13 -0
- package/dist/letta/index.d.ts.map +1 -0
- package/dist/letta/index.js +12 -0
- package/dist/letta/index.js.map +1 -0
- package/dist/letta/modelSettings.d.ts +176 -0
- package/dist/letta/modelSettings.d.ts.map +1 -0
- package/dist/letta/modelSettings.js +314 -0
- package/dist/letta/modelSettings.js.map +1 -0
- package/dist/letta/promptWrapping.d.ts +141 -0
- package/dist/letta/promptWrapping.d.ts.map +1 -0
- package/dist/letta/promptWrapping.js +213 -0
- package/dist/letta/promptWrapping.js.map +1 -0
- package/dist/letta/sandbox.d.ts +100 -0
- package/dist/letta/sandbox.d.ts.map +1 -0
- package/dist/letta/sandbox.js +151 -0
- package/dist/letta/sandbox.js.map +1 -0
- package/dist/letta/settingsFileSeed.d.ts +146 -0
- package/dist/letta/settingsFileSeed.d.ts.map +1 -0
- package/dist/letta/settingsFileSeed.js +177 -0
- package/dist/letta/settingsFileSeed.js.map +1 -0
- package/dist/letta/subprocessThrottle.d.ts +151 -0
- package/dist/letta/subprocessThrottle.d.ts.map +1 -0
- package/dist/letta/subprocessThrottle.js +195 -0
- package/dist/letta/subprocessThrottle.js.map +1 -0
- package/dist/letta/tpmRecovery.d.ts +202 -0
- package/dist/letta/tpmRecovery.d.ts.map +1 -0
- package/dist/letta/tpmRecovery.js +314 -0
- package/dist/letta/tpmRecovery.js.map +1 -0
- package/dist/letta/types.d.ts +54 -0
- package/dist/letta/types.d.ts.map +1 -0
- package/dist/letta/types.js +16 -0
- package/dist/letta/types.js.map +1 -0
- package/dist/observability/logger.d.ts +48 -0
- package/dist/observability/logger.d.ts.map +1 -0
- package/dist/observability/logger.js +81 -0
- package/dist/observability/logger.js.map +1 -0
- package/dist/observability/metrics.d.ts +53 -0
- package/dist/observability/metrics.d.ts.map +1 -0
- package/dist/observability/metrics.js +92 -0
- package/dist/observability/metrics.js.map +1 -0
- package/dist/routes/admin/agents.d.ts +63 -0
- package/dist/routes/admin/agents.d.ts.map +1 -0
- package/dist/routes/admin/agents.js +461 -0
- package/dist/routes/admin/agents.js.map +1 -0
- package/dist/routes/admin/rotatingMessages.d.ts +51 -0
- package/dist/routes/admin/rotatingMessages.d.ts.map +1 -0
- package/dist/routes/admin/rotatingMessages.js +292 -0
- package/dist/routes/admin/rotatingMessages.js.map +1 -0
- package/dist/routes/admin/sessions.d.ts +61 -0
- package/dist/routes/admin/sessions.d.ts.map +1 -0
- package/dist/routes/admin/sessions.js +232 -0
- package/dist/routes/admin/sessions.js.map +1 -0
- package/dist/routes/admin/settings.d.ts +110 -0
- package/dist/routes/admin/settings.d.ts.map +1 -0
- package/dist/routes/admin/settings.js +401 -0
- package/dist/routes/admin/settings.js.map +1 -0
- package/dist/routes/admin/users.d.ts +115 -0
- package/dist/routes/admin/users.d.ts.map +1 -0
- package/dist/routes/admin/users.js +374 -0
- package/dist/routes/admin/users.js.map +1 -0
- package/dist/routes/agents.d.ts +30 -0
- package/dist/routes/agents.d.ts.map +1 -0
- package/dist/routes/agents.js +55 -0
- package/dist/routes/agents.js.map +1 -0
- package/dist/routes/auth.d.ts +121 -0
- package/dist/routes/auth.d.ts.map +1 -0
- package/dist/routes/auth.js +422 -0
- package/dist/routes/auth.js.map +1 -0
- package/dist/routes/chat.d.ts +97 -0
- package/dist/routes/chat.d.ts.map +1 -0
- package/dist/routes/chat.js +484 -0
- package/dist/routes/chat.js.map +1 -0
- package/dist/routes/featureToggles.d.ts +34 -0
- package/dist/routes/featureToggles.d.ts.map +1 -0
- package/dist/routes/featureToggles.js +47 -0
- package/dist/routes/featureToggles.js.map +1 -0
- package/dist/routes/health.d.ts +24 -0
- package/dist/routes/health.d.ts.map +1 -0
- package/dist/routes/health.js +37 -0
- package/dist/routes/health.js.map +1 -0
- package/dist/routes/metrics.d.ts +35 -0
- package/dist/routes/metrics.d.ts.map +1 -0
- package/dist/routes/metrics.js +106 -0
- package/dist/routes/metrics.js.map +1 -0
- package/dist/routes/sessions.d.ts +161 -0
- package/dist/routes/sessions.d.ts.map +1 -0
- package/dist/routes/sessions.js +1501 -0
- package/dist/routes/sessions.js.map +1 -0
- package/dist/routes/stubs.d.ts +25 -0
- package/dist/routes/stubs.d.ts.map +1 -0
- package/dist/routes/stubs.js +25 -0
- package/dist/routes/stubs.js.map +1 -0
- package/dist/state/cron.d.ts +58 -0
- package/dist/state/cron.d.ts.map +1 -0
- package/dist/state/cron.js +160 -0
- package/dist/state/cron.js.map +1 -0
- package/dist/state/rotationRunner.d.ts +96 -0
- package/dist/state/rotationRunner.d.ts.map +1 -0
- package/dist/state/rotationRunner.js +246 -0
- package/dist/state/rotationRunner.js.map +1 -0
- package/dist/state/rotationScheduler.d.ts +76 -0
- package/dist/state/rotationScheduler.d.ts.map +1 -0
- package/dist/state/rotationScheduler.js +124 -0
- package/dist/state/rotationScheduler.js.map +1 -0
- package/dist/state/turnRegistry.d.ts +95 -0
- package/dist/state/turnRegistry.d.ts.map +1 -0
- package/dist/state/turnRegistry.js +149 -0
- package/dist/state/turnRegistry.js.map +1 -0
- package/dist/state/turnRunner.d.ts +134 -0
- package/dist/state/turnRunner.d.ts.map +1 -0
- package/dist/state/turnRunner.js +476 -0
- package/dist/state/turnRunner.js.map +1 -0
- package/package.json +47 -0
- package/src/auth/argon2.ts +152 -0
- package/src/auth/encryption.ts +110 -0
- package/src/auth/jwt.ts +217 -0
- package/src/auth/middleware.ts +250 -0
- package/src/auth/passwordPolicy.ts +99 -0
- package/src/auth/roleRules.ts +497 -0
- package/src/auth/userView.ts +88 -0
- package/src/bootstrap.ts +254 -0
- package/src/db/connection.ts +75 -0
- package/src/db/migrate.ts +212 -0
- package/src/db/schema.ts +750 -0
- package/src/db/seed.ts +236 -0
- package/src/handlers/fetch.ts +251 -0
- package/src/handlers/nextjs.ts +49 -0
- package/src/handlers/responses.ts +95 -0
- package/src/handlers/routeTable.ts +188 -0
- package/src/handlers/router.ts +96 -0
- package/src/handlers/sse.ts +55 -0
- package/src/handlers/types.ts +104 -0
- package/src/index.ts +60 -0
- package/src/letta/client.ts +860 -0
- package/src/letta/eventTranslator.ts +667 -0
- package/src/letta/index.ts +46 -0
- package/src/letta/lettaCliShim.mjs +40 -0
- package/src/letta/sandbox.ts +167 -0
- package/src/letta/settingsFileSeed.ts +254 -0
- package/src/letta/subprocessThrottle.ts +251 -0
- package/src/letta/types.ts +65 -0
- package/src/observability/logger.ts +103 -0
- package/src/observability/metrics.ts +114 -0
- package/src/routes/admin/agents.ts +574 -0
- package/src/routes/admin/rotatingMessages.ts +351 -0
- package/src/routes/admin/sessions.ts +283 -0
- package/src/routes/admin/settings.ts +463 -0
- package/src/routes/admin/users.ts +455 -0
- package/src/routes/agents.ts +66 -0
- package/src/routes/auth.ts +513 -0
- package/src/routes/chat.ts +534 -0
- package/src/routes/featureToggles.ts +52 -0
- package/src/routes/health.ts +44 -0
- package/src/routes/metrics.ts +111 -0
- package/src/routes/sessions.ts +1878 -0
- package/src/state/cron.ts +179 -0
- package/src/state/rotationRunner.ts +326 -0
- package/src/state/rotationScheduler.ts +164 -0
- package/src/state/turnRegistry.ts +210 -0
- package/src/state/turnRunner.ts +588 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal, self-contained 5-field cron evaluator.
|
|
3
|
+
*
|
|
4
|
+
* Used as the "advanced" escape hatch for rotating-message schedules
|
|
5
|
+
* (`schedule_kind = 'cron'`). The common cases are covered by the `every` /
|
|
6
|
+
* `daily` presets; this handles arbitrary recurrence without pulling in an
|
|
7
|
+
* external dependency (keeps the build hermetic — no network install).
|
|
8
|
+
*
|
|
9
|
+
* **Scope (intentionally numeric-only).** Supports the standard five fields —
|
|
10
|
+
* `minute hour day-of-month month day-of-week` — with `*`, single values,
|
|
11
|
+
* ranges (`a-b`), steps (`* / n`, `a-b/n`), and comma lists. It does NOT
|
|
12
|
+
* support month/weekday names, `L`/`W`/`#`, `@`-aliases, or seconds. Invalid
|
|
13
|
+
* input throws `CronParseError` so the admin route can surface a 400.
|
|
14
|
+
*
|
|
15
|
+
* **Timezone.** All evaluation is in **UTC** — the server stores ISO-8601
|
|
16
|
+
* UTC timestamps everywhere, and UTC keeps next-run computation deterministic
|
|
17
|
+
* and test-stable. The admin UI labels cron/daily times as UTC.
|
|
18
|
+
*
|
|
19
|
+
* Day-of-month vs day-of-week follow cron's classic rule: when BOTH are
|
|
20
|
+
* restricted (neither is `*`), a timestamp matches if EITHER matches; when one
|
|
21
|
+
* is `*`, the other must match.
|
|
22
|
+
*
|
|
23
|
+
* @see docs/server/rotating-messages.md
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** Thrown when a cron expression can't be parsed. */
|
|
27
|
+
export class CronParseError extends Error {
|
|
28
|
+
constructor(message: string) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.name = "CronParseError";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface CronFields {
|
|
35
|
+
minute: ReadonlySet<number>;
|
|
36
|
+
hour: ReadonlySet<number>;
|
|
37
|
+
/** day-of-month; `null` when the field is `*` (unrestricted). */
|
|
38
|
+
dom: ReadonlySet<number> | null;
|
|
39
|
+
month: ReadonlySet<number>;
|
|
40
|
+
/** day-of-week 0..6 (Sunday=0); `null` when the field is `*` (unrestricted). */
|
|
41
|
+
dow: ReadonlySet<number> | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Parses one cron field into the set of values it allows.
|
|
46
|
+
*
|
|
47
|
+
* @param raw - the field text (e.g. a step `"x/15"`, a range `"1-5"`, a list `"0,30"`).
|
|
48
|
+
* @param min - smallest legal value for this field.
|
|
49
|
+
* @param max - largest legal value for this field.
|
|
50
|
+
* @returns the allowed values, or `null` when the field is a bare `*`.
|
|
51
|
+
* @throws {CronParseError} on malformed syntax or out-of-range values.
|
|
52
|
+
*/
|
|
53
|
+
function parseField(
|
|
54
|
+
raw: string,
|
|
55
|
+
min: number,
|
|
56
|
+
max: number,
|
|
57
|
+
): Set<number> | null {
|
|
58
|
+
const trimmed = raw.trim();
|
|
59
|
+
if (trimmed === "") throw new CronParseError("Empty cron field.");
|
|
60
|
+
if (trimmed === "*") return null;
|
|
61
|
+
|
|
62
|
+
const out = new Set<number>();
|
|
63
|
+
for (const part of trimmed.split(",")) {
|
|
64
|
+
const [rangePart, stepPart] = part.split("/") as [string, string?];
|
|
65
|
+
let step = 1;
|
|
66
|
+
if (stepPart !== undefined) {
|
|
67
|
+
step = Number(stepPart);
|
|
68
|
+
if (!Number.isInteger(step) || step <= 0) {
|
|
69
|
+
throw new CronParseError(`Invalid step "${stepPart}" in "${raw}".`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let lo: number;
|
|
74
|
+
let hi: number;
|
|
75
|
+
if (rangePart === "*") {
|
|
76
|
+
lo = min;
|
|
77
|
+
hi = max;
|
|
78
|
+
} else if (rangePart.includes("-")) {
|
|
79
|
+
const [a, b] = rangePart.split("-") as [string, string];
|
|
80
|
+
lo = Number(a);
|
|
81
|
+
hi = Number(b);
|
|
82
|
+
} else {
|
|
83
|
+
lo = Number(rangePart);
|
|
84
|
+
hi = lo;
|
|
85
|
+
}
|
|
86
|
+
if (!Number.isInteger(lo) || !Number.isInteger(hi)) {
|
|
87
|
+
throw new CronParseError(`Invalid value "${part}" in "${raw}".`);
|
|
88
|
+
}
|
|
89
|
+
if (lo < min || hi > max || lo > hi) {
|
|
90
|
+
throw new CronParseError(
|
|
91
|
+
`Value "${part}" out of range ${min}-${max} in "${raw}".`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
for (let v = lo; v <= hi; v += step) out.add(v);
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Parses a 5-field cron expression.
|
|
101
|
+
*
|
|
102
|
+
* @param expr - the expression (whitespace-separated).
|
|
103
|
+
* @returns the parsed field sets.
|
|
104
|
+
* @throws {CronParseError} when the expression isn't exactly 5 valid fields.
|
|
105
|
+
*/
|
|
106
|
+
export function parseCron(expr: string): CronFields {
|
|
107
|
+
const parts = expr.trim().split(/\s+/);
|
|
108
|
+
if (parts.length !== 5) {
|
|
109
|
+
throw new CronParseError(
|
|
110
|
+
`Expected 5 cron fields (minute hour day-of-month month day-of-week), got ${parts.length}.`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
const [m, h, dom, mon, dowRaw] = parts as [string, string, string, string, string];
|
|
114
|
+
let dow = parseField(dowRaw, 0, 7);
|
|
115
|
+
// Normalize Sunday-as-7 to 0 so matching against getUTCDay() (0..6) works.
|
|
116
|
+
if (dow) {
|
|
117
|
+
const norm = new Set<number>();
|
|
118
|
+
for (const v of dow) norm.add(v === 7 ? 0 : v);
|
|
119
|
+
dow = norm;
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
minute: parseField(m, 0, 59) ?? fullRange(0, 59),
|
|
123
|
+
hour: parseField(h, 0, 23) ?? fullRange(0, 23),
|
|
124
|
+
dom: parseField(dom, 1, 31),
|
|
125
|
+
month: parseField(mon, 1, 12) ?? fullRange(1, 12),
|
|
126
|
+
dow,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Builds a set covering `[lo, hi]` inclusive (used for the `*` minute/hour/month case). */
|
|
131
|
+
function fullRange(lo: number, hi: number): Set<number> {
|
|
132
|
+
const s = new Set<number>();
|
|
133
|
+
for (let v = lo; v <= hi; v++) s.add(v);
|
|
134
|
+
return s;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Whether a given instant (in UTC) matches the parsed cron fields. */
|
|
138
|
+
function matches(fields: CronFields, d: Date): boolean {
|
|
139
|
+
if (!fields.minute.has(d.getUTCMinutes())) return false;
|
|
140
|
+
if (!fields.hour.has(d.getUTCHours())) return false;
|
|
141
|
+
if (!fields.month.has(d.getUTCMonth() + 1)) return false;
|
|
142
|
+
|
|
143
|
+
const domOk = fields.dom === null || fields.dom.has(d.getUTCDate());
|
|
144
|
+
const dowOk = fields.dow === null || fields.dow.has(d.getUTCDay());
|
|
145
|
+
// Classic cron rule: both restricted → OR; otherwise → AND (the `*` side is true).
|
|
146
|
+
if (fields.dom !== null && fields.dow !== null) {
|
|
147
|
+
return domOk || dowOk;
|
|
148
|
+
}
|
|
149
|
+
return domOk && dowOk;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Upper bound on the minute-by-minute forward search (~4 years + buffer). */
|
|
153
|
+
const MAX_SEARCH_MINUTES = 4 * 366 * 24 * 60;
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Returns the next instant strictly after `from` that satisfies `expr`, as a
|
|
157
|
+
* UTC ISO-8601 string.
|
|
158
|
+
*
|
|
159
|
+
* @param expr - a 5-field cron expression.
|
|
160
|
+
* @param from - the reference instant (exclusive lower bound).
|
|
161
|
+
* @returns the next matching instant as an ISO-8601 UTC string.
|
|
162
|
+
* @throws {CronParseError} on a malformed expression or if no match is found
|
|
163
|
+
* within the search bound (effectively an impossible schedule, e.g. Feb 30).
|
|
164
|
+
*/
|
|
165
|
+
export function cronNextRun(expr: string, from: Date): string {
|
|
166
|
+
const fields = parseCron(expr);
|
|
167
|
+
// Start at the next whole minute strictly after `from`.
|
|
168
|
+
const cursor = new Date(from.getTime());
|
|
169
|
+
cursor.setUTCSeconds(0, 0);
|
|
170
|
+
cursor.setUTCMinutes(cursor.getUTCMinutes() + 1);
|
|
171
|
+
|
|
172
|
+
for (let i = 0; i < MAX_SEARCH_MINUTES; i++) {
|
|
173
|
+
if (matches(fields, cursor)) return cursor.toISOString();
|
|
174
|
+
cursor.setUTCMinutes(cursor.getUTCMinutes() + 1);
|
|
175
|
+
}
|
|
176
|
+
throw new CronParseError(
|
|
177
|
+
`Cron expression "${expr}" has no occurrence within ~4 years — likely impossible (e.g. an invalid day/month combo).`,
|
|
178
|
+
);
|
|
179
|
+
}
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rotation runner for **rotating starting messages**.
|
|
3
|
+
*
|
|
4
|
+
* One rotation turns a configured prompt into a fresh, forkable "prepared
|
|
5
|
+
* conversation" (see `rotating_message` in `db/schema.ts`):
|
|
6
|
+
*
|
|
7
|
+
* 1. `createConversation(letta_agent_id)` — a clean conversation.
|
|
8
|
+
* 2. Send `prompt` via the normal streaming path, drained to the terminal
|
|
9
|
+
* `result`. Reuses `streamUserMessage`, so the rotation inherits the same
|
|
10
|
+
* subprocess throttle + toolset-none suppression + model routing as a
|
|
11
|
+
* real chat turn.
|
|
12
|
+
* 3. Find the prompt's `user_message` in the resulting history and delete it,
|
|
13
|
+
* so the conversation opens with the agent's reply.
|
|
14
|
+
* 4. Persist the new conversation id as `current_conversation_id`.
|
|
15
|
+
*
|
|
16
|
+
* **Resilience.** Any failure leaves `current_conversation_id` at its last-good
|
|
17
|
+
* value (sessions keep forking the previous good conversation), records
|
|
18
|
+
* `last_status = 'error'` + `last_error`, and still advances `next_rotation_at`
|
|
19
|
+
* so the next interval retries rather than the tick hammering a broken agent.
|
|
20
|
+
*
|
|
21
|
+
* `computeNextRotation` / `validateScheduleFields` are pure and unit-tested
|
|
22
|
+
* independently of any Letta interaction.
|
|
23
|
+
*
|
|
24
|
+
* @see docs/server/rotating-messages.md
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { eq } from "drizzle-orm";
|
|
28
|
+
|
|
29
|
+
import type { DrizzleDB } from "../db/connection.js";
|
|
30
|
+
import { rotatingMessage, type RotatingMessageRow } from "../db/schema.js";
|
|
31
|
+
import type { LettaClient } from "../letta/client.js";
|
|
32
|
+
import type { Logger } from "../observability/logger.js";
|
|
33
|
+
|
|
34
|
+
import { CronParseError, cronNextRun, parseCron } from "./cron.js";
|
|
35
|
+
|
|
36
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
37
|
+
/* Schedule (pure) */
|
|
38
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
39
|
+
|
|
40
|
+
/** The schedule-shaped subset of a `rotating_message` row. */
|
|
41
|
+
export interface RotationSchedule {
|
|
42
|
+
scheduleKind: "every" | "daily" | "cron";
|
|
43
|
+
intervalSeconds: number | null;
|
|
44
|
+
dailyTime: string | null;
|
|
45
|
+
cronExpression: string | null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Smallest allowed `every`-interval (guards against hammering Letta). */
|
|
49
|
+
export const MIN_INTERVAL_SECONDS = 60;
|
|
50
|
+
/** Largest allowed `every`-interval (366 days). */
|
|
51
|
+
export const MAX_INTERVAL_SECONDS = 366 * 24 * 3600;
|
|
52
|
+
/** `"HH:MM"` 24-hour validator for the `daily` preset (UTC). */
|
|
53
|
+
const DAILY_TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
|
54
|
+
|
|
55
|
+
/** Outcome of validating a schedule config. */
|
|
56
|
+
export type ScheduleValidation = { ok: true } | { ok: false; message: string };
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Validates a schedule config (used by the admin create/patch handlers).
|
|
60
|
+
* Pure — the only "work" is parsing cron, which throws `CronParseError` on
|
|
61
|
+
* malformed input.
|
|
62
|
+
*
|
|
63
|
+
* @param s - the schedule fields to validate.
|
|
64
|
+
* @returns `{ ok: true }` or `{ ok: false, message }`.
|
|
65
|
+
*/
|
|
66
|
+
export function validateScheduleFields(s: RotationSchedule): ScheduleValidation {
|
|
67
|
+
switch (s.scheduleKind) {
|
|
68
|
+
case "every": {
|
|
69
|
+
if (
|
|
70
|
+
s.intervalSeconds === null ||
|
|
71
|
+
!Number.isInteger(s.intervalSeconds) ||
|
|
72
|
+
s.intervalSeconds < MIN_INTERVAL_SECONDS ||
|
|
73
|
+
s.intervalSeconds > MAX_INTERVAL_SECONDS
|
|
74
|
+
) {
|
|
75
|
+
return {
|
|
76
|
+
ok: false,
|
|
77
|
+
message: `interval_seconds must be an integer between ${MIN_INTERVAL_SECONDS} and ${MAX_INTERVAL_SECONDS}.`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
return { ok: true };
|
|
81
|
+
}
|
|
82
|
+
case "daily": {
|
|
83
|
+
if (!s.dailyTime || !DAILY_TIME_RE.test(s.dailyTime)) {
|
|
84
|
+
return { ok: false, message: 'daily_time must be "HH:MM" (24-hour, UTC).' };
|
|
85
|
+
}
|
|
86
|
+
return { ok: true };
|
|
87
|
+
}
|
|
88
|
+
case "cron": {
|
|
89
|
+
if (!s.cronExpression || s.cronExpression.trim() === "") {
|
|
90
|
+
return { ok: false, message: "cron_expression is required for schedule_kind 'cron'." };
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
parseCron(s.cronExpression);
|
|
94
|
+
} catch (err) {
|
|
95
|
+
return {
|
|
96
|
+
ok: false,
|
|
97
|
+
message: err instanceof CronParseError ? err.message : "Invalid cron expression.",
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
return { ok: true };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Computes the next rotation instant (UTC ISO-8601) strictly after `from`.
|
|
107
|
+
*
|
|
108
|
+
* Assumes the schedule already passed `validateScheduleFields`. `daily` and
|
|
109
|
+
* `cron` are evaluated in UTC (see `cron.ts`).
|
|
110
|
+
*
|
|
111
|
+
* @param s - the schedule fields.
|
|
112
|
+
* @param from - the reference instant.
|
|
113
|
+
* @returns the next rotation time as a UTC ISO string.
|
|
114
|
+
*/
|
|
115
|
+
export function computeNextRotation(s: RotationSchedule, from: Date): string {
|
|
116
|
+
switch (s.scheduleKind) {
|
|
117
|
+
case "every": {
|
|
118
|
+
const secs = s.intervalSeconds ?? MIN_INTERVAL_SECONDS;
|
|
119
|
+
return new Date(from.getTime() + secs * 1000).toISOString();
|
|
120
|
+
}
|
|
121
|
+
case "daily": {
|
|
122
|
+
const [hh, mm] = (s.dailyTime ?? "00:00").split(":").map(Number) as [number, number];
|
|
123
|
+
const next = new Date(from.getTime());
|
|
124
|
+
next.setUTCHours(hh, mm, 0, 0);
|
|
125
|
+
if (next.getTime() <= from.getTime()) {
|
|
126
|
+
next.setUTCDate(next.getUTCDate() + 1);
|
|
127
|
+
}
|
|
128
|
+
return next.toISOString();
|
|
129
|
+
}
|
|
130
|
+
case "cron":
|
|
131
|
+
return cronNextRun(s.cronExpression ?? "* * * * *", from);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** `computeNextRotation` that never throws — falls back to +1h on a poison row. */
|
|
136
|
+
function safeComputeNextRotation(s: RotationSchedule, from: Date, logger: Logger): string {
|
|
137
|
+
try {
|
|
138
|
+
return computeNextRotation(s, from);
|
|
139
|
+
} catch (err) {
|
|
140
|
+
logger.error(
|
|
141
|
+
{ err: err instanceof Error ? err.message : String(err), event: "rotation_next_compute_failed" },
|
|
142
|
+
"Failed to compute next rotation time; falling back to +1h.",
|
|
143
|
+
);
|
|
144
|
+
return new Date(from.getTime() + 3600 * 1000).toISOString();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
149
|
+
/* Runner */
|
|
150
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
151
|
+
|
|
152
|
+
/** Dependency bundle for `runRotation`. */
|
|
153
|
+
export interface RotationRunnerDeps {
|
|
154
|
+
/** Drizzle handle. */
|
|
155
|
+
db: DrizzleDB;
|
|
156
|
+
/** Letta client wrapper. */
|
|
157
|
+
lettaClient: LettaClient;
|
|
158
|
+
/** Logger for this rotation. */
|
|
159
|
+
logger: Logger;
|
|
160
|
+
/** Override "now" for deterministic tests. Defaults to `new Date()`. */
|
|
161
|
+
now?: () => Date;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Result of a single rotation attempt. */
|
|
165
|
+
export interface RotationOutcome {
|
|
166
|
+
status: "ok" | "error";
|
|
167
|
+
/** The new prepared conversation id (only on success). */
|
|
168
|
+
conversationId?: string;
|
|
169
|
+
/** Error message (only on failure). */
|
|
170
|
+
error?: string;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Runs one rotation for `row` and persists the result. Never throws — the
|
|
175
|
+
* outcome is reported via the return value and the row's `last_status`.
|
|
176
|
+
*
|
|
177
|
+
* @param deps - db + Letta client + logger.
|
|
178
|
+
* @param row - the rotating-message row to rotate.
|
|
179
|
+
* @returns the rotation outcome.
|
|
180
|
+
*/
|
|
181
|
+
export async function runRotation(
|
|
182
|
+
deps: RotationRunnerDeps,
|
|
183
|
+
row: RotatingMessageRow,
|
|
184
|
+
): Promise<RotationOutcome> {
|
|
185
|
+
const now = (deps.now ?? (() => new Date()))();
|
|
186
|
+
const nowIso = now.toISOString();
|
|
187
|
+
const nextRotationAt = safeComputeNextRotation(row, now, deps.logger);
|
|
188
|
+
const previousConversationId = row.currentConversationId;
|
|
189
|
+
|
|
190
|
+
let createdConversationId: string | null = null;
|
|
191
|
+
try {
|
|
192
|
+
const conv = await deps.lettaClient.createConversation(row.lettaAgentId);
|
|
193
|
+
createdConversationId = conv.id;
|
|
194
|
+
|
|
195
|
+
await sendPromptAndDrain(deps.lettaClient, conv.id, row.lettaAgentId, row.prompt);
|
|
196
|
+
|
|
197
|
+
// Delete the prompt's user message so the conversation opens with the
|
|
198
|
+
// agent's reply. listMessages is oldest-first; the first user_message is
|
|
199
|
+
// the prompt we just sent.
|
|
200
|
+
const messages = await deps.lettaClient.listMessages(conv.id);
|
|
201
|
+
const firstUser = messages.find(
|
|
202
|
+
(m) => (m as { message_type?: string }).message_type === "user_message",
|
|
203
|
+
);
|
|
204
|
+
if (firstUser?.id) {
|
|
205
|
+
await deps.lettaClient.deleteMessage(row.lettaAgentId, firstUser.id);
|
|
206
|
+
} else {
|
|
207
|
+
deps.logger.warn(
|
|
208
|
+
{ rotating_message_id: row.id, conversation_id: conv.id, event: "rotation_no_user_message" },
|
|
209
|
+
"Rotation produced no user_message to delete; storing conversation as-is.",
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
deps.db
|
|
214
|
+
.update(rotatingMessage)
|
|
215
|
+
.set({
|
|
216
|
+
currentConversationId: conv.id,
|
|
217
|
+
lastStatus: "ok",
|
|
218
|
+
lastError: null,
|
|
219
|
+
lastRotatedAt: nowIso,
|
|
220
|
+
nextRotationAt,
|
|
221
|
+
updatedAt: nowIso,
|
|
222
|
+
})
|
|
223
|
+
.where(eq(rotatingMessage.id, row.id))
|
|
224
|
+
.run();
|
|
225
|
+
|
|
226
|
+
// Best-effort cleanup of the now-superseded conversation.
|
|
227
|
+
if (previousConversationId && previousConversationId !== conv.id) {
|
|
228
|
+
try {
|
|
229
|
+
await deps.lettaClient.deleteConversation(previousConversationId);
|
|
230
|
+
} catch (err) {
|
|
231
|
+
deps.logger.warn(
|
|
232
|
+
{
|
|
233
|
+
rotating_message_id: row.id,
|
|
234
|
+
conversation_id: previousConversationId,
|
|
235
|
+
err: err instanceof Error ? err.message : String(err),
|
|
236
|
+
event: "rotation_previous_cleanup_failed",
|
|
237
|
+
},
|
|
238
|
+
"Failed to delete the previous prepared conversation; leaving it orphaned.",
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
deps.logger.info(
|
|
244
|
+
{
|
|
245
|
+
rotating_message_id: row.id,
|
|
246
|
+
letta_agent_id: row.lettaAgentId,
|
|
247
|
+
conversation_id: conv.id,
|
|
248
|
+
next_rotation_at: nextRotationAt,
|
|
249
|
+
event: "rotation_ok",
|
|
250
|
+
},
|
|
251
|
+
"Rotating starting message refreshed.",
|
|
252
|
+
);
|
|
253
|
+
return { status: "ok", conversationId: conv.id };
|
|
254
|
+
} catch (err) {
|
|
255
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
256
|
+
|
|
257
|
+
// Best-effort drop of the half-built conversation so failures don't
|
|
258
|
+
// accumulate orphans. The last-good current_conversation_id is untouched.
|
|
259
|
+
if (createdConversationId) {
|
|
260
|
+
try {
|
|
261
|
+
await deps.lettaClient.deleteConversation(createdConversationId);
|
|
262
|
+
} catch {
|
|
263
|
+
/* best-effort */
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
deps.db
|
|
268
|
+
.update(rotatingMessage)
|
|
269
|
+
.set({
|
|
270
|
+
lastStatus: "error",
|
|
271
|
+
lastError: message,
|
|
272
|
+
nextRotationAt,
|
|
273
|
+
updatedAt: nowIso,
|
|
274
|
+
})
|
|
275
|
+
.where(eq(rotatingMessage.id, row.id))
|
|
276
|
+
.run();
|
|
277
|
+
|
|
278
|
+
deps.logger.error(
|
|
279
|
+
{
|
|
280
|
+
rotating_message_id: row.id,
|
|
281
|
+
letta_agent_id: row.lettaAgentId,
|
|
282
|
+
err: message,
|
|
283
|
+
next_rotation_at: nextRotationAt,
|
|
284
|
+
event: "rotation_error",
|
|
285
|
+
},
|
|
286
|
+
"Rotation failed; keeping the previous prepared conversation.",
|
|
287
|
+
);
|
|
288
|
+
return { status: "error", error: message };
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Sends `prompt` to `conversationId` and drains the stream to its terminal
|
|
294
|
+
* `result`. Throws when the run produced no terminal result or finished
|
|
295
|
+
* unsuccessfully. Always tears the subprocess down via `handle.close()`.
|
|
296
|
+
*/
|
|
297
|
+
async function sendPromptAndDrain(
|
|
298
|
+
client: LettaClient,
|
|
299
|
+
conversationId: string,
|
|
300
|
+
lettaAgentId: string,
|
|
301
|
+
prompt: string,
|
|
302
|
+
): Promise<void> {
|
|
303
|
+
const handle = await client.streamUserMessage({
|
|
304
|
+
conversationId,
|
|
305
|
+
lettaAgentId,
|
|
306
|
+
userMessage: prompt,
|
|
307
|
+
});
|
|
308
|
+
let sawResult = false;
|
|
309
|
+
let success = false;
|
|
310
|
+
try {
|
|
311
|
+
for await (const msg of handle.events) {
|
|
312
|
+
if (msg.type === "result") {
|
|
313
|
+
sawResult = true;
|
|
314
|
+
success = msg.success;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
} finally {
|
|
318
|
+
handle.close();
|
|
319
|
+
}
|
|
320
|
+
if (!sawResult) {
|
|
321
|
+
throw new Error("Agent run produced no terminal result.");
|
|
322
|
+
}
|
|
323
|
+
if (!success) {
|
|
324
|
+
throw new Error("Agent run did not complete successfully.");
|
|
325
|
+
}
|
|
326
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process scheduler for **rotating starting messages**.
|
|
3
|
+
*
|
|
4
|
+
* ai-hub deploys as one long-running Node process (in-process
|
|
5
|
+
* `better-sqlite3` + self-hosted Letta), so a simple tick loop is the right
|
|
6
|
+
* tool — no external cron, queue, or multi-instance coordination needed.
|
|
7
|
+
*
|
|
8
|
+
* Each tick selects non-archived `rotating_message` rows whose
|
|
9
|
+
* `next_rotation_at <= now` and runs them **serially** through `runRotation`.
|
|
10
|
+
* An in-memory `inFlight` set prevents a slow rotation from being re-picked by
|
|
11
|
+
* the next tick. `runRotation` advances `next_rotation_at` itself, so a row is
|
|
12
|
+
* naturally not due again until its interval elapses.
|
|
13
|
+
*
|
|
14
|
+
* The scheduler is started once per process from `createFetchHandler`
|
|
15
|
+
* (`ensureRotationScheduler`) — that's the single construction point both the
|
|
16
|
+
* dev `node:http` runner and the Next.js wrapper go through. It is **skipped
|
|
17
|
+
* under tests** (`VITEST` / `NODE_ENV=test`) and via
|
|
18
|
+
* `AI_HUB_DISABLE_ROTATION_SCHEDULER=1`, so the existing server-boot test
|
|
19
|
+
* suite is unaffected.
|
|
20
|
+
*
|
|
21
|
+
* @see docs/server/rotating-messages.md
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { and, eq, lte } from "drizzle-orm";
|
|
25
|
+
|
|
26
|
+
import type { DrizzleDB } from "../db/connection.js";
|
|
27
|
+
import { rotatingMessage } from "../db/schema.js";
|
|
28
|
+
import type { LettaClient } from "../letta/client.js";
|
|
29
|
+
import type { Logger } from "../observability/logger.js";
|
|
30
|
+
|
|
31
|
+
import { runRotation } from "./rotationRunner.js";
|
|
32
|
+
|
|
33
|
+
/** Default tick cadence. Rotations are hourly/daily, so 30s polling is ample. */
|
|
34
|
+
const DEFAULT_TICK_MS = 30_000;
|
|
35
|
+
|
|
36
|
+
/** Dependency bundle for the scheduler. */
|
|
37
|
+
export interface RotationSchedulerDeps {
|
|
38
|
+
db: DrizzleDB;
|
|
39
|
+
lettaClient: LettaClient;
|
|
40
|
+
logger: Logger;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Handle for stopping a running scheduler. */
|
|
44
|
+
export interface RotationScheduler {
|
|
45
|
+
/** Stops the tick loop. Idempotent. */
|
|
46
|
+
stop(): void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Options for `startRotationScheduler`. */
|
|
50
|
+
export interface StartRotationSchedulerOptions {
|
|
51
|
+
/** Tick interval in ms. Default 30000. Tests pass a small value. */
|
|
52
|
+
intervalMs?: number;
|
|
53
|
+
/** Skip the immediate kick-off tick (tests drive `tickOnce` directly). */
|
|
54
|
+
skipInitialTick?: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Starts a tick loop and returns a handle. Most callers want
|
|
59
|
+
* `ensureRotationScheduler` (process singleton); this is the testable core.
|
|
60
|
+
*
|
|
61
|
+
* @param deps - db + Letta client + logger.
|
|
62
|
+
* @param opts - tick cadence + initial-tick control.
|
|
63
|
+
* @returns a scheduler handle with `stop()`.
|
|
64
|
+
*/
|
|
65
|
+
export function startRotationScheduler(
|
|
66
|
+
deps: RotationSchedulerDeps,
|
|
67
|
+
opts: StartRotationSchedulerOptions = {},
|
|
68
|
+
): RotationScheduler {
|
|
69
|
+
const intervalMs = opts.intervalMs ?? DEFAULT_TICK_MS;
|
|
70
|
+
const inFlight = new Set<string>();
|
|
71
|
+
let stopped = false;
|
|
72
|
+
let ticking = false;
|
|
73
|
+
|
|
74
|
+
const tick = async (): Promise<void> => {
|
|
75
|
+
if (stopped || ticking) return;
|
|
76
|
+
ticking = true;
|
|
77
|
+
try {
|
|
78
|
+
await tickOnce(deps, inFlight);
|
|
79
|
+
} catch (err) {
|
|
80
|
+
deps.logger.error(
|
|
81
|
+
{ err: err instanceof Error ? err.message : String(err), event: "rotation_tick_failed" },
|
|
82
|
+
"Rotation scheduler tick failed.",
|
|
83
|
+
);
|
|
84
|
+
} finally {
|
|
85
|
+
ticking = false;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const timer = setInterval(() => void tick(), intervalMs);
|
|
90
|
+
// Never let the scheduler alone keep the process alive.
|
|
91
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
92
|
+
|
|
93
|
+
if (!opts.skipInitialTick) {
|
|
94
|
+
void tick();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
stop(): void {
|
|
99
|
+
stopped = true;
|
|
100
|
+
clearInterval(timer);
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Runs every due, non-archived rotation once, serially. Exported for tests
|
|
107
|
+
* (drive a single tick deterministically without a timer).
|
|
108
|
+
*
|
|
109
|
+
* @param deps - db + Letta client + logger.
|
|
110
|
+
* @param inFlight - shared set guarding against re-entrancy across ticks.
|
|
111
|
+
*/
|
|
112
|
+
export async function tickOnce(
|
|
113
|
+
deps: RotationSchedulerDeps,
|
|
114
|
+
inFlight: Set<string> = new Set(),
|
|
115
|
+
): Promise<void> {
|
|
116
|
+
const nowIso = new Date().toISOString();
|
|
117
|
+
const due = deps.db
|
|
118
|
+
.select()
|
|
119
|
+
.from(rotatingMessage)
|
|
120
|
+
.where(and(eq(rotatingMessage.isArchived, false), lte(rotatingMessage.nextRotationAt, nowIso)))
|
|
121
|
+
.all();
|
|
122
|
+
|
|
123
|
+
for (const row of due) {
|
|
124
|
+
if (inFlight.has(row.id)) continue;
|
|
125
|
+
inFlight.add(row.id);
|
|
126
|
+
try {
|
|
127
|
+
await runRotation(deps, row);
|
|
128
|
+
} finally {
|
|
129
|
+
inFlight.delete(row.id);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
135
|
+
/* Process singleton */
|
|
136
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
137
|
+
|
|
138
|
+
let singleton: RotationScheduler | null = null;
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Starts the rotation scheduler once per process and returns it (or `null`
|
|
142
|
+
* when intentionally disabled). Idempotent — subsequent calls return the
|
|
143
|
+
* already-running instance, so building multiple Fetch handlers in one process
|
|
144
|
+
* never spawns competing loops.
|
|
145
|
+
*
|
|
146
|
+
* Disabled (returns `null`, starts nothing) when:
|
|
147
|
+
* - `AI_HUB_DISABLE_ROTATION_SCHEDULER=1`, or
|
|
148
|
+
* - running under tests (`VITEST` set, or `NODE_ENV === "test"`).
|
|
149
|
+
*
|
|
150
|
+
* @param deps - db + Letta client + logger.
|
|
151
|
+
* @param env - env source (defaults to `process.env`).
|
|
152
|
+
* @returns the scheduler handle, or `null` when disabled.
|
|
153
|
+
*/
|
|
154
|
+
export function ensureRotationScheduler(
|
|
155
|
+
deps: RotationSchedulerDeps,
|
|
156
|
+
env: Record<string, string | undefined> = process.env,
|
|
157
|
+
): RotationScheduler | null {
|
|
158
|
+
if (singleton) return singleton;
|
|
159
|
+
if (env.AI_HUB_DISABLE_ROTATION_SCHEDULER === "1") return null;
|
|
160
|
+
if (env.VITEST || env.NODE_ENV === "test") return null;
|
|
161
|
+
singleton = startRotationScheduler(deps);
|
|
162
|
+
deps.logger.info({ event: "rotation_scheduler_started" }, "Rotating-message scheduler started.");
|
|
163
|
+
return singleton;
|
|
164
|
+
}
|