talon-agent 3.33.4 → 3.34.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/package.json +5 -2
- package/src/app.ts +16 -8
- package/src/backend/claude-sdk/handler.ts +428 -415
- package/src/backend/claude-sdk/stream.ts +76 -53
- package/src/backend/codex/handler/message.ts +275 -366
- package/src/backend/codex/handler/rollout-accounting.ts +137 -0
- package/src/backend/openai-agents/handler/message.ts +282 -356
- package/src/backend/remote-server/chat-turn.ts +66 -122
- package/src/backend/remote-server/index.ts +4 -0
- package/src/backend/remote-server/mcp.ts +73 -9
- package/src/backend/remote-server/model-catalog/presentation.ts +269 -228
- package/src/backend/remote-server/sessions.ts +2 -2
- package/src/backend/remote-server/turn.ts +58 -55
- package/src/backend/shared/cache-telemetry.ts +17 -1
- package/src/backend/shared/handler-to-events.ts +11 -16
- package/src/backend/shared/index.ts +14 -15
- package/src/backend/shared/result-events.ts +30 -0
- package/src/backend/shared/turn-phases.ts +277 -0
- package/src/bootstrap.ts +120 -79
- package/src/cli/setup.ts +375 -349
- package/src/core/background/cron-spec.ts +273 -0
- package/src/core/background/heartbeat/agent.ts +167 -104
- package/src/core/background/triggers/exit.ts +19 -2
- package/src/core/background/triggers/resume.ts +14 -7
- package/src/core/background/triggers/state.ts +9 -0
- package/src/core/engine/backend-controller/pool.ts +2 -0
- package/src/core/engine/backend-controller/state.ts +25 -12
- package/src/core/engine/gateway-actions/cron.ts +28 -292
- package/src/core/engine/gateway-routes.ts +239 -0
- package/src/core/engine/gateway.ts +66 -238
- package/src/core/mcp-hub/child-transport.ts +215 -0
- package/src/core/mcp-hub/children.ts +72 -13
- package/src/core/mcp-hub/index.ts +32 -13
- package/src/core/models/active-model.ts +29 -6
- package/src/core/vfs/mounts/files.ts +128 -115
- package/src/core/weaver/shuttle.ts +33 -2
- package/src/core/weaver/weaver.ts +37 -6
- package/src/frontend/discord/callbacks/components/agent-buttons.ts +82 -0
- package/src/frontend/discord/callbacks/components/backend-select.ts +149 -0
- package/src/frontend/discord/callbacks/components/effort.ts +72 -0
- package/src/frontend/discord/callbacks/components/index.ts +120 -0
- package/src/frontend/discord/callbacks/components/metrics.ts +24 -0
- package/src/frontend/discord/callbacks/components/model-nav.ts +93 -0
- package/src/frontend/discord/callbacks/components/model-select.ts +118 -0
- package/src/frontend/discord/callbacks/components/model.ts +33 -0
- package/src/frontend/discord/callbacks/components/pulse.ts +93 -0
- package/src/frontend/discord/callbacks/components/settings.ts +243 -0
- package/src/frontend/discord/callbacks/components/types.ts +34 -0
- package/src/frontend/discord/callbacks/index.ts +4 -4
- package/src/frontend/discord/connection.ts +36 -0
- package/src/frontend/discord/diagnostics.ts +62 -0
- package/src/frontend/discord/guild-policy.ts +89 -0
- package/src/frontend/discord/index.ts +44 -305
- package/src/frontend/discord/outbound.ts +61 -0
- package/src/frontend/discord/ready.ts +73 -0
- package/src/frontend/discord/runtime.ts +55 -0
- package/src/frontend/native/chat-lifecycle.ts +39 -0
- package/src/frontend/native/chat-wire.ts +69 -0
- package/src/frontend/native/context.ts +106 -0
- package/src/frontend/native/control.ts +78 -0
- package/src/frontend/native/emit.ts +167 -0
- package/src/frontend/native/empty-chat-sweep.ts +50 -0
- package/src/frontend/native/handlers.ts +121 -0
- package/src/frontend/native/history.ts +101 -0
- package/src/frontend/native/index.ts +90 -1293
- package/src/frontend/native/media.ts +43 -0
- package/src/frontend/native/models.ts +221 -0
- package/src/frontend/native/queue.ts +47 -0
- package/src/frontend/native/reset.ts +50 -0
- package/src/frontend/native/routes/chats.ts +115 -0
- package/src/frontend/native/routes/daemon.ts +70 -0
- package/src/frontend/native/routes/host.ts +151 -0
- package/src/frontend/native/routes/index.ts +22 -0
- package/src/frontend/native/routes/mesh.ts +72 -0
- package/src/frontend/native/routes/models.ts +54 -0
- package/src/frontend/native/routes/params.ts +29 -0
- package/src/frontend/native/routes/pre-auth.ts +94 -0
- package/src/frontend/native/routes/table.ts +92 -0
- package/src/frontend/native/runtime.ts +109 -0
- package/src/frontend/native/server.ts +30 -555
- package/src/frontend/native/status.ts +26 -0
- package/src/frontend/native/tool-result.ts +48 -0
- package/src/frontend/native/turn.ts +341 -0
- package/src/frontend/telegram/admin.ts +75 -52
- package/src/frontend/whatsapp/access.ts +67 -0
- package/src/frontend/whatsapp/connection.ts +280 -0
- package/src/frontend/whatsapp/inbound.ts +327 -0
- package/src/frontend/whatsapp/index.ts +28 -599
- package/src/frontend/whatsapp/runtime.ts +74 -0
- package/src/storage/metrics.ts +18 -0
- package/src/storage/session-record.ts +29 -0
- package/src/storage/sessions.ts +24 -0
- package/src/storage/trigger-store.ts +8 -0
- package/src/util/boot-timer.ts +31 -0
- package/src/util/concurrency.ts +28 -0
- package/src/util/watchdog.ts +32 -7
- package/src/frontend/discord/callbacks/components.ts +0 -793
- package/src/frontend/discord/callbacks/shared.ts +0 -22
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
getAllTriggers,
|
|
10
10
|
updateTrigger,
|
|
11
11
|
RESTART_KILL_ERROR,
|
|
12
|
+
SHUTDOWN_KILL_ERROR,
|
|
12
13
|
type Trigger,
|
|
13
14
|
} from "../../../storage/trigger-store.js";
|
|
14
15
|
import { log, logError } from "../../../util/log.js";
|
|
@@ -50,19 +51,25 @@ export async function resumeAfterRestart(): Promise<void> {
|
|
|
50
51
|
// Late death notice. Two cases earn one:
|
|
51
52
|
// - never fired at all (the old rule) — the chat heard nothing
|
|
52
53
|
// from this trigger, so its termination is news; and
|
|
53
|
-
// - killed by THIS restart
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
54
|
+
// - killed by THIS restart — either the crash path
|
|
55
|
+
// (recoverInterrupted stamped RESTART_KILL_ERROR) or the clean
|
|
56
|
+
// path (shutdownTriggers stamped SHUTDOWN_KILL_ERROR and
|
|
57
|
+
// finalizeExit deliberately skipped the wake because the
|
|
58
|
+
// backend pool was already going away). Even a multi-fire
|
|
59
|
+
// watcher that signalled mid-run was still an active promise
|
|
60
|
+
// when the process died, and without this wake the chat never
|
|
61
|
+
// learns its watcher is gone. (Previously gated on
|
|
62
|
+
// lastFireAt === undefined alone, which silently dropped
|
|
63
|
+
// exactly those watchers.)
|
|
59
64
|
// Triggers that exited on their own already fired their terminal
|
|
60
65
|
// wake (lastFireAt set, no marker) — they stay silent here.
|
|
61
66
|
if (
|
|
62
67
|
t.status === "terminated" &&
|
|
63
68
|
t.endedAt &&
|
|
64
69
|
Date.now() - t.endedAt < 5 * 60_000 &&
|
|
65
|
-
(t.lastFireAt === undefined ||
|
|
70
|
+
(t.lastFireAt === undefined ||
|
|
71
|
+
t.lastError === RESTART_KILL_ERROR ||
|
|
72
|
+
t.lastError === SHUTDOWN_KILL_ERROR)
|
|
66
73
|
) {
|
|
67
74
|
await fireWake(t.id, "terminated", t.lastError, /* terminal */ true);
|
|
68
75
|
}
|
|
@@ -20,6 +20,14 @@ export type TriggerDeps = {
|
|
|
20
20
|
/** Reassignable on a holder object so submodules see the injected deps. */
|
|
21
21
|
export const depsHolder: { deps: TriggerDeps | null } = { deps: null };
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Set by shutdownTriggers for the rest of the process lifetime. finalizeExit
|
|
25
|
+
* consults it so the children it kills don't dispatch wakes into a backend
|
|
26
|
+
* pool that is being torn down at the same time. initTriggers clears it so a
|
|
27
|
+
* fresh lifecycle (next boot, or the next test) starts clean.
|
|
28
|
+
*/
|
|
29
|
+
export const lifecycle = { shuttingDown: false };
|
|
30
|
+
|
|
23
31
|
/** Live child handles, keyed by trigger id. */
|
|
24
32
|
export const children = new Map<string, ChildProcess>();
|
|
25
33
|
export const timeouts = new Map<string, ReturnType<typeof setTimeout>>();
|
|
@@ -43,6 +51,7 @@ export const WARDEN_GRACE_SLACK_MS = 2_000;
|
|
|
43
51
|
|
|
44
52
|
export function initTriggers(d: TriggerDeps): void {
|
|
45
53
|
depsHolder.deps = d;
|
|
54
|
+
lifecycle.shuttingDown = false;
|
|
46
55
|
log("triggers", "Initialized");
|
|
47
56
|
}
|
|
48
57
|
|
|
@@ -14,6 +14,7 @@ import { log, logWarn } from "../../../util/log.js";
|
|
|
14
14
|
import { roleHolder } from "./holders.js";
|
|
15
15
|
import {
|
|
16
16
|
pool,
|
|
17
|
+
initInFlight,
|
|
17
18
|
bindings,
|
|
18
19
|
listeners,
|
|
19
20
|
ctx,
|
|
@@ -285,6 +286,7 @@ export async function cleanupBackendPool(): Promise<void> {
|
|
|
285
286
|
/** Test-only state reset. */
|
|
286
287
|
export function resetBackendPoolForTest(): void {
|
|
287
288
|
pool.clear();
|
|
289
|
+
initInFlight.clear();
|
|
288
290
|
bindings.clear();
|
|
289
291
|
ctx.initCtx = null;
|
|
290
292
|
ctx.poolConfig = null;
|
|
@@ -29,6 +29,8 @@ export interface PoolEntry {
|
|
|
29
29
|
|
|
30
30
|
/** id → entry. */
|
|
31
31
|
export const pool = new Map<string, PoolEntry>();
|
|
32
|
+
/** id → init in progress; concurrent callers await the same promise. */
|
|
33
|
+
export const initInFlight = new Map<string, Promise<PoolEntry>>();
|
|
32
34
|
/** holder → backend id (only present for holders currently bound). */
|
|
33
35
|
export const bindings = new Map<BackendHolder, string>();
|
|
34
36
|
|
|
@@ -56,6 +58,8 @@ export async function ensurePoolEntry(
|
|
|
56
58
|
): Promise<PoolEntry> {
|
|
57
59
|
const existing = pool.get(id);
|
|
58
60
|
if (existing) return existing;
|
|
61
|
+
const inFlight = initInFlight.get(id);
|
|
62
|
+
if (inFlight) return inFlight;
|
|
59
63
|
|
|
60
64
|
const factory = getBackend(id);
|
|
61
65
|
if (!factory) {
|
|
@@ -64,22 +68,31 @@ export async function ensurePoolEntry(
|
|
|
64
68
|
.join(", ");
|
|
65
69
|
throw new Error(`Unknown backend "${id}" — known: ${known}`);
|
|
66
70
|
}
|
|
67
|
-
|
|
71
|
+
const initCtx = ctx.initCtx;
|
|
72
|
+
if (!initCtx) {
|
|
68
73
|
throw new Error(
|
|
69
74
|
"Backend pool not initialised — call initBackendPool first",
|
|
70
75
|
);
|
|
71
76
|
}
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
77
|
+
const init = (async (): Promise<PoolEntry> => {
|
|
78
|
+
const instance = await factory.init(config, initCtx);
|
|
79
|
+
const entry: PoolEntry = {
|
|
80
|
+
id: factory.id,
|
|
81
|
+
label: factory.label,
|
|
82
|
+
backend: instance.backend,
|
|
83
|
+
cleanup: instance.cleanup,
|
|
84
|
+
holders: new Set(),
|
|
85
|
+
};
|
|
86
|
+
pool.set(factory.id, entry);
|
|
87
|
+
log("backend-controller", `Pool init: ${factory.label} (${factory.id})`);
|
|
88
|
+
return entry;
|
|
89
|
+
})();
|
|
90
|
+
initInFlight.set(id, init);
|
|
91
|
+
try {
|
|
92
|
+
return await init;
|
|
93
|
+
} finally {
|
|
94
|
+
initInFlight.delete(id);
|
|
95
|
+
}
|
|
83
96
|
}
|
|
84
97
|
|
|
85
98
|
/** Drop a holder from an entry; clean up the entry if its refcount hits zero. */
|
|
@@ -8,14 +8,12 @@ import {
|
|
|
8
8
|
getCronJobsForChat,
|
|
9
9
|
updateCronJob,
|
|
10
10
|
deleteCronJob,
|
|
11
|
-
validateCronExpression,
|
|
12
11
|
generateCronId,
|
|
13
12
|
describeSchedule,
|
|
14
13
|
nextRunAt,
|
|
15
|
-
type CronJobType,
|
|
16
|
-
type CatchupPolicy,
|
|
17
14
|
} from "../../../storage/cron-store.js";
|
|
18
15
|
import { runJobNow } from "../../background/cron.js";
|
|
16
|
+
import { parseCronSpec } from "../../background/cron-spec.js";
|
|
19
17
|
import { log } from "../../../util/log.js";
|
|
20
18
|
import type { Backend } from "../../agent-runtime/capabilities.js";
|
|
21
19
|
import {
|
|
@@ -27,31 +25,6 @@ import {
|
|
|
27
25
|
import { validateJobModelOverride } from "./shared.js";
|
|
28
26
|
import type { SharedActionHandlers } from "./types.js";
|
|
29
27
|
|
|
30
|
-
// ── Scheduler field parsing (shared by create/edit cron) ────────────────────
|
|
31
|
-
|
|
32
|
-
/** The scheduler ticks once a minute, so sub-minute intervals are meaningless. */
|
|
33
|
-
const MIN_INTERVAL_SECONDS = 60;
|
|
34
|
-
const CATCHUP_POLICIES = new Set<CatchupPolicy>(["skip", "once", "all"]);
|
|
35
|
-
|
|
36
|
-
/** True for a body field that was actually supplied (not absent/blank). */
|
|
37
|
-
function provided(v: unknown): boolean {
|
|
38
|
-
return v !== undefined && v !== null && v !== "";
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Parse an instant given as an ISO-8601 string or epoch-ms number into epoch
|
|
43
|
-
* ms. Returns undefined when the field is absent or unparseable — callers
|
|
44
|
-
* distinguish the two via `provided()`.
|
|
45
|
-
*/
|
|
46
|
-
function parseInstant(v: unknown): number | undefined {
|
|
47
|
-
if (!provided(v)) return undefined;
|
|
48
|
-
if (typeof v === "number") return Number.isFinite(v) ? v : undefined;
|
|
49
|
-
const s = String(v).trim();
|
|
50
|
-
if (/^\d+$/.test(s)) return Number(s);
|
|
51
|
-
const d = Date.parse(s);
|
|
52
|
-
return Number.isFinite(d) ? d : undefined;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
28
|
/**
|
|
56
29
|
* Validate a cron `query` job's model + optional provider override. Cron runs
|
|
57
30
|
* isolated, so unlike triggers it may target a different provider — the backend
|
|
@@ -113,147 +86,38 @@ function validateCronBackgroundCapability(
|
|
|
113
86
|
|
|
114
87
|
export const cronHandlers: SharedActionHandlers = {
|
|
115
88
|
create_cron_job: async (body, chatId) => {
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
const
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
return { ok: false, error: "Content too long (max 10,000 chars)" };
|
|
129
|
-
|
|
130
|
-
// Cadence: exactly one of `schedule` (cron expression) or
|
|
131
|
-
// `every_seconds` (fixed interval).
|
|
132
|
-
const schedule = provided(body.schedule)
|
|
133
|
-
? String(body.schedule)
|
|
134
|
-
: undefined;
|
|
135
|
-
const hasEvery = provided(body.every_seconds);
|
|
136
|
-
if (!schedule && !hasEvery)
|
|
137
|
-
return {
|
|
138
|
-
ok: false,
|
|
139
|
-
error:
|
|
140
|
-
"Provide either 'schedule' (a cron expression) or 'every_seconds' (a fixed interval).",
|
|
141
|
-
};
|
|
142
|
-
if (schedule && hasEvery)
|
|
143
|
-
return {
|
|
144
|
-
ok: false,
|
|
145
|
-
error: "Provide only one of 'schedule' or 'every_seconds', not both.",
|
|
146
|
-
};
|
|
147
|
-
|
|
148
|
-
let everyMs: number | undefined;
|
|
149
|
-
if (hasEvery) {
|
|
150
|
-
const everySeconds = Number(body.every_seconds);
|
|
151
|
-
if (!Number.isFinite(everySeconds) || everySeconds < MIN_INTERVAL_SECONDS)
|
|
152
|
-
return {
|
|
153
|
-
ok: false,
|
|
154
|
-
error: `'every_seconds' must be a number >= ${MIN_INTERVAL_SECONDS} (the scheduler ticks once a minute).`,
|
|
155
|
-
};
|
|
156
|
-
everyMs = Math.round(everySeconds * 1000);
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
if (schedule) {
|
|
160
|
-
const validation = validateCronExpression(schedule, timezone);
|
|
161
|
-
if (!validation.valid)
|
|
162
|
-
return {
|
|
163
|
-
ok: false,
|
|
164
|
-
error: `Invalid cron expression: ${validation.error}`,
|
|
165
|
-
};
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// Lifecycle bounds.
|
|
169
|
-
const startAt = parseInstant(body.start_at);
|
|
170
|
-
if (provided(body.start_at) && startAt === undefined)
|
|
171
|
-
return {
|
|
172
|
-
ok: false,
|
|
173
|
-
error:
|
|
174
|
-
"Could not parse 'start_at' (use an ISO-8601 timestamp or epoch ms).",
|
|
175
|
-
};
|
|
176
|
-
const endAt = parseInstant(body.end_at);
|
|
177
|
-
if (provided(body.end_at) && endAt === undefined)
|
|
178
|
-
return {
|
|
179
|
-
ok: false,
|
|
180
|
-
error:
|
|
181
|
-
"Could not parse 'end_at' (use an ISO-8601 timestamp or epoch ms).",
|
|
182
|
-
};
|
|
183
|
-
if (startAt !== undefined && endAt !== undefined && endAt <= startAt)
|
|
184
|
-
return { ok: false, error: "'end_at' must be after 'start_at'." };
|
|
185
|
-
if (endAt !== undefined && endAt <= Date.now())
|
|
186
|
-
return {
|
|
187
|
-
ok: false,
|
|
188
|
-
error: "'end_at' is in the past — the job would never run.",
|
|
189
|
-
};
|
|
190
|
-
|
|
191
|
-
// Run cap. `once: true` is sugar for max_runs = 1 (one-shot).
|
|
192
|
-
let maxRuns: number | undefined;
|
|
193
|
-
if (body.once === true) maxRuns = 1;
|
|
194
|
-
else if (provided(body.max_runs)) {
|
|
195
|
-
const m = Number(body.max_runs);
|
|
196
|
-
if (!Number.isInteger(m) || m < 1)
|
|
197
|
-
return { ok: false, error: "'max_runs' must be a positive integer." };
|
|
198
|
-
maxRuns = m;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
// Missed-run catch-up policy. New jobs default to "once": a run that
|
|
202
|
-
// came due while Talon was down (or while the scheduler was wedged)
|
|
203
|
-
// replays a single time at startup instead of being lost silently — a
|
|
204
|
-
// live audit found one-shot reminders that missed their date under the
|
|
205
|
-
// old "skip" default and quietly rolled over a full year. Explicit
|
|
206
|
-
// "skip" remains available for jobs where a late run is worthless.
|
|
207
|
-
let catchup: CatchupPolicy = "once";
|
|
208
|
-
if (provided(body.catchup)) {
|
|
209
|
-
catchup = String(body.catchup) as CatchupPolicy;
|
|
210
|
-
if (!CATCHUP_POLICIES.has(catchup))
|
|
211
|
-
return {
|
|
212
|
-
ok: false,
|
|
213
|
-
error: "'catchup' must be one of: skip, once, all.",
|
|
214
|
-
};
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
// The overrides only make sense for "query" jobs (a "message" job just
|
|
218
|
-
// sends text — no model runs).
|
|
219
|
-
if ((model || provider || instructions) && jobType !== "query")
|
|
220
|
-
return {
|
|
221
|
-
ok: false,
|
|
222
|
-
error: "Model/provider/instructions only apply to 'query' jobs.",
|
|
223
|
-
};
|
|
224
|
-
if (provider && !model)
|
|
225
|
-
return {
|
|
226
|
-
ok: false,
|
|
227
|
-
error: "A 'provider' override also requires a 'model'.",
|
|
228
|
-
};
|
|
89
|
+
const parsed = parseCronSpec(body);
|
|
90
|
+
if (!parsed.ok) return { ok: false, error: parsed.error };
|
|
91
|
+
const spec = parsed.effective;
|
|
92
|
+
const {
|
|
93
|
+
name,
|
|
94
|
+
type: jobType,
|
|
95
|
+
schedule,
|
|
96
|
+
everyMs,
|
|
97
|
+
startAt,
|
|
98
|
+
endAt,
|
|
99
|
+
maxRuns,
|
|
100
|
+
} = spec;
|
|
229
101
|
|
|
230
102
|
// Validate the target backend/model up front so a bad id or unsupported
|
|
231
103
|
// backend is rejected here instead of silently failing at fire time.
|
|
232
104
|
if (jobType === "query") {
|
|
233
|
-
const modelErr = await validateCronModelOverride(
|
|
105
|
+
const modelErr = await validateCronModelOverride(
|
|
106
|
+
chatId,
|
|
107
|
+
spec.model,
|
|
108
|
+
spec.provider,
|
|
109
|
+
);
|
|
234
110
|
if (modelErr) return { ok: false, error: modelErr };
|
|
235
111
|
}
|
|
236
112
|
|
|
237
113
|
const id = generateCronId();
|
|
238
114
|
addCronJob({
|
|
115
|
+
...spec,
|
|
239
116
|
id,
|
|
240
117
|
chatId: String(chatId),
|
|
241
|
-
type: jobType,
|
|
242
|
-
content,
|
|
243
|
-
name,
|
|
244
118
|
enabled: true,
|
|
245
119
|
createdAt: Date.now(),
|
|
246
120
|
runCount: 0,
|
|
247
|
-
timezone,
|
|
248
|
-
...(schedule ? { schedule } : {}),
|
|
249
|
-
...(everyMs !== undefined ? { everyMs } : {}),
|
|
250
|
-
...(startAt !== undefined ? { startAt } : {}),
|
|
251
|
-
...(endAt !== undefined ? { endAt } : {}),
|
|
252
|
-
...(maxRuns !== undefined ? { maxRuns } : {}),
|
|
253
|
-
catchup,
|
|
254
|
-
...(model ? { model } : {}),
|
|
255
|
-
...(provider ? { provider } : {}),
|
|
256
|
-
...(instructions ? { instructions } : {}),
|
|
257
121
|
});
|
|
258
122
|
log(
|
|
259
123
|
"gateway",
|
|
@@ -268,7 +132,7 @@ export const cronHandlers: SharedActionHandlers = {
|
|
|
268
132
|
? `starts: ${new Date(startAt).toISOString()}`
|
|
269
133
|
: null,
|
|
270
134
|
endAt !== undefined ? `ends: ${new Date(endAt).toISOString()}` : null,
|
|
271
|
-
catchup ? `catch-up: ${catchup}` : null,
|
|
135
|
+
spec.catchup ? `catch-up: ${spec.catchup}` : null,
|
|
272
136
|
]
|
|
273
137
|
.filter(Boolean)
|
|
274
138
|
.join(", ");
|
|
@@ -335,157 +199,29 @@ export const cronHandlers: SharedActionHandlers = {
|
|
|
335
199
|
if (job.chatId !== String(chatId))
|
|
336
200
|
return { ok: false, error: "Job belongs to a different chat" };
|
|
337
201
|
|
|
338
|
-
const
|
|
339
|
-
if (
|
|
340
|
-
|
|
341
|
-
if (body.enabled !== undefined) updates.enabled = Boolean(body.enabled);
|
|
342
|
-
if (body.type !== undefined) updates.type = String(body.type);
|
|
343
|
-
if (body.timezone !== undefined)
|
|
344
|
-
updates.timezone = body.timezone ? String(body.timezone) : undefined;
|
|
345
|
-
|
|
346
|
-
// Cadence — schedule and every_seconds are mutually exclusive; setting
|
|
347
|
-
// one switches mode and clears the other.
|
|
348
|
-
const editSchedule = body.schedule !== undefined;
|
|
349
|
-
const editEvery = provided(body.every_seconds);
|
|
350
|
-
if (editSchedule && provided(body.schedule) && editEvery)
|
|
351
|
-
return {
|
|
352
|
-
ok: false,
|
|
353
|
-
error: "Provide only one of 'schedule' or 'every_seconds', not both.",
|
|
354
|
-
};
|
|
355
|
-
if (editSchedule && provided(body.schedule)) {
|
|
356
|
-
const tz = (updates.timezone as string | undefined) ?? job.timezone;
|
|
357
|
-
const v = validateCronExpression(String(body.schedule), tz);
|
|
358
|
-
if (!v.valid)
|
|
359
|
-
return { ok: false, error: `Invalid cron expression: ${v.error}` };
|
|
360
|
-
updates.schedule = String(body.schedule);
|
|
361
|
-
updates.everyMs = undefined;
|
|
362
|
-
}
|
|
363
|
-
if (editEvery) {
|
|
364
|
-
const everySeconds = Number(body.every_seconds);
|
|
365
|
-
if (!Number.isFinite(everySeconds) || everySeconds < MIN_INTERVAL_SECONDS)
|
|
366
|
-
return {
|
|
367
|
-
ok: false,
|
|
368
|
-
error: `'every_seconds' must be a number >= ${MIN_INTERVAL_SECONDS}.`,
|
|
369
|
-
};
|
|
370
|
-
updates.everyMs = Math.round(everySeconds * 1000);
|
|
371
|
-
updates.schedule = undefined;
|
|
372
|
-
}
|
|
202
|
+
const parsed = parseCronSpec(body, job);
|
|
203
|
+
if (!parsed.ok) return { ok: false, error: parsed.error };
|
|
204
|
+
const { updates, effective } = parsed;
|
|
373
205
|
|
|
374
|
-
// Lifecycle bounds (pass null/"" to clear).
|
|
375
|
-
if (body.start_at !== undefined) {
|
|
376
|
-
if (!provided(body.start_at)) updates.startAt = undefined;
|
|
377
|
-
else {
|
|
378
|
-
const s = parseInstant(body.start_at);
|
|
379
|
-
if (s === undefined)
|
|
380
|
-
return { ok: false, error: "Could not parse 'start_at'." };
|
|
381
|
-
updates.startAt = s;
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
if (body.end_at !== undefined) {
|
|
385
|
-
if (!provided(body.end_at)) updates.endAt = undefined;
|
|
386
|
-
else {
|
|
387
|
-
const e = parseInstant(body.end_at);
|
|
388
|
-
if (e === undefined)
|
|
389
|
-
return { ok: false, error: "Could not parse 'end_at'." };
|
|
390
|
-
if (e <= Date.now())
|
|
391
|
-
return {
|
|
392
|
-
ok: false,
|
|
393
|
-
error: "'end_at' is in the past — the job would never run.",
|
|
394
|
-
};
|
|
395
|
-
updates.endAt = e;
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
if (body.once === true) updates.maxRuns = 1;
|
|
399
|
-
else if (body.max_runs !== undefined) {
|
|
400
|
-
if (!provided(body.max_runs)) updates.maxRuns = undefined;
|
|
401
|
-
else {
|
|
402
|
-
const m = Number(body.max_runs);
|
|
403
|
-
if (!Number.isInteger(m) || m < 1)
|
|
404
|
-
return {
|
|
405
|
-
ok: false,
|
|
406
|
-
error: "'max_runs' must be a positive integer.",
|
|
407
|
-
};
|
|
408
|
-
updates.maxRuns = m;
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
if (body.catchup !== undefined) {
|
|
412
|
-
const c = String(body.catchup) as CatchupPolicy;
|
|
413
|
-
if (!CATCHUP_POLICIES.has(c))
|
|
414
|
-
return {
|
|
415
|
-
ok: false,
|
|
416
|
-
error: "'catchup' must be one of: skip, once, all.",
|
|
417
|
-
};
|
|
418
|
-
updates.catchup = c;
|
|
419
|
-
}
|
|
420
|
-
if (body.model !== undefined) {
|
|
421
|
-
updates.model = provided(body.model) ? String(body.model) : undefined;
|
|
422
|
-
}
|
|
423
|
-
if (body.provider !== undefined) {
|
|
424
|
-
updates.provider = provided(body.provider)
|
|
425
|
-
? String(body.provider)
|
|
426
|
-
: undefined;
|
|
427
|
-
}
|
|
428
|
-
if (body.instructions !== undefined) {
|
|
429
|
-
updates.instructions = provided(body.instructions)
|
|
430
|
-
? String(body.instructions)
|
|
431
|
-
: undefined;
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
// Query execution overrides only apply to 'query' jobs — mirror create.
|
|
435
206
|
// Validate the effective post-edit target so provider+model edits are
|
|
436
207
|
// checked together using cron's cross-provider rules.
|
|
437
|
-
const effType = (updates.type as CronJobType | undefined) ?? job.type;
|
|
438
|
-
const effModel =
|
|
439
|
-
"model" in updates ? (updates.model as string | undefined) : job.model;
|
|
440
|
-
const effProvider =
|
|
441
|
-
"provider" in updates
|
|
442
|
-
? (updates.provider as string | undefined)
|
|
443
|
-
: job.provider;
|
|
444
|
-
const effInstructions =
|
|
445
|
-
"instructions" in updates
|
|
446
|
-
? (updates.instructions as string | undefined)
|
|
447
|
-
: job.instructions;
|
|
448
|
-
if (effType !== "query" && (effModel || effProvider || effInstructions))
|
|
449
|
-
return {
|
|
450
|
-
ok: false,
|
|
451
|
-
error: "Model/provider/instructions only apply to 'query' jobs.",
|
|
452
|
-
};
|
|
453
|
-
if (effProvider && !effModel)
|
|
454
|
-
return {
|
|
455
|
-
ok: false,
|
|
456
|
-
error: "A 'provider' override also requires a 'model'.",
|
|
457
|
-
};
|
|
458
208
|
if (
|
|
459
|
-
|
|
209
|
+
effective.type === "query" &&
|
|
460
210
|
("model" in updates || "provider" in updates || "type" in updates)
|
|
461
211
|
) {
|
|
462
212
|
const modelErr = await validateCronModelOverride(
|
|
463
213
|
chatId,
|
|
464
|
-
|
|
465
|
-
|
|
214
|
+
effective.model,
|
|
215
|
+
effective.provider,
|
|
466
216
|
);
|
|
467
217
|
if (modelErr) return { ok: false, error: modelErr };
|
|
468
218
|
}
|
|
469
219
|
|
|
470
|
-
// Reject a start/end window that can never fire, accounting for the merge.
|
|
471
|
-
const effStart =
|
|
472
|
-
"startAt" in updates
|
|
473
|
-
? (updates.startAt as number | undefined)
|
|
474
|
-
: job.startAt;
|
|
475
|
-
const effEnd =
|
|
476
|
-
"endAt" in updates ? (updates.endAt as number | undefined) : job.endAt;
|
|
477
|
-
if (effStart !== undefined && effEnd !== undefined && effEnd <= effStart)
|
|
478
|
-
return { ok: false, error: "'end_at' must be after 'start_at'." };
|
|
479
|
-
|
|
480
220
|
// If a lowered run cap already met/exceeds runCount, retire the job now so
|
|
481
221
|
// it can't sneak one more run before enforceRunCap catches it post-run.
|
|
482
|
-
const effMaxRuns =
|
|
483
|
-
"maxRuns" in updates
|
|
484
|
-
? (updates.maxRuns as number | undefined)
|
|
485
|
-
: job.maxRuns;
|
|
486
222
|
if (
|
|
487
|
-
|
|
488
|
-
job.runCount >=
|
|
223
|
+
effective.maxRuns !== undefined &&
|
|
224
|
+
job.runCount >= effective.maxRuns &&
|
|
489
225
|
updates.enabled !== true
|
|
490
226
|
)
|
|
491
227
|
updates.enabled = false;
|