@onlyne/mcp-gateway 3.0.0 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +107 -3
- package/dist/config.d.ts +69 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +210 -0
- package/dist/config.js.map +1 -0
- package/dist/cron.d.ts +23 -0
- package/dist/cron.d.ts.map +1 -0
- package/dist/cron.js +112 -0
- package/dist/cron.js.map +1 -0
- package/dist/exec.d.ts +33 -0
- package/dist/exec.d.ts.map +1 -0
- package/dist/exec.js +155 -0
- package/dist/exec.js.map +1 -0
- package/dist/gateway-text.d.ts +26 -0
- package/dist/gateway-text.d.ts.map +1 -0
- package/dist/gateway-text.js +47 -0
- package/dist/gateway-text.js.map +1 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +274 -0
- package/dist/index.js.map +1 -0
- package/dist/main.d.ts +3 -0
- package/dist/main.d.ts.map +1 -0
- package/{src/main.ts → dist/main.js} +24 -27
- package/dist/main.js.map +1 -0
- package/dist/mounts.d.ts +151 -0
- package/dist/mounts.d.ts.map +1 -0
- package/dist/mounts.js +927 -0
- package/dist/mounts.js.map +1 -0
- package/package.json +19 -12
- package/src/config.ts +0 -98
- package/src/gateway-text.ts +0 -33
- package/src/index.ts +0 -151
package/dist/mounts.js
ADDED
|
@@ -0,0 +1,927 @@
|
|
|
1
|
+
import { OnlyneError, } from '@onlyne/lib';
|
|
2
|
+
import { nextCronTime, parseCron } from './cron.js';
|
|
3
|
+
import { runCommand, stopChild } from './exec.js';
|
|
4
|
+
/** The scheduler's resource ids carry this prefix, so one driver routes MCP servers and mounts apart. */
|
|
5
|
+
export const MOUNTS_RESOURCE_PREFIX = 'mounts:';
|
|
6
|
+
export const MOUNT_OPERATIONS = ['mount', 'update', 'list', 'inspect', 'runs', 'cancel', 'resume', 'remove'];
|
|
7
|
+
export const mountsResourceId = (controller) => `${MOUNTS_RESOURCE_PREFIX}${controller}`;
|
|
8
|
+
const DAY_MS = 86_400_000;
|
|
9
|
+
/** How long a result message may keep being retried before the queue settles it as expired. */
|
|
10
|
+
const PUSH_WINDOW_MS = DAY_MS;
|
|
11
|
+
const MAX_CHANGES = 20;
|
|
12
|
+
const MAX_SUMMARY = 4_000;
|
|
13
|
+
const RETRY_CAP_MS = 60_000;
|
|
14
|
+
/** Push codes that no retry can fix; anything else stays in the queue. */
|
|
15
|
+
const TERMINAL_PUSH = {
|
|
16
|
+
invalid_request: true, forbidden: true, not_a_member: true, id_conflict: true,
|
|
17
|
+
revision_conflict: true, conflict: true, thread_closed: true, payload_too_large: true,
|
|
18
|
+
unsupported_media_type: true, unsupported_delivery: true, ambiguous_target: true,
|
|
19
|
+
};
|
|
20
|
+
const iso = (ms) => new Date(ms).toISOString();
|
|
21
|
+
const timeOf = (value) => (value === null ? '—' : new Date(value).toTimeString().slice(0, 8));
|
|
22
|
+
/** Operation data is `Json`; the task and run interfaces are the typed face of the same shapes. */
|
|
23
|
+
const wire = (value) => JSON.parse(JSON.stringify(value));
|
|
24
|
+
function failureOf(error) {
|
|
25
|
+
const reported = error.error;
|
|
26
|
+
return {
|
|
27
|
+
code: typeof reported?.code === 'string' ? reported.code : 'internal',
|
|
28
|
+
message: typeof reported?.message === 'string' ? reported.message : (error instanceof Error ? error.message : String(error)),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
function recordOf(value, what) {
|
|
32
|
+
if (value === undefined || value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
33
|
+
throw new OnlyneError('invalid_request', `${what} must be an object.`);
|
|
34
|
+
}
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
function textField(container, field) {
|
|
38
|
+
const value = container[field];
|
|
39
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
40
|
+
throw new OnlyneError('invalid_request', `${field} must be a non-empty string.`);
|
|
41
|
+
}
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
function describeSchedule(schedule) {
|
|
45
|
+
if (schedule.kind === 'cron')
|
|
46
|
+
return `cron ${schedule.expression}`;
|
|
47
|
+
if (schedule.kind === 'once')
|
|
48
|
+
return `once ${schedule.at}`;
|
|
49
|
+
return schedule.every_ms % 1_000 === 0 ? `every ${schedule.every_ms / 1_000}s` : `every ${schedule.every_ms}ms`;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* One run's readable output: stdout, then stderr, tail-bounded. `bytes` counts what the child
|
|
53
|
+
* wrote even when the summary only keeps the end of it.
|
|
54
|
+
*/
|
|
55
|
+
function summarizeRun(stdout, stderr, bytes) {
|
|
56
|
+
const joined = [stdout, stderr].filter(text => text.length > 0).join('\n');
|
|
57
|
+
return { summary: joined.length > MAX_SUMMARY ? joined.slice(-MAX_SUMMARY) : joined, bytes };
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The persistent scheduler: task records, the run ledger and the result-push queue all live in
|
|
61
|
+
* the node's store. A mount's definition is immutable for the run it started; a crash between
|
|
62
|
+
* start and finish is recorded as `interrupted`, never silently retried, so an external effect
|
|
63
|
+
* is never claimed to be exactly-once.
|
|
64
|
+
*/
|
|
65
|
+
export function createMountService(options) {
|
|
66
|
+
const owner = options.owner;
|
|
67
|
+
const config = options.config;
|
|
68
|
+
const storage = options.storage;
|
|
69
|
+
const now = options.now ?? (() => Date.now());
|
|
70
|
+
const log = options.log ?? (() => undefined);
|
|
71
|
+
const commands = new Map((config?.commands ?? []).map(entry => [entry.id, entry]));
|
|
72
|
+
const tools = new Set((config?.tools ?? []).map(entry => `${entry.server}\u0000${entry.tool}`));
|
|
73
|
+
const controllers = new Set(config?.controllers ?? []);
|
|
74
|
+
const servers = new Map(options.servers.map(entry => [entry.name, entry]));
|
|
75
|
+
const tickMs = config?.tick_ms ?? 1_000;
|
|
76
|
+
const tasks = new Map();
|
|
77
|
+
/** Running runs only; finished history is read from the store, so memory stays bounded. */
|
|
78
|
+
const active = new Map();
|
|
79
|
+
const push = new Map();
|
|
80
|
+
const pushing = new Set();
|
|
81
|
+
const settled = { accepted: 0, refused: 0, expired: 0 };
|
|
82
|
+
let recent = [];
|
|
83
|
+
let timer;
|
|
84
|
+
let ticking = false;
|
|
85
|
+
let stopping = false;
|
|
86
|
+
const taskKey = (id) => `mounts/task/${id}`;
|
|
87
|
+
const runKey = (taskId, runId) => `mounts/run/${taskId}/${runId}`;
|
|
88
|
+
const runPrefix = (taskId) => `mounts/run/${taskId}/`;
|
|
89
|
+
const pushKey = (taskId, runId) => `mounts/push/${taskId}/${runId}`;
|
|
90
|
+
const saveTask = (task) => storage.transaction(tx => tx.put(taskKey(task.id), task));
|
|
91
|
+
const saveRun = (run) => storage.transaction(tx => tx.put(runKey(run.task_id, run.id), run));
|
|
92
|
+
const loadRun = (taskId, runId) => storage.transaction(tx => tx.get(runKey(taskId, runId)));
|
|
93
|
+
const savePush = (entry) => storage.transaction(tx => tx.put(pushKey(entry.task_id, entry.run_id), entry));
|
|
94
|
+
const dropPush = (entry) => storage.transaction(tx => tx.delete(pushKey(entry.task_id, entry.run_id)));
|
|
95
|
+
function change(task, actor, action, extra = {}) {
|
|
96
|
+
task.changes.push({ at: iso(now()), actor, action, ...extra });
|
|
97
|
+
if (task.changes.length > MAX_CHANGES)
|
|
98
|
+
task.changes = task.changes.slice(-MAX_CHANGES);
|
|
99
|
+
}
|
|
100
|
+
function runSummary(run) {
|
|
101
|
+
return { id: run.id, state: run.state, started_at: run.started_at, finished_at: run.finished_at, exit_code: run.exit_code };
|
|
102
|
+
}
|
|
103
|
+
function noteRecent(run) {
|
|
104
|
+
recent = [run, ...recent.filter(item => item.id !== run.id)]
|
|
105
|
+
.sort((a, b) => ((a.finished_at ?? a.started_at) < (b.finished_at ?? b.started_at) ? 1 : -1))
|
|
106
|
+
.slice(0, 5);
|
|
107
|
+
}
|
|
108
|
+
// ---- request parsing and authorization -------------------------------------
|
|
109
|
+
function controllerOf(resource) {
|
|
110
|
+
const id = resource.resource_ref.id;
|
|
111
|
+
if (!id.startsWith(MOUNTS_RESOURCE_PREFIX)) {
|
|
112
|
+
throw new OnlyneError('forbidden', 'This driver only serves the scheduler resource.');
|
|
113
|
+
}
|
|
114
|
+
const controller = id.slice(MOUNTS_RESOURCE_PREFIX.length);
|
|
115
|
+
if (!controllers.has(controller)) {
|
|
116
|
+
throw new OnlyneError('forbidden', 'Only a configured mount controller may operate this resource.');
|
|
117
|
+
}
|
|
118
|
+
return controller;
|
|
119
|
+
}
|
|
120
|
+
function parseSchedule(value) {
|
|
121
|
+
const record = recordOf(value, 'schedule');
|
|
122
|
+
const kind = textField(record, 'kind');
|
|
123
|
+
if (kind === 'interval') {
|
|
124
|
+
const every = record.every_ms;
|
|
125
|
+
if (typeof every !== 'number' || !Number.isInteger(every) || every < 1_000) {
|
|
126
|
+
throw new OnlyneError('invalid_request', 'An interval schedule needs every_ms as an integer of at least 1000.');
|
|
127
|
+
}
|
|
128
|
+
return { kind: 'interval', every_ms: every };
|
|
129
|
+
}
|
|
130
|
+
if (kind === 'cron') {
|
|
131
|
+
const expression = textField(record, 'expression');
|
|
132
|
+
if (nextCronTime(parseCron(expression), now()) === undefined) {
|
|
133
|
+
throw new OnlyneError('invalid_request', `The cron expression ${expression} has no occurrence in the next five years.`);
|
|
134
|
+
}
|
|
135
|
+
return { kind: 'cron', expression };
|
|
136
|
+
}
|
|
137
|
+
if (kind === 'once') {
|
|
138
|
+
const at = textField(record, 'at');
|
|
139
|
+
const when = Date.parse(at);
|
|
140
|
+
if (!Number.isFinite(when))
|
|
141
|
+
throw new OnlyneError('invalid_request', 'A one-shot schedule needs at as an ISO timestamp.');
|
|
142
|
+
if (when < now())
|
|
143
|
+
throw new OnlyneError('invalid_request', 'A one-shot schedule needs a time in the future.');
|
|
144
|
+
return { kind: 'once', at: iso(when) };
|
|
145
|
+
}
|
|
146
|
+
throw new OnlyneError('invalid_request', `The schedule kind ${JSON.stringify(kind)} is interval, cron or once.`);
|
|
147
|
+
}
|
|
148
|
+
function parseAction(controller, value) {
|
|
149
|
+
const record = recordOf(value, 'action');
|
|
150
|
+
const kind = textField(record, 'kind');
|
|
151
|
+
if (kind === 'command') {
|
|
152
|
+
const command = textField(record, 'command');
|
|
153
|
+
if (!commands.has(command)) {
|
|
154
|
+
throw new OnlyneError('forbidden', `The mount command ${command} is not in the configured command allowlist.`);
|
|
155
|
+
}
|
|
156
|
+
return { kind: 'command', command };
|
|
157
|
+
}
|
|
158
|
+
if (kind === 'mcp') {
|
|
159
|
+
const server = textField(record, 'server');
|
|
160
|
+
const tool = textField(record, 'tool');
|
|
161
|
+
if (!servers.has(server)) {
|
|
162
|
+
throw new OnlyneError('invalid_request', `No configured MCP server is named ${server}.`);
|
|
163
|
+
}
|
|
164
|
+
const permitted = servers.get(server)?.controllers ?? [];
|
|
165
|
+
// A scheduled tool call runs with the mounting controller's authority, so the server's
|
|
166
|
+
// own controller list decides whether that controller could have called it at all.
|
|
167
|
+
if (!permitted.includes(controller)) {
|
|
168
|
+
throw new OnlyneError('forbidden', `The server ${server} does not list ${controller} as a controller.`);
|
|
169
|
+
}
|
|
170
|
+
if (!tools.has(`${server}\u0000${tool}`)) {
|
|
171
|
+
throw new OnlyneError('forbidden', `The tool ${server}/${tool} is not in the configured mount tool allowlist.`);
|
|
172
|
+
}
|
|
173
|
+
const argument = record.arguments;
|
|
174
|
+
if (argument !== undefined && argument !== null && (typeof argument !== 'object' || Array.isArray(argument))) {
|
|
175
|
+
throw new OnlyneError('invalid_request', 'The tool arguments for a mount are an object.');
|
|
176
|
+
}
|
|
177
|
+
return {
|
|
178
|
+
kind: 'mcp', server, tool,
|
|
179
|
+
...(argument === undefined || argument === null ? {} : { arguments: argument }),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
throw new OnlyneError('invalid_request', `The mount action kind ${JSON.stringify(kind)} is command or mcp.`);
|
|
183
|
+
}
|
|
184
|
+
function parseTitle(value) {
|
|
185
|
+
if (value === undefined || value === null)
|
|
186
|
+
return undefined;
|
|
187
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
188
|
+
throw new OnlyneError('invalid_request', 'title must be a non-empty string when present.');
|
|
189
|
+
}
|
|
190
|
+
if (value.length > 200)
|
|
191
|
+
throw new OnlyneError('invalid_request', 'title is at most 200 characters.');
|
|
192
|
+
return value;
|
|
193
|
+
}
|
|
194
|
+
function parseTaskId(record) {
|
|
195
|
+
const id = textField(record, 'task');
|
|
196
|
+
if (!tasks.has(id))
|
|
197
|
+
throw new OnlyneError('not_found', `No mount task is named ${id}.`);
|
|
198
|
+
return id;
|
|
199
|
+
}
|
|
200
|
+
function requireTask(controller, id) {
|
|
201
|
+
const task = tasks.get(id);
|
|
202
|
+
if (task === undefined || task.controller !== controller) {
|
|
203
|
+
throw new OnlyneError('not_found', `No mount task is named ${id}.`);
|
|
204
|
+
}
|
|
205
|
+
return task;
|
|
206
|
+
}
|
|
207
|
+
/** The next instant for a schedule; `immediate` fires interval/once at `fromMs` instead of later. */
|
|
208
|
+
function nextFor(schedule, fromMs, immediate) {
|
|
209
|
+
if (schedule.kind === 'once')
|
|
210
|
+
return immediate ? iso(fromMs) : schedule.at;
|
|
211
|
+
if (schedule.kind === 'interval')
|
|
212
|
+
return iso(immediate ? fromMs : fromMs + schedule.every_ms);
|
|
213
|
+
const next = nextCronTime(parseCron(schedule.expression), fromMs);
|
|
214
|
+
return next === undefined ? null : iso(next);
|
|
215
|
+
}
|
|
216
|
+
// ---- run lifecycle ----------------------------------------------------------
|
|
217
|
+
async function recordSkipped(task, atMs, reason) {
|
|
218
|
+
const run = {
|
|
219
|
+
id: `${atMs.toString(36)}-${crypto.randomUUID().slice(0, 8)}`,
|
|
220
|
+
task_id: task.id, controller: task.controller, action: structuredClone(task.action), thread: task.thread,
|
|
221
|
+
state: 'skipped', started_at: iso(atMs), finished_at: iso(atMs), exit_code: null,
|
|
222
|
+
output_summary: '', output_bytes: 0,
|
|
223
|
+
error: { code: 'overlap', message: `The previous run was still active at this occurrence (${reason}).` },
|
|
224
|
+
};
|
|
225
|
+
await saveRun(run);
|
|
226
|
+
noteRecent(run);
|
|
227
|
+
}
|
|
228
|
+
/** Re-arms a task whose occurrence passed while its previous run was still active. */
|
|
229
|
+
function advanceOverdue(task, atMs) {
|
|
230
|
+
if (task.schedule.kind === 'interval') {
|
|
231
|
+
task.next_run_at = iso(atMs + task.schedule.every_ms);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (task.schedule.kind === 'cron') {
|
|
235
|
+
const next = nextCronTime(parseCron(task.schedule.expression), atMs);
|
|
236
|
+
task.next_run_at = next === undefined ? null : iso(next);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
task.next_run_at = null;
|
|
240
|
+
}
|
|
241
|
+
async function startRun(task, atMs) {
|
|
242
|
+
const runId = `${atMs.toString(36)}-${crypto.randomUUID().slice(0, 8)}`;
|
|
243
|
+
const run = {
|
|
244
|
+
id: runId,
|
|
245
|
+
task_id: task.id, controller: task.controller, action: structuredClone(task.action), thread: task.thread,
|
|
246
|
+
state: 'running', started_at: iso(atMs), finished_at: null, exit_code: null,
|
|
247
|
+
output_summary: '', output_bytes: 0, error: null,
|
|
248
|
+
// The message id is the run id: a re-enqueue of the same run is the same message, so a
|
|
249
|
+
// peer dedupes it instead of receiving the result twice.
|
|
250
|
+
push: { message_id: runId, state: 'pending', attempts: 0, retry_until: iso(atMs + PUSH_WINDOW_MS) },
|
|
251
|
+
};
|
|
252
|
+
// The schedule advances before the external effect starts. A crash in between leaves a
|
|
253
|
+
// running record that startup repairs to `interrupted`; it is never re-fired as success.
|
|
254
|
+
if (task.schedule.kind === 'once')
|
|
255
|
+
task.next_run_at = null;
|
|
256
|
+
else if (task.schedule.kind === 'interval')
|
|
257
|
+
task.next_run_at = iso(atMs + task.schedule.every_ms);
|
|
258
|
+
else {
|
|
259
|
+
const next = nextCronTime(parseCron(task.schedule.expression), atMs);
|
|
260
|
+
task.next_run_at = next === undefined ? null : iso(next);
|
|
261
|
+
}
|
|
262
|
+
task.updated_at = iso(atMs);
|
|
263
|
+
const child = { cancelled: false };
|
|
264
|
+
let release = () => undefined;
|
|
265
|
+
const done = new Promise(resolve => { release = resolve; });
|
|
266
|
+
// Insert the running record before any await: a concurrent tick sees the overlap, and the
|
|
267
|
+
// durable record lands before the child exists.
|
|
268
|
+
active.set(runId, { task_id: task.id, child, done });
|
|
269
|
+
try {
|
|
270
|
+
await saveRun(run);
|
|
271
|
+
await saveTask(task);
|
|
272
|
+
}
|
|
273
|
+
catch (error) {
|
|
274
|
+
// Nothing was started, so no run is left owing a record.
|
|
275
|
+
active.delete(runId);
|
|
276
|
+
release();
|
|
277
|
+
throw error;
|
|
278
|
+
}
|
|
279
|
+
log('mount.run.started', { task: task.id, run: runId, kind: task.action.kind });
|
|
280
|
+
void executeRun(task, run, child)
|
|
281
|
+
.catch(error => log('mount.run.failed', { task: task.id, run: runId, error: String(error) }))
|
|
282
|
+
.finally(() => {
|
|
283
|
+
active.delete(runId);
|
|
284
|
+
release();
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
async function executeRun(task, run, child) {
|
|
288
|
+
let state = 'failed';
|
|
289
|
+
let exitCode = null;
|
|
290
|
+
let summary = '';
|
|
291
|
+
let bytes = 0;
|
|
292
|
+
let error = null;
|
|
293
|
+
const action = run.action;
|
|
294
|
+
try {
|
|
295
|
+
if (action.kind === 'command') {
|
|
296
|
+
const spec = commands.get(action.command);
|
|
297
|
+
if (spec === undefined) {
|
|
298
|
+
throw new OnlyneError('invalid_request', `The allowlist no longer holds the command ${action.command}.`);
|
|
299
|
+
}
|
|
300
|
+
const result = await runCommand(spec, child);
|
|
301
|
+
const combined = summarizeRun(result.stdout, result.stderr, result.output_bytes);
|
|
302
|
+
summary = combined.summary;
|
|
303
|
+
bytes = combined.bytes;
|
|
304
|
+
exitCode = result.exit_code;
|
|
305
|
+
if (stopping) {
|
|
306
|
+
state = 'interrupted';
|
|
307
|
+
error = { code: 'interrupted', message: 'The gateway stopped before this run finished; the external effect is unconfirmed.' };
|
|
308
|
+
}
|
|
309
|
+
else if (child.cancelled) {
|
|
310
|
+
state = 'cancelled';
|
|
311
|
+
error = { code: 'cancelled', message: 'The mount was cancelled while this run was active.' };
|
|
312
|
+
}
|
|
313
|
+
else if (exitCode === 0) {
|
|
314
|
+
state = 'succeeded';
|
|
315
|
+
}
|
|
316
|
+
else {
|
|
317
|
+
state = 'failed';
|
|
318
|
+
error = { code: 'exit_nonzero', message: `The command exited with status ${exitCode ?? 'unknown'}.` };
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
else {
|
|
322
|
+
const connection = options.connections.get(action.server);
|
|
323
|
+
if (connection === undefined) {
|
|
324
|
+
throw new OnlyneError('unavailable', `No configured MCP server is named ${action.server}.`);
|
|
325
|
+
}
|
|
326
|
+
// A scheduled run starts in a new process with an empty catalog, so resolve the tool the
|
|
327
|
+
// same way an operation does: policy first, then one tools/list refresh when needed.
|
|
328
|
+
const tool = await connection.resolveTool(action.tool);
|
|
329
|
+
const outcome = await connection.call(action.tool, action.arguments ?? {});
|
|
330
|
+
if (outcome.kind === 'result') {
|
|
331
|
+
const text = JSON.stringify({
|
|
332
|
+
...(outcome.content === undefined ? {} : { content: outcome.content }),
|
|
333
|
+
...(outcome.structuredContent === undefined ? {} : { structuredContent: outcome.structuredContent }),
|
|
334
|
+
});
|
|
335
|
+
summary = text.length > MAX_SUMMARY ? text.slice(0, MAX_SUMMARY) : text;
|
|
336
|
+
bytes = text.length;
|
|
337
|
+
state = 'succeeded';
|
|
338
|
+
}
|
|
339
|
+
else if (outcome.kind === 'rejected') {
|
|
340
|
+
summary = outcome.text.slice(0, MAX_SUMMARY);
|
|
341
|
+
bytes = outcome.text.length;
|
|
342
|
+
const readOnly = tool.annotations?.readOnlyHint === true;
|
|
343
|
+
state = readOnly ? 'failed' : 'unknown';
|
|
344
|
+
error = {
|
|
345
|
+
code: 'tool_rejected',
|
|
346
|
+
message: readOnly
|
|
347
|
+
? 'The MCP tool reported failure.'
|
|
348
|
+
: 'The MCP tool reported failure; the side effect is unconfirmed.',
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
summary = outcome.text.slice(0, MAX_SUMMARY);
|
|
353
|
+
bytes = outcome.text.length;
|
|
354
|
+
state = 'unknown';
|
|
355
|
+
error = { code: 'unavailable', message: 'The MCP server gave no answer; the side effect is unconfirmed.' };
|
|
356
|
+
}
|
|
357
|
+
// A cancel or shutdown cannot abort the in-flight tool call, so the observed result is
|
|
358
|
+
// kept, but the run is recorded for what it is: stopped before its answer was settled.
|
|
359
|
+
if (stopping) {
|
|
360
|
+
state = 'interrupted';
|
|
361
|
+
error = { code: 'interrupted', message: 'The gateway stopped before this tool call settled; the external effect is unconfirmed.' };
|
|
362
|
+
}
|
|
363
|
+
else if (child.cancelled) {
|
|
364
|
+
state = 'cancelled';
|
|
365
|
+
error = { code: 'cancelled', message: 'The mount was cancelled while this tool call was in flight.' };
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
catch (err) {
|
|
370
|
+
error = failureOf(err);
|
|
371
|
+
state = error.code === 'unavailable' ? 'unknown' : 'failed';
|
|
372
|
+
}
|
|
373
|
+
finally {
|
|
374
|
+
await finishRun(task, run, { state, exitCode, summary, bytes, error });
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
async function finishRun(task, run, outcome) {
|
|
378
|
+
run.state = outcome.state;
|
|
379
|
+
run.exit_code = outcome.exitCode;
|
|
380
|
+
run.output_summary = outcome.summary;
|
|
381
|
+
run.output_bytes = outcome.bytes;
|
|
382
|
+
run.error = outcome.error;
|
|
383
|
+
run.finished_at = iso(now());
|
|
384
|
+
task.last_run = runSummary(run);
|
|
385
|
+
task.updated_at = run.finished_at;
|
|
386
|
+
if (task.state === 'active') {
|
|
387
|
+
if (task.schedule.kind === 'once') {
|
|
388
|
+
task.state = 'completed';
|
|
389
|
+
}
|
|
390
|
+
else if (task.next_run_at !== null && Date.parse(task.next_run_at) <= now()) {
|
|
391
|
+
// The run overran its slot: one honest skip record per missed occurrence, then re-arm.
|
|
392
|
+
await recordSkipped(task, now(), 'run overran its schedule');
|
|
393
|
+
advanceOverdue(task, now());
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
const pushState = run.push;
|
|
397
|
+
if (pushState !== undefined) {
|
|
398
|
+
pushState.state = 'pending';
|
|
399
|
+
pushState.attempts = 0;
|
|
400
|
+
// The retry window opens when the result exists, not when a long run started.
|
|
401
|
+
pushState.retry_until = iso(Date.parse(run.finished_at) + PUSH_WINDOW_MS);
|
|
402
|
+
const entry = {
|
|
403
|
+
task_id: run.task_id, run_id: run.id, state: 'pending', attempts: 0, next_attempt_at: run.finished_at,
|
|
404
|
+
};
|
|
405
|
+
push.set(pushKey(run.task_id, run.id), entry);
|
|
406
|
+
await savePush(entry);
|
|
407
|
+
}
|
|
408
|
+
await saveRun(run);
|
|
409
|
+
await saveTask(task);
|
|
410
|
+
noteRecent(run);
|
|
411
|
+
log('mount.run.finished', { task: task.id, run: run.id, state: run.state, exit_code: run.exit_code });
|
|
412
|
+
}
|
|
413
|
+
// ---- result push queue ------------------------------------------------------
|
|
414
|
+
function resultMessage(run) {
|
|
415
|
+
const exit = run.exit_code === null ? '' : ` exit=${run.exit_code}`;
|
|
416
|
+
const reason = run.error === null ? '' : `: ${run.error.message}`;
|
|
417
|
+
const body = run.output_summary.trim();
|
|
418
|
+
return {
|
|
419
|
+
id: run.push?.message_id ?? run.id,
|
|
420
|
+
sender: owner,
|
|
421
|
+
delivery_kind: 'mount.run',
|
|
422
|
+
msg: `mount run ${run.id} (task ${run.task_id}) ${run.state}${exit}${reason}${body.length > 0 ? `\n${body}` : ''}`.slice(0, 3_500),
|
|
423
|
+
meta: {
|
|
424
|
+
thread: run.thread,
|
|
425
|
+
data: {
|
|
426
|
+
task_id: run.task_id,
|
|
427
|
+
run_id: run.id,
|
|
428
|
+
state: run.state,
|
|
429
|
+
exit_code: run.exit_code,
|
|
430
|
+
started_at: run.started_at,
|
|
431
|
+
finished_at: run.finished_at,
|
|
432
|
+
output_summary: run.output_summary.slice(0, 2_000),
|
|
433
|
+
error: run.error === null ? null : { ...run.error },
|
|
434
|
+
},
|
|
435
|
+
},
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
async function attemptPush(entry) {
|
|
439
|
+
const run = await loadRun(entry.task_id, entry.run_id);
|
|
440
|
+
if (run?.push === undefined) {
|
|
441
|
+
push.delete(pushKey(entry.task_id, entry.run_id));
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
const current = run.push;
|
|
445
|
+
current.attempts += 1;
|
|
446
|
+
const settle = async (state, message) => {
|
|
447
|
+
current.state = state;
|
|
448
|
+
if (message === undefined)
|
|
449
|
+
delete current.error;
|
|
450
|
+
else
|
|
451
|
+
current.error = message;
|
|
452
|
+
if (state === 'pending' || state === 'unknown') {
|
|
453
|
+
const delay = Math.min(RETRY_CAP_MS, 1_000 * 2 ** Math.min(current.attempts, 6));
|
|
454
|
+
current.next_attempt_at = iso(now() + delay);
|
|
455
|
+
entry.state = state;
|
|
456
|
+
entry.attempts = current.attempts;
|
|
457
|
+
entry.next_attempt_at = current.next_attempt_at;
|
|
458
|
+
push.set(pushKey(entry.task_id, entry.run_id), entry);
|
|
459
|
+
await savePush(entry);
|
|
460
|
+
}
|
|
461
|
+
else {
|
|
462
|
+
delete current.next_attempt_at;
|
|
463
|
+
push.delete(pushKey(entry.task_id, entry.run_id));
|
|
464
|
+
await dropPush(entry);
|
|
465
|
+
if (state === 'refused')
|
|
466
|
+
settled.refused += 1;
|
|
467
|
+
else
|
|
468
|
+
settled.expired += 1;
|
|
469
|
+
}
|
|
470
|
+
await saveRun(run);
|
|
471
|
+
};
|
|
472
|
+
const expired = () => now() > Date.parse(current.retry_until);
|
|
473
|
+
try {
|
|
474
|
+
await options.transport.resolveTarget(run.controller);
|
|
475
|
+
}
|
|
476
|
+
catch (error) {
|
|
477
|
+
const failure = failureOf(error);
|
|
478
|
+
// An undiscovered controller is not a refusal: the queue keeps the result until the peer
|
|
479
|
+
// becomes resolvable or the window closes.
|
|
480
|
+
if (expired())
|
|
481
|
+
await settle('expired', failure.message);
|
|
482
|
+
else
|
|
483
|
+
await settle('pending', `target-unresolved: ${failure.message}`);
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
try {
|
|
487
|
+
const outcome = await options.transport.send(run.controller, resultMessage(run));
|
|
488
|
+
if (outcome.admission === 'accepted') {
|
|
489
|
+
current.state = 'accepted';
|
|
490
|
+
delete current.error;
|
|
491
|
+
delete current.next_attempt_at;
|
|
492
|
+
if (outcome.receipt !== undefined) {
|
|
493
|
+
current.receipt = { thread: outcome.receipt.thread_ref, seq: outcome.receipt.seq };
|
|
494
|
+
}
|
|
495
|
+
push.delete(pushKey(entry.task_id, entry.run_id));
|
|
496
|
+
await dropPush(entry);
|
|
497
|
+
await saveRun(run);
|
|
498
|
+
settled.accepted += 1;
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
const message = outcome.error?.message ?? 'The delivery admission is unsettled.';
|
|
502
|
+
if (outcome.admission === 'refused')
|
|
503
|
+
await settle('refused', message);
|
|
504
|
+
else if (expired())
|
|
505
|
+
await settle('expired', message);
|
|
506
|
+
else
|
|
507
|
+
await settle('unknown', message);
|
|
508
|
+
}
|
|
509
|
+
catch (error) {
|
|
510
|
+
const failure = failureOf(error);
|
|
511
|
+
if (TERMINAL_PUSH[failure.code] === true)
|
|
512
|
+
await settle('refused', failure.message);
|
|
513
|
+
else if (expired())
|
|
514
|
+
await settle('expired', failure.message);
|
|
515
|
+
else
|
|
516
|
+
await settle('unknown', failure.message);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
function schedulePushes() {
|
|
520
|
+
const at = now();
|
|
521
|
+
for (const [key, entry] of push) {
|
|
522
|
+
if (pushing.has(key))
|
|
523
|
+
continue;
|
|
524
|
+
if (Date.parse(entry.next_attempt_at) > at)
|
|
525
|
+
continue;
|
|
526
|
+
pushing.add(key);
|
|
527
|
+
void attemptPush(entry)
|
|
528
|
+
.catch(error => log('mount.push.failed', { task: entry.task_id, run: entry.run_id, error: String(error) }))
|
|
529
|
+
.finally(() => pushing.delete(key));
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
// ---- scheduling -------------------------------------------------------------
|
|
533
|
+
async function startDue(nowMs) {
|
|
534
|
+
const due = [...tasks.values()]
|
|
535
|
+
.filter(task => task.state === 'active' && task.next_run_at !== null && Date.parse(task.next_run_at) <= nowMs)
|
|
536
|
+
.sort((a, b) => ((a.next_run_at ?? '') < (b.next_run_at ?? '') ? -1 : 1));
|
|
537
|
+
for (const task of due) {
|
|
538
|
+
if (stopping)
|
|
539
|
+
return;
|
|
540
|
+
const running = [...active.values()].find(entry => entry.task_id === task.id);
|
|
541
|
+
if (running !== undefined) {
|
|
542
|
+
await recordSkipped(task, nowMs, 'the previous run is still active');
|
|
543
|
+
advanceOverdue(task, nowMs);
|
|
544
|
+
task.updated_at = iso(nowMs);
|
|
545
|
+
await saveTask(task);
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
await startRun(task, nowMs);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
async function loadStore() {
|
|
552
|
+
const at = now();
|
|
553
|
+
const stored = await storage.transaction(async (tx) => ({
|
|
554
|
+
tasks: (await tx.scan('mounts/task/')).map(row => row.value),
|
|
555
|
+
runs: (await tx.scan('mounts/run/')).map(row => row.value),
|
|
556
|
+
pushes: (await tx.scan('mounts/push/')).map(row => row.value),
|
|
557
|
+
}));
|
|
558
|
+
for (const task of stored.tasks)
|
|
559
|
+
tasks.set(task.id, task);
|
|
560
|
+
for (const entry of stored.pushes)
|
|
561
|
+
push.set(pushKey(entry.task_id, entry.run_id), entry);
|
|
562
|
+
const runsByTask = new Map();
|
|
563
|
+
for (const run of stored.runs) {
|
|
564
|
+
const list = runsByTask.get(run.task_id) ?? [];
|
|
565
|
+
list.push(run);
|
|
566
|
+
runsByTask.set(run.task_id, list);
|
|
567
|
+
if (run.state === 'running') {
|
|
568
|
+
const task = tasks.get(run.task_id);
|
|
569
|
+
run.state = 'interrupted';
|
|
570
|
+
run.finished_at = iso(at);
|
|
571
|
+
run.error = { code: 'interrupted', message: 'The gateway stopped before this run finished; the external effect is unconfirmed.' };
|
|
572
|
+
if (task !== undefined) {
|
|
573
|
+
task.last_run = runSummary(run);
|
|
574
|
+
task.updated_at = run.finished_at;
|
|
575
|
+
}
|
|
576
|
+
if (run.push !== undefined && run.push.state !== 'accepted') {
|
|
577
|
+
run.push.state = 'pending';
|
|
578
|
+
run.push.attempts = 0;
|
|
579
|
+
const entry = {
|
|
580
|
+
task_id: run.task_id, run_id: run.id, state: 'pending', attempts: 0, next_attempt_at: run.finished_at,
|
|
581
|
+
};
|
|
582
|
+
push.set(pushKey(run.task_id, run.id), entry);
|
|
583
|
+
await savePush(entry);
|
|
584
|
+
}
|
|
585
|
+
await saveRun(run);
|
|
586
|
+
log('mount.run.interrupted', { task: run.task_id, run: run.id });
|
|
587
|
+
}
|
|
588
|
+
noteRecent(run);
|
|
589
|
+
}
|
|
590
|
+
for (const task of tasks.values()) {
|
|
591
|
+
if (task.state !== 'active')
|
|
592
|
+
continue;
|
|
593
|
+
const runs = runsByTask.get(task.id) ?? [];
|
|
594
|
+
if (task.schedule.kind === 'once') {
|
|
595
|
+
// A one-shot mount fires once. A finished or interrupted attempt counts as fired, so a
|
|
596
|
+
// restart never repeats a side effect it cannot prove did not happen.
|
|
597
|
+
if (runs.length > 0) {
|
|
598
|
+
task.state = 'completed';
|
|
599
|
+
task.next_run_at = null;
|
|
600
|
+
await saveTask(task);
|
|
601
|
+
}
|
|
602
|
+
else if (task.next_run_at === null) {
|
|
603
|
+
task.next_run_at = iso(at);
|
|
604
|
+
await saveTask(task);
|
|
605
|
+
}
|
|
606
|
+
continue;
|
|
607
|
+
}
|
|
608
|
+
const next = task.next_run_at === null ? undefined : Date.parse(task.next_run_at);
|
|
609
|
+
if (task.schedule.kind === 'interval') {
|
|
610
|
+
// Missed occurrences coalesce into one immediate run: the scheduler never bursts through
|
|
611
|
+
// a backlog it cannot prove the side effects of.
|
|
612
|
+
if (next === undefined || next <= at) {
|
|
613
|
+
task.next_run_at = iso(at);
|
|
614
|
+
await saveTask(task);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
else if (next === undefined || next <= at) {
|
|
618
|
+
const computed = nextCronTime(parseCron(task.schedule.expression), at);
|
|
619
|
+
task.next_run_at = computed === undefined ? null : iso(computed);
|
|
620
|
+
if (task.next_run_at === null)
|
|
621
|
+
task.state = 'completed';
|
|
622
|
+
await saveTask(task);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
// ---- operations -------------------------------------------------------------
|
|
627
|
+
async function mount(controller, data) {
|
|
628
|
+
const at = now();
|
|
629
|
+
const schedule = parseSchedule(data.schedule);
|
|
630
|
+
const action = parseAction(controller, data.action);
|
|
631
|
+
const title = parseTitle(data.title);
|
|
632
|
+
const id = `mnt-${at.toString(36)}-${crypto.randomUUID().slice(0, 6)}`;
|
|
633
|
+
const task = {
|
|
634
|
+
id, controller, state: 'active',
|
|
635
|
+
...(title === undefined ? {} : { title }),
|
|
636
|
+
schedule, action,
|
|
637
|
+
// A fixed interval starts now: the first run fires on the next pass, then start-to-start.
|
|
638
|
+
next_run_at: nextFor(schedule, at, schedule.kind === 'interval'),
|
|
639
|
+
thread: { owner, id: `mnt-${id}` },
|
|
640
|
+
created_at: iso(at), updated_at: iso(at),
|
|
641
|
+
changes: [], last_run: null,
|
|
642
|
+
};
|
|
643
|
+
change(task, controller, 'mount', { schedule, definition: action, ...(title === undefined ? {} : { title }) });
|
|
644
|
+
await options.transport.ensureThread(task.thread.id, `mount ${id}`);
|
|
645
|
+
tasks.set(id, task);
|
|
646
|
+
await saveTask(task);
|
|
647
|
+
log('mount.created', { task: id, controller, schedule: describeSchedule(schedule) });
|
|
648
|
+
return wire({ task });
|
|
649
|
+
}
|
|
650
|
+
async function update(controller, data) {
|
|
651
|
+
const task = requireTask(controller, parseTaskId(data));
|
|
652
|
+
if (task.state === 'removed')
|
|
653
|
+
throw new OnlyneError('invalid_request', 'A removed mount cannot be updated.');
|
|
654
|
+
const title = parseTitle(data.title);
|
|
655
|
+
const schedule = data.schedule === undefined ? undefined : parseSchedule(data.schedule);
|
|
656
|
+
const action = data.action === undefined ? undefined : parseAction(controller, data.action);
|
|
657
|
+
if (title === undefined && schedule === undefined && action === undefined) {
|
|
658
|
+
throw new OnlyneError('invalid_request', 'An update names at least one of title, schedule or action.');
|
|
659
|
+
}
|
|
660
|
+
if (title !== undefined)
|
|
661
|
+
task.title = title;
|
|
662
|
+
if (action !== undefined)
|
|
663
|
+
task.action = action;
|
|
664
|
+
if (schedule !== undefined) {
|
|
665
|
+
task.schedule = schedule;
|
|
666
|
+
// A schedule change takes effect from now, never by replaying occurrences missed while the
|
|
667
|
+
// old definition was in force. An interval waits one full period so an update is not
|
|
668
|
+
// itself a trigger; an active run keeps the definition it started with.
|
|
669
|
+
task.next_run_at = task.state === 'active' ? nextFor(schedule, now(), false) : null;
|
|
670
|
+
}
|
|
671
|
+
task.updated_at = iso(now());
|
|
672
|
+
change(task, controller, 'update', {
|
|
673
|
+
...(title === undefined ? {} : { title }),
|
|
674
|
+
...(schedule === undefined ? {} : { schedule }),
|
|
675
|
+
...(action === undefined ? {} : { definition: action }),
|
|
676
|
+
});
|
|
677
|
+
await saveTask(task);
|
|
678
|
+
log('mount.updated', { task: task.id, controller });
|
|
679
|
+
return wire({ task });
|
|
680
|
+
}
|
|
681
|
+
function stopRunsOf(taskId) {
|
|
682
|
+
for (const [runId, entry] of active) {
|
|
683
|
+
if (entry.task_id !== taskId)
|
|
684
|
+
continue;
|
|
685
|
+
stopChild(entry.child);
|
|
686
|
+
log('mount.run.cancelled', { task: taskId, run: runId });
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
async function cancel(controller, data) {
|
|
690
|
+
const task = requireTask(controller, parseTaskId(data));
|
|
691
|
+
if (task.state === 'active') {
|
|
692
|
+
task.state = 'cancelled';
|
|
693
|
+
task.next_run_at = null;
|
|
694
|
+
task.updated_at = iso(now());
|
|
695
|
+
change(task, controller, 'cancel');
|
|
696
|
+
await saveTask(task);
|
|
697
|
+
stopRunsOf(task.id);
|
|
698
|
+
log('mount.cancelled', { task: task.id, controller });
|
|
699
|
+
}
|
|
700
|
+
return wire({ task });
|
|
701
|
+
}
|
|
702
|
+
async function resume(controller, data) {
|
|
703
|
+
const task = requireTask(controller, parseTaskId(data));
|
|
704
|
+
if (task.state === 'removed')
|
|
705
|
+
throw new OnlyneError('invalid_request', 'A removed mount cannot be resumed.');
|
|
706
|
+
if (task.state !== 'active') {
|
|
707
|
+
task.state = 'active';
|
|
708
|
+
task.next_run_at = nextFor(task.schedule, now(), task.schedule.kind !== 'cron');
|
|
709
|
+
task.updated_at = iso(now());
|
|
710
|
+
change(task, controller, 'resume');
|
|
711
|
+
await saveTask(task);
|
|
712
|
+
log('mount.resumed', { task: task.id, controller });
|
|
713
|
+
}
|
|
714
|
+
return wire({ task });
|
|
715
|
+
}
|
|
716
|
+
async function remove(controller, data) {
|
|
717
|
+
const task = requireTask(controller, parseTaskId(data));
|
|
718
|
+
if (task.state !== 'removed') {
|
|
719
|
+
task.state = 'removed';
|
|
720
|
+
task.next_run_at = null;
|
|
721
|
+
task.updated_at = iso(now());
|
|
722
|
+
change(task, controller, 'remove');
|
|
723
|
+
await saveTask(task);
|
|
724
|
+
stopRunsOf(task.id);
|
|
725
|
+
log('mount.removed', { task: task.id, controller });
|
|
726
|
+
}
|
|
727
|
+
return wire({ task });
|
|
728
|
+
}
|
|
729
|
+
async function list(controller, data) {
|
|
730
|
+
const includeRemoved = data.include_removed === true;
|
|
731
|
+
const items = [...tasks.values()]
|
|
732
|
+
.filter(task => task.controller === controller && (includeRemoved || task.state !== 'removed'))
|
|
733
|
+
.sort((a, b) => (a.created_at < b.created_at ? 1 : -1))
|
|
734
|
+
.map(task => structuredClone(task));
|
|
735
|
+
return wire({ tasks: items });
|
|
736
|
+
}
|
|
737
|
+
async function inspect(controller, data) {
|
|
738
|
+
const task = requireTask(controller, parseTaskId(data));
|
|
739
|
+
const runs = (await storage.transaction(tx => tx.scan(runPrefix(task.id))))
|
|
740
|
+
.map(row => row.value)
|
|
741
|
+
.sort((a, b) => (a.started_at < b.started_at ? 1 : -1))
|
|
742
|
+
.slice(0, 10);
|
|
743
|
+
return wire({ task, runs, audit: task.changes });
|
|
744
|
+
}
|
|
745
|
+
async function runs(controller, data) {
|
|
746
|
+
const task = requireTask(controller, parseTaskId(data));
|
|
747
|
+
const limit = typeof data.limit === 'number' && Number.isInteger(data.limit) && data.limit > 0 && data.limit <= 100
|
|
748
|
+
? data.limit
|
|
749
|
+
: 20;
|
|
750
|
+
const items = (await storage.transaction(tx => tx.scan(runPrefix(task.id))))
|
|
751
|
+
.map(row => row.value)
|
|
752
|
+
.sort((a, b) => (a.started_at < b.started_at ? 1 : -1))
|
|
753
|
+
.slice(0, limit);
|
|
754
|
+
return wire({ task_id: task.id, runs: items });
|
|
755
|
+
}
|
|
756
|
+
const OPERATIONS = {
|
|
757
|
+
mount, update, list, inspect, runs, cancel, resume, remove,
|
|
758
|
+
};
|
|
759
|
+
// ---- driver -----------------------------------------------------------------
|
|
760
|
+
const driver = {
|
|
761
|
+
async validate(resource, operation, data) {
|
|
762
|
+
const controller = controllerOf(resource);
|
|
763
|
+
if (!(operation in OPERATIONS)) {
|
|
764
|
+
throw new OnlyneError('unsupported_delivery', `The scheduler does not declare the operation ${operation}.`);
|
|
765
|
+
}
|
|
766
|
+
const value = data === undefined ? {} : recordOf(data, 'The operation payload');
|
|
767
|
+
if (operation === 'mount') {
|
|
768
|
+
if (value.schedule === undefined)
|
|
769
|
+
throw new OnlyneError('invalid_request', 'A mount names its schedule.');
|
|
770
|
+
if (value.action === undefined)
|
|
771
|
+
throw new OnlyneError('invalid_request', 'A mount names its action.');
|
|
772
|
+
parseSchedule(value.schedule);
|
|
773
|
+
parseAction(controller, value.action);
|
|
774
|
+
parseTitle(value.title);
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
if (operation === 'update') {
|
|
778
|
+
parseTaskId(value);
|
|
779
|
+
if (value.title === undefined && value.schedule === undefined && value.action === undefined) {
|
|
780
|
+
throw new OnlyneError('invalid_request', 'An update names at least one of title, schedule or action.');
|
|
781
|
+
}
|
|
782
|
+
if (value.schedule !== undefined)
|
|
783
|
+
parseSchedule(value.schedule);
|
|
784
|
+
if (value.action !== undefined)
|
|
785
|
+
parseAction(controller, value.action);
|
|
786
|
+
parseTitle(value.title);
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
if (operation === 'list') {
|
|
790
|
+
if (value.include_removed !== undefined && typeof value.include_removed !== 'boolean') {
|
|
791
|
+
throw new OnlyneError('invalid_request', 'include_removed is a boolean.');
|
|
792
|
+
}
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
parseTaskId(value);
|
|
796
|
+
if (operation === 'runs' && value.limit !== undefined) {
|
|
797
|
+
const limit = value.limit;
|
|
798
|
+
if (typeof limit !== 'number' || !Number.isInteger(limit) || limit < 1 || limit > 100) {
|
|
799
|
+
throw new OnlyneError('invalid_request', 'limit is an integer from 1 to 100.');
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
},
|
|
803
|
+
async execute(resource, request) {
|
|
804
|
+
const controller = controllerOf(resource);
|
|
805
|
+
const run = OPERATIONS[request.operation];
|
|
806
|
+
if (run === undefined)
|
|
807
|
+
throw new OnlyneError('unsupported_delivery', `The scheduler does not declare the operation ${request.operation}.`);
|
|
808
|
+
const data = request.data === undefined ? {} : recordOf(request.data, 'The operation payload');
|
|
809
|
+
return { state: 'committed', data: await run(controller, data) };
|
|
810
|
+
},
|
|
811
|
+
};
|
|
812
|
+
const service = {
|
|
813
|
+
driver,
|
|
814
|
+
async start() {
|
|
815
|
+
if (config === undefined || config.controllers.length === 0)
|
|
816
|
+
return;
|
|
817
|
+
await loadStore();
|
|
818
|
+
timer = setInterval(() => { void service.tick(); }, tickMs);
|
|
819
|
+
log('mounts.started', { controllers: config.controllers, tick_ms: tickMs });
|
|
820
|
+
},
|
|
821
|
+
async stop() {
|
|
822
|
+
if (stopping)
|
|
823
|
+
return;
|
|
824
|
+
stopping = true;
|
|
825
|
+
clearInterval(timer);
|
|
826
|
+
timer = undefined;
|
|
827
|
+
const pending = [...active.values()];
|
|
828
|
+
for (const entry of pending)
|
|
829
|
+
stopChild(entry.child);
|
|
830
|
+
// The child may outlive SIGTERM; the escalation timer is unref'd, so shutdown itself does
|
|
831
|
+
// not wait for it. Give the normal exit path a moment, then repair whatever is left.
|
|
832
|
+
const bounded = Promise.withResolvers();
|
|
833
|
+
const deadline = setTimeout(bounded.resolve, 2_000);
|
|
834
|
+
deadline.unref?.();
|
|
835
|
+
await Promise.race([Promise.allSettled(pending.map(entry => entry.done)), bounded.promise]);
|
|
836
|
+
clearTimeout(deadline);
|
|
837
|
+
for (const [runId, entry] of active) {
|
|
838
|
+
const run = await loadRun(entry.task_id, runId);
|
|
839
|
+
if (run !== undefined && run.state === 'running') {
|
|
840
|
+
run.state = 'interrupted';
|
|
841
|
+
run.finished_at = iso(now());
|
|
842
|
+
run.error = { code: 'interrupted', message: 'The gateway stopped before this run finished; the external effect is unconfirmed.' };
|
|
843
|
+
if (run.push !== undefined && run.push.state !== 'accepted') {
|
|
844
|
+
run.push.state = 'pending';
|
|
845
|
+
run.push.attempts = 0;
|
|
846
|
+
const queued = {
|
|
847
|
+
task_id: run.task_id, run_id: run.id, state: 'pending', attempts: 0, next_attempt_at: run.finished_at,
|
|
848
|
+
};
|
|
849
|
+
push.set(pushKey(run.task_id, run.id), queued);
|
|
850
|
+
await savePush(queued);
|
|
851
|
+
}
|
|
852
|
+
const task = tasks.get(run.task_id);
|
|
853
|
+
if (task !== undefined) {
|
|
854
|
+
task.last_run = runSummary(run);
|
|
855
|
+
task.updated_at = run.finished_at;
|
|
856
|
+
if (task.state === 'active' && task.schedule.kind === 'once')
|
|
857
|
+
task.state = 'completed';
|
|
858
|
+
await saveTask(task);
|
|
859
|
+
}
|
|
860
|
+
await saveRun(run);
|
|
861
|
+
}
|
|
862
|
+
active.delete(runId);
|
|
863
|
+
}
|
|
864
|
+
log('mounts.stopped', {});
|
|
865
|
+
},
|
|
866
|
+
async tick() {
|
|
867
|
+
if (stopping || ticking || config === undefined)
|
|
868
|
+
return;
|
|
869
|
+
ticking = true;
|
|
870
|
+
try {
|
|
871
|
+
schedulePushes();
|
|
872
|
+
await startDue(now());
|
|
873
|
+
}
|
|
874
|
+
catch (error) {
|
|
875
|
+
log('mounts.tick.failed', { error: String(error) });
|
|
876
|
+
}
|
|
877
|
+
finally {
|
|
878
|
+
ticking = false;
|
|
879
|
+
}
|
|
880
|
+
},
|
|
881
|
+
async status() {
|
|
882
|
+
const lines = [];
|
|
883
|
+
const names = [...options.connections.entries()].map(([name, connection]) => `${name}(${connection.tools.length})`);
|
|
884
|
+
lines.push(`servers: ${names.length === 0 ? 'none' : names.join(' ')}`);
|
|
885
|
+
const activeTasks = [...tasks.values()].filter(task => task.state === 'active');
|
|
886
|
+
const running = activeTasks.filter(task => [...active.values()].some(entry => entry.task_id === task.id));
|
|
887
|
+
lines.push(`mounts: ${activeTasks.length} active, ${running.length} running`);
|
|
888
|
+
const queued = [...push.values()];
|
|
889
|
+
const pending = queued.filter(entry => entry.state === 'pending').length;
|
|
890
|
+
const unknown = queued.filter(entry => entry.state === 'unknown').length;
|
|
891
|
+
lines.push(`push: ${pending} pending, ${unknown} unknown, ${settled.refused} refused`);
|
|
892
|
+
const last = recent[0];
|
|
893
|
+
lines.push(last === undefined
|
|
894
|
+
? 'last: none'
|
|
895
|
+
: `last: ${last.task_id} ${last.state} exit=${last.exit_code ?? '-'} ${timeOf(last.finished_at ?? last.started_at)}`);
|
|
896
|
+
// The four global lines come first so a 24-row screen shows categories before detail.
|
|
897
|
+
const shown = activeTasks.slice(0, activeTasks.length > 4 ? 3 : 4);
|
|
898
|
+
for (const task of shown) {
|
|
899
|
+
const runningNow = running.includes(task) ? ' running' : '';
|
|
900
|
+
lines.push(` ${task.id} next=${timeOf(task.next_run_at)} ${describeSchedule(task.schedule)}${runningNow}`);
|
|
901
|
+
}
|
|
902
|
+
if (activeTasks.length > shown.length)
|
|
903
|
+
lines.push(` +${activeTasks.length - shown.length} more`);
|
|
904
|
+
return lines;
|
|
905
|
+
},
|
|
906
|
+
resources() {
|
|
907
|
+
if (config === undefined)
|
|
908
|
+
return [];
|
|
909
|
+
return config.controllers.map(controller => ({
|
|
910
|
+
controller,
|
|
911
|
+
resource: {
|
|
912
|
+
resource_ref: { owner, id: mountsResourceId(controller) },
|
|
913
|
+
kind: 'mounts',
|
|
914
|
+
operations: [...MOUNT_OPERATIONS],
|
|
915
|
+
binding: { handler: controller, cursor: '0' },
|
|
916
|
+
log_readers: [...new Set([owner, controller])],
|
|
917
|
+
revision: 'rev-1',
|
|
918
|
+
event_seq: '0',
|
|
919
|
+
retention_ms: 0,
|
|
920
|
+
data: { kind: 'mounts', controller },
|
|
921
|
+
},
|
|
922
|
+
}));
|
|
923
|
+
},
|
|
924
|
+
};
|
|
925
|
+
return service;
|
|
926
|
+
}
|
|
927
|
+
//# sourceMappingURL=mounts.js.map
|