talon-agent 3.33.4 → 3.34.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/package.json +2 -1
- package/src/app.ts +16 -8
- package/src/backend/claude-sdk/stream.ts +76 -53
- package/src/backend/remote-server/turn.ts +58 -55
- 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/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/vfs/mounts/files.ts +128 -115
- package/src/core/weaver/shuttle.ts +29 -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/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/util/boot-timer.ts +31 -0
- package/src/util/concurrency.ts +28 -0
- package/src/frontend/discord/callbacks/components.ts +0 -793
- package/src/frontend/discord/callbacks/shared.ts +0 -22
|
@@ -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;
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The action gateway's HTTP routes — declared once, in order, rather than
|
|
3
|
+
* implied by where an `if` sits in `Gateway.start`. Every route shares the
|
|
4
|
+
* 127.0.0.1 trust boundary; the table is the list a reviewer reads.
|
|
5
|
+
*/
|
|
6
|
+
import type { IncomingMessage, Server, ServerResponse } from "node:http";
|
|
7
|
+
import { bus } from "../bus/index.js";
|
|
8
|
+
import { taskTable } from "../tasks/index.js";
|
|
9
|
+
import { handleHubRequest, HUB_PATH_PREFIX } from "../mcp-hub/index.js";
|
|
10
|
+
import { log, logError } from "../../util/log.js";
|
|
11
|
+
|
|
12
|
+
/** What the routes need from the Gateway that owns them. */
|
|
13
|
+
export type GatewayRouteHost = {
|
|
14
|
+
/** The /health body — identity fields plus live counters. */
|
|
15
|
+
healthSnapshot: () => Record<string, unknown>;
|
|
16
|
+
/** Schedule a graceful stop; false when this process cannot be stopped this way. */
|
|
17
|
+
requestShutdown: () => boolean;
|
|
18
|
+
/** Hot-reload plugins from config; resolves to the loaded plugin names. */
|
|
19
|
+
reloadPlugins: () => Promise<string[]>;
|
|
20
|
+
/** Origin the MCP hub advertises for its own endpoints. */
|
|
21
|
+
hubOrigin: () => string;
|
|
22
|
+
/** The /action body handler. */
|
|
23
|
+
handleAction: (body: Record<string, unknown>) => Promise<unknown>;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type RouteContext = {
|
|
27
|
+
req: IncomingMessage;
|
|
28
|
+
res: ServerResponse;
|
|
29
|
+
url: URL;
|
|
30
|
+
host: GatewayRouteHost;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
type GatewayRoute = {
|
|
34
|
+
method: "GET" | "POST" | "ANY";
|
|
35
|
+
path: string;
|
|
36
|
+
/** `prefix` matches `path` as a leading segment; default is an exact match. */
|
|
37
|
+
match?: "prefix";
|
|
38
|
+
handle: (ctx: RouteContext) => void | Promise<void>;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function sendJson(res: ServerResponse, status: number, body: unknown): void {
|
|
42
|
+
// Serialise before writing headers: a body that cannot be stringified
|
|
43
|
+
// must surface as a 500, not as a half-sent 200 that never ends.
|
|
44
|
+
const json = JSON.stringify(body);
|
|
45
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
46
|
+
res.end(json);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function readJsonBody(req: IncomingMessage): Promise<unknown> {
|
|
50
|
+
const chunks: Buffer[] = [];
|
|
51
|
+
for await (const chunk of req) chunks.push(chunk as Buffer);
|
|
52
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf-8"));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const ROUTES: readonly GatewayRoute[] = [
|
|
56
|
+
{
|
|
57
|
+
method: "GET",
|
|
58
|
+
path: "/health",
|
|
59
|
+
handle: ({ res, host }) => sendJson(res, 200, host.healthSnapshot()),
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
// Graceful stop for `talon stop`/`talon restart`. Respond before
|
|
63
|
+
// triggering so the client isn't cut off mid-request; the shutdown
|
|
64
|
+
// path takes seconds, so the reply flushes safely.
|
|
65
|
+
method: "POST",
|
|
66
|
+
path: "/shutdown",
|
|
67
|
+
handle: ({ res, host }) => {
|
|
68
|
+
if (!host.requestShutdown()) {
|
|
69
|
+
sendJson(res, 501, {
|
|
70
|
+
ok: false,
|
|
71
|
+
error: "Shutdown not supported by this process",
|
|
72
|
+
});
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
sendJson(res, 200, { ok: true });
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
// Bus tail — recent events, optionally after a cursor. Read by `talon events`.
|
|
80
|
+
method: "GET",
|
|
81
|
+
path: "/events/recent",
|
|
82
|
+
match: "prefix",
|
|
83
|
+
handle: ({ res, url }) => {
|
|
84
|
+
const since = Number(url.searchParams.get("since") ?? "0");
|
|
85
|
+
sendJson(res, 200, {
|
|
86
|
+
ok: true,
|
|
87
|
+
events: bus.recent(Number.isInteger(since) && since > 0 ? since : 0),
|
|
88
|
+
});
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
// The task table — every live/recent unit of agent work. Read by `talon ps`.
|
|
93
|
+
method: "GET",
|
|
94
|
+
path: "/tasks",
|
|
95
|
+
handle: ({ res }) =>
|
|
96
|
+
sendJson(res, 200, { ok: true, tasks: taskTable.list() }),
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
// Abort one killable task by id — the transport for `talon kill`.
|
|
100
|
+
method: "POST",
|
|
101
|
+
path: "/tasks/kill",
|
|
102
|
+
handle: async ({ req, res }) => {
|
|
103
|
+
let id: unknown;
|
|
104
|
+
try {
|
|
105
|
+
id = ((await readJsonBody(req)) as { id?: unknown }).id;
|
|
106
|
+
} catch {
|
|
107
|
+
sendJson(res, 400, { ok: false, error: "Invalid JSON" });
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (typeof id !== "number" || !Number.isInteger(id)) {
|
|
111
|
+
sendJson(res, 400, { ok: false, error: "id must be an integer" });
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
sendJson(res, 200, taskTable.kill(id));
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
// Hot-reload plugins from config — the transport for `talon plugin
|
|
119
|
+
// install/enable/disable`, which has no chat context and so cannot use
|
|
120
|
+
// the reload_plugins action.
|
|
121
|
+
method: "POST",
|
|
122
|
+
path: "/plugins/reload",
|
|
123
|
+
handle: async ({ res, host }) => {
|
|
124
|
+
try {
|
|
125
|
+
const loaded = await host.reloadPlugins();
|
|
126
|
+
log("gateway", `/plugins/reload: ${loaded.length} plugins loaded`);
|
|
127
|
+
sendJson(res, 200, { ok: true, loaded });
|
|
128
|
+
} catch (err) {
|
|
129
|
+
sendJson(res, 200, {
|
|
130
|
+
ok: false,
|
|
131
|
+
error: `Plugin reload failed: ${err instanceof Error ? err.message : err}`,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
// MCP hub — daemon-hosted MCP-over-HTTP endpoints for every backend
|
|
138
|
+
// (see core/mcp-hub).
|
|
139
|
+
method: "ANY",
|
|
140
|
+
path: HUB_PATH_PREFIX,
|
|
141
|
+
match: "prefix",
|
|
142
|
+
handle: ({ req, res, host }) =>
|
|
143
|
+
handleHubRequest(req, res, host.hubOrigin()),
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
method: "POST",
|
|
147
|
+
path: "/action",
|
|
148
|
+
handle: async ({ req, res, host }) => {
|
|
149
|
+
let body: Record<string, unknown>;
|
|
150
|
+
try {
|
|
151
|
+
body = (await readJsonBody(req)) as Record<string, unknown>;
|
|
152
|
+
} catch {
|
|
153
|
+
sendJson(res, 400, { ok: false, error: "Invalid JSON" });
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
sendJson(res, 200, await host.handleAction(body));
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
];
|
|
160
|
+
|
|
161
|
+
function matches(route: GatewayRoute, req: IncomingMessage): boolean {
|
|
162
|
+
if (route.method !== "ANY" && req.method !== route.method) return false;
|
|
163
|
+
const url = req.url ?? "";
|
|
164
|
+
return route.match === "prefix"
|
|
165
|
+
? url.startsWith(route.path)
|
|
166
|
+
: url === route.path;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Serve one request: first matching route wins; nothing matches → 404. */
|
|
170
|
+
export async function dispatchGatewayRoute(
|
|
171
|
+
req: IncomingMessage,
|
|
172
|
+
res: ServerResponse,
|
|
173
|
+
host: GatewayRouteHost,
|
|
174
|
+
): Promise<void> {
|
|
175
|
+
const route = ROUTES.find((candidate) => matches(candidate, req));
|
|
176
|
+
if (!route) {
|
|
177
|
+
res.writeHead(404);
|
|
178
|
+
res.end("Not found");
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
await route.handle({
|
|
183
|
+
req,
|
|
184
|
+
res,
|
|
185
|
+
url: new URL(req.url ?? "/", "http://gateway"),
|
|
186
|
+
host,
|
|
187
|
+
});
|
|
188
|
+
} catch (err) {
|
|
189
|
+
if (res.headersSent) return;
|
|
190
|
+
// Log full error (incl. stack via logError's structured `stack` field)
|
|
191
|
+
// on the server; return a generic message to the client so we don't
|
|
192
|
+
// leak implementation details. CodeQL: js/stack-trace-exposure.
|
|
193
|
+
logError("gateway", `Unhandled error on ${req.method} ${req.url}`, err);
|
|
194
|
+
sendJson(res, 500, { ok: false, error: "Internal server error" });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const PORT_RETRIES = 5;
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Bind to 127.0.0.1, walking up from `port` on EADDRINUSE (at most
|
|
202
|
+
* `PORT_RETRIES` times). Resolves with the port actually bound — `port`
|
|
203
|
+
* 0 asks the OS for a free one — and leaves a persistent error handler
|
|
204
|
+
* on the server, so a later server-level error is logged instead of
|
|
205
|
+
* crashing the process via an unhandled 'error' event.
|
|
206
|
+
*/
|
|
207
|
+
export function listenWithRetry(server: Server, port: number): Promise<number> {
|
|
208
|
+
return new Promise<number>((resolve, reject) => {
|
|
209
|
+
let attempt = 0;
|
|
210
|
+
const tryPort = (candidate: number): void => {
|
|
211
|
+
server.once("error", (err: NodeJS.ErrnoException) => {
|
|
212
|
+
if (err.code === "EADDRINUSE" && attempt < PORT_RETRIES) {
|
|
213
|
+
attempt++;
|
|
214
|
+
server.removeAllListeners("error");
|
|
215
|
+
// The failed listen() left its one-shot 'listening' callback
|
|
216
|
+
// registered; drop it or every stale callback fires when a later
|
|
217
|
+
// port finally binds.
|
|
218
|
+
server.removeAllListeners("listening");
|
|
219
|
+
tryPort(candidate + 1);
|
|
220
|
+
} else {
|
|
221
|
+
reject(err);
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
server.listen(candidate, "127.0.0.1", () => {
|
|
225
|
+
const addr = server.address();
|
|
226
|
+
const bound =
|
|
227
|
+
typeof addr === "object" && addr !== null
|
|
228
|
+
? (addr as { port: number }).port
|
|
229
|
+
: candidate;
|
|
230
|
+
server.removeAllListeners("error");
|
|
231
|
+
server.on("error", (err) =>
|
|
232
|
+
logError("gateway", "HTTP server error", err),
|
|
233
|
+
);
|
|
234
|
+
resolve(bound);
|
|
235
|
+
});
|
|
236
|
+
};
|
|
237
|
+
tryPort(port);
|
|
238
|
+
});
|
|
239
|
+
}
|