mandala-computer-mcp 0.1.0 → 0.3.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 +127 -16
- package/dist/api.d.ts +19 -6
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +261 -57
- package/dist/api.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +113 -8
- package/dist/cli.js.map +1 -1
- package/dist/errors.d.ts +74 -9
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +115 -25
- package/dist/errors.js.map +1 -1
- package/dist/events.d.ts +36 -4
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +223 -33
- package/dist/events.js.map +1 -1
- package/dist/format.d.ts +48 -0
- package/dist/format.d.ts.map +1 -1
- package/dist/format.js +31 -1
- package/dist/format.js.map +1 -1
- package/dist/http-body.d.ts +17 -0
- package/dist/http-body.d.ts.map +1 -0
- package/dist/http-body.js +48 -0
- package/dist/http-body.js.map +1 -0
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +177 -51
- package/dist/http.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/paths.d.ts +28 -18
- package/dist/paths.d.ts.map +1 -1
- package/dist/paths.js +85 -22
- package/dist/paths.js.map +1 -1
- package/dist/poll.d.ts +103 -0
- package/dist/poll.d.ts.map +1 -0
- package/dist/poll.js +129 -0
- package/dist/poll.js.map +1 -0
- package/dist/server.d.ts +1 -1
- package/dist/server.js +1 -1
- package/dist/tools/agent.d.ts.map +1 -1
- package/dist/tools/agent.js +14 -2
- package/dist/tools/agent.js.map +1 -1
- package/dist/tools/computers.d.ts.map +1 -1
- package/dist/tools/computers.js +346 -62
- package/dist/tools/computers.js.map +1 -1
- package/dist/tools/events.d.ts.map +1 -1
- package/dist/tools/events.js +295 -49
- package/dist/tools/events.js.map +1 -1
- package/dist/tools/guest.d.ts.map +1 -1
- package/dist/tools/guest.js +228 -32
- package/dist/tools/guest.js.map +1 -1
- package/dist/tools/input.d.ts.map +1 -1
- package/dist/tools/input.js +29 -3
- package/dist/tools/input.js.map +1 -1
- package/dist/tools/snapshots.d.ts.map +1 -1
- package/dist/tools/snapshots.js +478 -20
- package/dist/tools/snapshots.js.map +1 -1
- package/dist/tools/templates.d.ts.map +1 -1
- package/dist/tools/templates.js +52 -13
- package/dist/tools/templates.js.map +1 -1
- package/dist/tools/webhooks.d.ts.map +1 -1
- package/dist/tools/webhooks.js +116 -17
- package/dist/tools/webhooks.js.map +1 -1
- package/package.json +3 -2
package/dist/tools/computers.js
CHANGED
|
@@ -1,47 +1,14 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { CancelledError, isTransientForPoll, MoveRequiredError, NotFoundError,
|
|
3
|
-
import { describe, guarded, incompleteWarning, json, refused, said, unwrapComputer, withoutCredentials, } from '../format.js';
|
|
2
|
+
import { CancelledError, ConflictError, isTransientForPoll, MoveRequiredError, NotFoundError, } from '../errors.js';
|
|
3
|
+
import { describe, guarded, incompleteWarning, json, nothingAdmitted, refused, said, unwrapComputer, withoutCredentials, } from '../format.js';
|
|
4
4
|
import * as P from '../paths.js';
|
|
5
|
+
import { heartbeat, POLL_MS, pollDelay, sleep } from '../poll.js';
|
|
5
6
|
const idArg = {
|
|
6
7
|
computer_id: z
|
|
7
8
|
.string()
|
|
8
9
|
.optional()
|
|
9
10
|
.describe('Which computer. Defaults to the one selected with use_computer.'),
|
|
10
11
|
};
|
|
11
|
-
/**
|
|
12
|
-
* A pause that ends early when the caller gives up.
|
|
13
|
-
*
|
|
14
|
-
* The wait loops check the signal at the top of each turn, so a sleep that
|
|
15
|
-
* ignored it would still hold a cancelled call for its remaining seconds.
|
|
16
|
-
*/
|
|
17
|
-
const sleep = (ms, signal) => new Promise((resolve) => {
|
|
18
|
-
if (signal?.aborted)
|
|
19
|
-
return resolve();
|
|
20
|
-
const t = setTimeout(done, ms);
|
|
21
|
-
function done() {
|
|
22
|
-
clearTimeout(t);
|
|
23
|
-
signal?.removeEventListener('abort', done);
|
|
24
|
-
resolve();
|
|
25
|
-
}
|
|
26
|
-
signal?.addEventListener('abort', done, { once: true });
|
|
27
|
-
});
|
|
28
|
-
/** How long these loops leave between polls. */
|
|
29
|
-
const POLL_MS = 2_000;
|
|
30
|
-
/**
|
|
31
|
-
* The same interval, unless the platform asked for longer.
|
|
32
|
-
*
|
|
33
|
-
* A 429 is the one failure that says how long to wait, and
|
|
34
|
-
* {@link isTransientForPoll} now polls through it — so a loop that ignored
|
|
35
|
-
* `Retry-After` and asked again in two seconds would be spending a rate limit
|
|
36
|
-
* to discover it was still rate limited. The floor stays {@link POLL_MS}: the
|
|
37
|
-
* header can say zero, and a poll loop with no interval is a request storm.
|
|
38
|
-
*
|
|
39
|
-
* Only reached from a failed poll, which is why it takes the error rather than
|
|
40
|
-
* living in {@link sleep}: an ordinary turn has nothing to honour.
|
|
41
|
-
*/
|
|
42
|
-
const pollDelay = (err) => err instanceof RateLimitError && err.retryAfterMs !== undefined
|
|
43
|
-
? Math.max(POLL_MS, err.retryAfterMs)
|
|
44
|
-
: POLL_MS;
|
|
45
12
|
/**
|
|
46
13
|
* The answer to a wait the caller ended.
|
|
47
14
|
*
|
|
@@ -82,8 +49,15 @@ const movesOf = (body) => {
|
|
|
82
49
|
const moves = list.filter((row) => row !== null && typeof row === 'object' && !Array.isArray(row));
|
|
83
50
|
return { moves, dropped: list.length - moves.length };
|
|
84
51
|
};
|
|
85
|
-
/**
|
|
86
|
-
|
|
52
|
+
/**
|
|
53
|
+
* What arrived where a list was expected, for a refusal that names it.
|
|
54
|
+
*
|
|
55
|
+
* A list is called a list. `typeof []` is `'object'`, and the usage refusal is
|
|
56
|
+
* reached BY an array — its guard rejects one explicitly — so without this it
|
|
57
|
+
* reports a body that arrived as an "object where the totals object goes",
|
|
58
|
+
* which is a contradiction in the sentence a model has to act on.
|
|
59
|
+
*/
|
|
60
|
+
const shapeOf = (v) => v === undefined ? 'no body at all' : v === null ? 'null' : Array.isArray(v) ? 'a list' : typeof v;
|
|
87
61
|
/**
|
|
88
62
|
* The resize refusal that is an OFFER, turned into a next step (OPL-3775).
|
|
89
63
|
*
|
|
@@ -127,12 +101,34 @@ const moveLine = (m) => `${m.computer_id}: ${m.state}${m.live ? ' (running)' : '
|
|
|
127
101
|
* that could not be reached and comes right when it comes back, `unmetered` is a
|
|
128
102
|
* host running a daemon older than the meter, and telling a caller to wait for
|
|
129
103
|
* that one is advice that never comes true.
|
|
104
|
+
*
|
|
105
|
+
* EVERY DIMENSION THE PLATFORM PRICES IS IN THE LINE. This sentence is what a
|
|
106
|
+
* model reads before it acts on cost, and a dimension the account is billed on
|
|
107
|
+
* that appears only in the JSON underneath is a figure nobody weighs. Two were
|
|
108
|
+
* missing: `ram_gb_hours`, although the tool's own description promises hours
|
|
109
|
+
* "weighted by cores and memory", and `snapshot_gb_months`, which the API
|
|
110
|
+
* reference calls the unit snapshots are priced in — the figure that explains
|
|
111
|
+
* the bill
|
|
112
|
+
* of an account holding many durable snapshots and running almost nothing.
|
|
113
|
+
*
|
|
114
|
+
* The `_hours` twins of the two `_months` figures are deliberately NOT here.
|
|
115
|
+
* `disk_gb_hours` and `snapshot_gb_hours` are the same integral in the unit the
|
|
116
|
+
* platform does not price, so printing both spellings would double the length
|
|
117
|
+
* of the line to say each thing twice. Add to this line when the platform
|
|
118
|
+
* prices something new, not when it reports something new.
|
|
119
|
+
*
|
|
120
|
+
* Zeroes are printed rather than omitted, like every other figure here: an
|
|
121
|
+
* account that ran nothing weighted by memory metered zero, and a clause that
|
|
122
|
+
* disappears makes that indistinguishable from a platform that did not send the
|
|
123
|
+
* figure at all — the distinction `get_usage` already refuses a missing totals
|
|
124
|
+
* object in order to keep.
|
|
130
125
|
*/
|
|
131
126
|
const usageLine = (u) => {
|
|
132
127
|
const t = u.usage ?? {};
|
|
133
128
|
const window = u.from && u.to ? `${u.from} to ${u.to}` : 'this billing period';
|
|
134
|
-
const head = `${t.vcpu_hours ?? 0} vCPU-hours, ${t.
|
|
135
|
-
`${t.disk_gb_months ?? 0} GB-months of disk
|
|
129
|
+
const head = `${t.vcpu_hours ?? 0} vCPU-hours, ${t.ram_gb_hours ?? 0} GB-hours of RAM, ` +
|
|
130
|
+
`${t.run_hours ?? 0} running hours, ${t.disk_gb_months ?? 0} GB-months of disk ` +
|
|
131
|
+
`and ${t.snapshot_gb_months ?? 0} GB-months of snapshots over ${window}.`;
|
|
136
132
|
const short = [
|
|
137
133
|
u.degraded && 'a hypervisor could not be reached (retry — this one clears)',
|
|
138
134
|
u.unmetered &&
|
|
@@ -180,6 +176,16 @@ const finishedMove = (id, m) => {
|
|
|
180
176
|
return refused(`The move of ${id} stopped being watched, so we cannot say whether it finished.${detail} Read ` +
|
|
181
177
|
`get_computer to see which size it is at now before doing anything else.`, m);
|
|
182
178
|
};
|
|
179
|
+
/**
|
|
180
|
+
* What follows an acknowledged power action, where anything does. A start is
|
|
181
|
+
* the one whose "ok" is furthest from "usable": the VM is booting or resuming
|
|
182
|
+
* and the desktop inside it answers later, which is the gap the second line of
|
|
183
|
+
* the server instructions exists for.
|
|
184
|
+
*/
|
|
185
|
+
const POWER_NEXT = {
|
|
186
|
+
start: ' wait_for_computer with until="guest" is what says when the desktop is answering.',
|
|
187
|
+
restart: ' wait_for_computer with until="guest" is what says when the desktop is back.',
|
|
188
|
+
};
|
|
183
189
|
const POWER_DESCRIPTIONS = {
|
|
184
190
|
start: 'Boot a computer, or resume a suspended one — a resume restores the saved session, same processes and windows, in about a second.',
|
|
185
191
|
stop: 'Shut a computer down: the guest is asked, and given time to do it. Discards a saved session if there is one. The disk is kept. `force` pulls the power instead, for a guest that will not come down on its own.',
|
|
@@ -192,7 +198,15 @@ export const registerComputers = (server, session, opts) => {
|
|
|
192
198
|
description: 'The base images a computer can be created from — name, OS, and the default CPU, RAM and disk each one implies.',
|
|
193
199
|
inputSchema: {},
|
|
194
200
|
annotations: { readOnlyHint: true },
|
|
195
|
-
}, (_args, extra) => guarded(async () =>
|
|
201
|
+
}, (_args, extra) => guarded(async () => {
|
|
202
|
+
const { items, incomplete } = await session.api.with(extra.signal).listing(P.TEMPLATES);
|
|
203
|
+
if (items === undefined || items === null) {
|
|
204
|
+
return refused('The platform did not return a template catalogue. Retry list_templates.');
|
|
205
|
+
}
|
|
206
|
+
return incomplete === null
|
|
207
|
+
? json(items)
|
|
208
|
+
: said(incompleteWarning('templates', incomplete).trimEnd(), items);
|
|
209
|
+
}));
|
|
196
210
|
server.registerTool('list_sizes', {
|
|
197
211
|
title: 'List sizes',
|
|
198
212
|
description: `The named sizes a computer can be launched at — each a template plus a CPU/RAM/disk shape. These are the shapes the platform keeps pre-booted, so ${opts.lifecycle ? 'create_computer with a `size`' : 'a create naming a `size`'} is typically answered in about a second where a custom shape boots cold. ` +
|
|
@@ -202,15 +216,28 @@ export const registerComputers = (server, session, opts) => {
|
|
|
202
216
|
}, (_args, extra) => guarded(async () => json(await session.api.with(extra.signal).json('GET', P.SIZES))));
|
|
203
217
|
server.registerTool('list_computers', {
|
|
204
218
|
title: 'List computers',
|
|
205
|
-
description:
|
|
219
|
+
description: "Every computer on this account that exists or may exist. Desktop credentials are deliberately not included — use get_desktop_url for those. Read `state` before acting on a row: it is the platform's record of whether the machine exists, which is a different question from `status`, what its host says the guest is doing. A row reading `deleting` is on its way out and is not one to bind, start or wait for, and one reading `unreachable` is a row served from the record because the host did not answer — the computer is most likely fine, but nothing only its host knows is on it, `status` included. The two terminal states are not here at all: `deleted` and `lost` come back only when asked for with `state`.",
|
|
206
220
|
inputSchema: {
|
|
207
221
|
allow_partial: z
|
|
208
222
|
.boolean()
|
|
209
223
|
.optional()
|
|
210
224
|
.describe('Accept a short list when a hypervisor cannot be reached, instead of the 503 the platform answers by default. The answer then says it is short — a short list reads exactly like the missing computers were deleted.'),
|
|
225
|
+
// The control plane's own record, not the guest's (OPL-4554).
|
|
226
|
+
// Read where the listing is assembled and never forwarded to a host, so
|
|
227
|
+
// a filtered listing is as complete as an unfiltered one.
|
|
228
|
+
//
|
|
229
|
+
// Said in the description rather than assumed: omitting this is NOT
|
|
230
|
+
// "every computer". `deleted` and `lost` are terminal and withheld from
|
|
231
|
+
// an unfiltered listing, so this parameter is the only way a caller ever
|
|
232
|
+
// sees one — which is the question somebody asks when a computer they
|
|
233
|
+
// remember is not in the list.
|
|
234
|
+
state: z
|
|
235
|
+
.enum(['live', 'unreachable', 'deleting', 'deleted', 'lost'])
|
|
236
|
+
.optional()
|
|
237
|
+
.describe("Only computers the platform's own record puts in this state — a different question from what the machine is doing, which is `status`. 'live': its host lists it. 'unreachable': its host did not answer THIS request, so the row is the identity on record and not what the machine says; the computer is most likely fine. 'deleting': a delete was sent and not answered yet. 'deleted' and 'lost' are terminal, and asking here is the ONLY way to see one — an unfiltered listing is live, unreachable and deleting, so a computer missing from it may still have a record."),
|
|
211
238
|
},
|
|
212
239
|
annotations: { readOnlyHint: true },
|
|
213
|
-
}, ({ allow_partial }, extra) => guarded(async () => {
|
|
240
|
+
}, ({ allow_partial, state }, extra) => guarded(async () => {
|
|
214
241
|
// listing, not json: with allow_partial the platform will hand over an
|
|
215
242
|
// inventory it knows is short, and says so in X-GC-Incomplete. Reading
|
|
216
243
|
// the body and dropping the header turns "here is part of the fleet"
|
|
@@ -218,7 +245,7 @@ export const registerComputers = (server, session, opts) => {
|
|
|
218
245
|
const { items, incomplete } = await session.api
|
|
219
246
|
.with(extra.signal)
|
|
220
247
|
.listing(P.COMPUTERS, {
|
|
221
|
-
query: { allow_partial: allow_partial ? 1 : undefined },
|
|
248
|
+
query: { allow_partial: allow_partial ? 1 : undefined, state },
|
|
222
249
|
});
|
|
223
250
|
// Checked rather than asserted. `listing<unknown[]>` is a claim about
|
|
224
251
|
// what the platform sends, not a guarantee — a proxy or a future
|
|
@@ -262,6 +289,16 @@ export const registerComputers = (server, session, opts) => {
|
|
|
262
289
|
if (incomplete !== null) {
|
|
263
290
|
return said(`${warning}No computers came back from the part of the fleet that answered. This is NOT an empty account — do not create a computer on the strength of it. Retry in a moment.`);
|
|
264
291
|
}
|
|
292
|
+
// A filtered listing that came back empty is a fact about the FILTER,
|
|
293
|
+
// and the sentence below is a fact about the account. Saying the
|
|
294
|
+
// account is empty because nothing is `deleted` is the same
|
|
295
|
+
// duplicate-create the incomplete branch above guards against,
|
|
296
|
+
// arrived at from a third direction — and this one is silent, since
|
|
297
|
+
// the platform answers a filter that matches nothing exactly as it
|
|
298
|
+
// answers an account with nothing in it.
|
|
299
|
+
if (state) {
|
|
300
|
+
return said(`${warning}No computers on this account are ${state}. Other computers may exist — this listing asked only for that state. Call list_computers without \`state\` to see the account.`);
|
|
301
|
+
}
|
|
265
302
|
// Named only when it is there to call. Under MANDALA_NO_LIFECYCLE
|
|
266
303
|
// create_computer is not registered, and this is the one place the
|
|
267
304
|
// name reached the model at RUN time rather than in a description —
|
|
@@ -290,6 +327,15 @@ export const registerComputers = (server, session, opts) => {
|
|
|
290
327
|
inputSchema: {
|
|
291
328
|
computer_id: z.string().describe('The id from list_computers.'),
|
|
292
329
|
},
|
|
330
|
+
// No readOnlyHint — this is not a pure read, so the hint would overclaim —
|
|
331
|
+
// but destructiveHint set, because the spec defaults it to TRUE once
|
|
332
|
+
// readOnlyHint is absent and this destroys nothing (OPL-4516). Without it,
|
|
333
|
+
// waiting for a machine to finish booting asks the operator whether
|
|
334
|
+
// destructive updates may be performed.
|
|
335
|
+
//
|
|
336
|
+
// Idempotent: asking again gives the same answer, and a host gating
|
|
337
|
+
// retry-of-a-timed-out-call on this flag should not refuse.
|
|
338
|
+
annotations: { destructiveHint: false, idempotentHint: true },
|
|
293
339
|
}, ({ computer_id }, extra) => guarded(async () => {
|
|
294
340
|
const selectionVersion = session.beginSelection(computer_id);
|
|
295
341
|
try {
|
|
@@ -306,10 +352,48 @@ export const registerComputers = (server, session, opts) => {
|
|
|
306
352
|
if (!session.bindIfCurrent(c.id ?? computer_id, c.resolution, selectionVersion)) {
|
|
307
353
|
return refused(`${c.id ?? computer_id} was deleted while it was being selected. The session selection was not changed.`);
|
|
308
354
|
}
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
355
|
+
// The advice has to know what the two waits know, or it sends a model
|
|
356
|
+
// to start a computer that is already starting — the whole of
|
|
357
|
+
// OPL-4631, in the one place a model acts on the sentence soonest.
|
|
358
|
+
//
|
|
359
|
+
// STATUS FIRST, THEN THE POOL, and that order is the correction: the
|
|
360
|
+
// first cut asked only about the pool, so it told a caller to start a
|
|
361
|
+
// build-failed computer, a half-removed one and a build still running
|
|
362
|
+
// — three machines the platform refuses to start at all. A
|
|
363
|
+
// reservation is only ever the difference between "start it" and
|
|
364
|
+
// "wait for it" on a machine that COULD be started.
|
|
365
|
+
const advice = () => {
|
|
366
|
+
const status = c.status ?? 'not running';
|
|
367
|
+
if (status === 'running')
|
|
368
|
+
return '';
|
|
369
|
+
// Terminal, and each with the remedy that actually applies.
|
|
370
|
+
if (status === 'build-failed') {
|
|
371
|
+
return `\n\nIts disk was never finished — delete_computer and build it again. Nothing else clears this.`;
|
|
372
|
+
}
|
|
373
|
+
if (status === 'half-removed') {
|
|
374
|
+
return `\n\nIts files were partly removed: it cannot be started or used again. delete_computer is what clears it.`;
|
|
375
|
+
}
|
|
376
|
+
// A disk copy in progress cannot be started either, and a memory
|
|
377
|
+
// fork resumes itself at the end of one — so both wait, and only
|
|
378
|
+
// the reason differs.
|
|
379
|
+
if (status === 'building') {
|
|
380
|
+
return c.running_ram_mb
|
|
381
|
+
? `\n\nIt is still building, and its RAM is already reserved — it will come up on its own when the copy finishes. wait_for_computer.`
|
|
382
|
+
: `\n\nIt is still building — wait_for_computer, then start_computer once the copy has finished.`;
|
|
383
|
+
}
|
|
384
|
+
// Stopped or suspended: the one place a start is the right advice,
|
|
385
|
+
// and only when the platform says it is holding nothing.
|
|
386
|
+
if (nothingAdmitted(c)) {
|
|
387
|
+
return `\n\nIt is ${status} — start_computer before driving it.`;
|
|
388
|
+
}
|
|
389
|
+
if (c.running_ram_mb !== undefined) {
|
|
390
|
+
return `\n\nIt is ${status}, and its start has already been admitted — wait_for_computer, not start_computer.`;
|
|
391
|
+
}
|
|
392
|
+
// The pool was not reported, so neither answer is established. Say
|
|
393
|
+
// that, rather than prescribing one of them as though it were.
|
|
394
|
+
return `\n\nIt is ${status}, and this host did not say whether a start is under way — wait_for_computer, which starts nothing, says which it is.`;
|
|
395
|
+
};
|
|
396
|
+
return said(`Selected ${describe(c)}. Later calls need no computer_id.${advice()}`, withoutCredentials(c));
|
|
313
397
|
}
|
|
314
398
|
finally {
|
|
315
399
|
session.endSelection(computer_id);
|
|
@@ -325,9 +409,23 @@ export const registerComputers = (server, session, opts) => {
|
|
|
325
409
|
// parameter their route does not read.
|
|
326
410
|
const power = (action, computer_id, extra, opts = {}) => guarded(async () => {
|
|
327
411
|
const id = session.resolve(computer_id);
|
|
328
|
-
const
|
|
412
|
+
const body = await session.api
|
|
329
413
|
.with(extra.signal)
|
|
330
|
-
.json('POST', P.computerAction(id, action), { query: opts.query })
|
|
414
|
+
.json('POST', P.computerAction(id, action), { query: opts.query });
|
|
415
|
+
const c = unwrapComputer(body);
|
|
416
|
+
// What the platform documents for all four is an Ack — `{ok: true}` and
|
|
417
|
+
// nothing else — and that is what it sends. Formatting it as a computer
|
|
418
|
+
// record answered `suspend: (unnamed) · (no id) · unknown`, which the
|
|
419
|
+
// first agent to dogfood the skill (OPL-3914) read as a suspend that had
|
|
420
|
+
// not worked. The id is the one we sent, so say that; a record, should
|
|
421
|
+
// the platform ever start returning one, is described as before.
|
|
422
|
+
//
|
|
423
|
+
// Stripped of credentials on this branch too, and not only on the record
|
|
424
|
+
// one: the test for "is this an Ack" is the absence of an id, and a body
|
|
425
|
+
// with no id is not thereby a body with no `vnc`.
|
|
426
|
+
if (!c.id) {
|
|
427
|
+
return said(`${action}: ok — ${id}.${POWER_NEXT[action] ?? ''}${opts.note ?? ''}`, withoutCredentials(c));
|
|
428
|
+
}
|
|
331
429
|
session.noteResolution(id, c.resolution);
|
|
332
430
|
return said(`${action}: ${describe(c)}${opts.note ?? ''}`, withoutCredentials(c));
|
|
333
431
|
});
|
|
@@ -412,7 +510,7 @@ export const registerComputers = (server, session, opts) => {
|
|
|
412
510
|
}));
|
|
413
511
|
server.registerTool('move_computer', {
|
|
414
512
|
title: 'Move a computer to a host that can run a bigger size',
|
|
415
|
-
description: 'Grow a computer past what its current host can run, by moving it to another host in the same region first. Only call this after update_computer has refused a resize and said a move is possible: it is the second half of that refusal and nothing else. THIS MOVES THE MACHINE TO DIFFERENT HARDWARE and copies its disk to get there — say so before you call it. The computer must be STOPPED (suspended is not stopped here: a saved desktop only loads on the host that wrote it, so resume and stop it, or discard the session). One move runs per account at a time. Everything is decided again when this runs, so it can still refuse. Waits for the outcome and reports it; list_moves reads
|
|
513
|
+
description: 'Grow a computer past what its current host can run, by moving it to another host in the same region first. Only call this after update_computer has refused a resize and said a move is possible: it is the second half of that refusal and nothing else. THIS MOVES THE MACHINE TO DIFFERENT HARDWARE and copies its disk to get there — say so before you call it. The computer must be STOPPED (suspended is not stopped here: a saved desktop only loads on the host that wrote it, so resume and stop it, or discard the session). One move runs per account at a time. Everything is decided again when this runs, so it can still refuse. Waits for the outcome and reports it, reporting progress while it waits so a client that sends a progressToken and sets resetTimeoutOnProgress can hold the request open; list_moves reads the outcome if the wait runs out, and is the answer for a client that cannot.',
|
|
416
514
|
inputSchema: {
|
|
417
515
|
...idArg,
|
|
418
516
|
// Required, unlike every other field here, and unlike the same argument
|
|
@@ -466,6 +564,11 @@ export const registerComputers = (server, session, opts) => {
|
|
|
466
564
|
// and it is kept because it is the only description of this move that
|
|
467
565
|
// does not depend on a later read succeeding.
|
|
468
566
|
const started = (await api.json('POST', P.computerAction(id, 'move'), { body }));
|
|
567
|
+
// The keepalive. A disk crossing between two hosts is minutes, and a
|
|
568
|
+
// tool that says nothing for minutes is one a client cancels — see
|
|
569
|
+
// heartbeat, and OPL-4579 for the wait that proved it.
|
|
570
|
+
const beat = heartbeat(extra, server.server);
|
|
571
|
+
await beat(`Moving ${id} — the platform has accepted it.`);
|
|
469
572
|
let last = started;
|
|
470
573
|
let blocked;
|
|
471
574
|
while (!untilDeadline.aborted) {
|
|
@@ -487,6 +590,7 @@ export const registerComputers = (server, session, opts) => {
|
|
|
487
590
|
if (untilDeadline.aborted)
|
|
488
591
|
break;
|
|
489
592
|
blocked = err.message;
|
|
593
|
+
await beat(`Moving ${id} — the platform could not be asked: ${blocked}`);
|
|
490
594
|
await sleep(POLL_MS, signal);
|
|
491
595
|
continue;
|
|
492
596
|
}
|
|
@@ -500,6 +604,7 @@ export const registerComputers = (server, session, opts) => {
|
|
|
500
604
|
`was the poll failing, not the move. list_moves says where it got to.`, last);
|
|
501
605
|
}
|
|
502
606
|
blocked = err instanceof Error ? err.message : String(err);
|
|
607
|
+
await beat(`Moving ${id} — the platform could not be asked: ${blocked}`);
|
|
503
608
|
await sleep(pollDelay(err), signal);
|
|
504
609
|
continue;
|
|
505
610
|
}
|
|
@@ -509,6 +614,7 @@ export const registerComputers = (server, session, opts) => {
|
|
|
509
614
|
// not be asked rather than claiming a deletion nothing established.
|
|
510
615
|
if (!table) {
|
|
511
616
|
blocked = `GET /moves answered with ${shapeOf(raw?.moves)}, not a list of moves`;
|
|
617
|
+
await beat(`Moving ${id} — the platform could not be asked: ${blocked}`);
|
|
512
618
|
await sleep(POLL_MS, signal);
|
|
513
619
|
continue;
|
|
514
620
|
}
|
|
@@ -526,6 +632,7 @@ export const registerComputers = (server, session, opts) => {
|
|
|
526
632
|
// instead of claiming a deletion nothing showed.
|
|
527
633
|
if (!mine && table.dropped) {
|
|
528
634
|
blocked = `GET /moves answered with ${table.dropped} unreadable row(s), so this move may be among them`;
|
|
635
|
+
await beat(`Moving ${id} — the platform could not be asked: ${blocked}`);
|
|
529
636
|
await sleep(POLL_MS, signal);
|
|
530
637
|
continue;
|
|
531
638
|
}
|
|
@@ -536,6 +643,7 @@ export const registerComputers = (server, session, opts) => {
|
|
|
536
643
|
last = mine;
|
|
537
644
|
if (!mine.live)
|
|
538
645
|
return finishedMove(id, mine);
|
|
646
|
+
await beat(`Moving ${id} — ${mine.state}${mine.detail ? `: ${mine.detail}` : ''}`);
|
|
539
647
|
await sleep(POLL_MS, signal);
|
|
540
648
|
}
|
|
541
649
|
return refused(blocked
|
|
@@ -601,7 +709,7 @@ export const registerComputers = (server, session, opts) => {
|
|
|
601
709
|
}));
|
|
602
710
|
server.registerTool('wait_for_computer', {
|
|
603
711
|
title: 'Wait for a computer to be ready',
|
|
604
|
-
description: 'Poll until the computer is running, or until the software inside it answers. Use "guest" before exec, files or windows, and before expecting a screenshot to show a desktop rather than a boot screen.',
|
|
712
|
+
description: 'Poll until the computer is running, or until the software inside it answers. Use "guest" before exec, files or windows, and before expecting a screenshot to show a desktop rather than a boot screen. Reports progress while it waits, so a client that sends a progressToken and sets resetTimeoutOnProgress can hold the request open; a client that cannot should lower timeout_s and call again rather than watch its own default timeout cancel the wait.',
|
|
605
713
|
inputSchema: {
|
|
606
714
|
...idArg,
|
|
607
715
|
until: z
|
|
@@ -610,6 +718,15 @@ export const registerComputers = (server, session, opts) => {
|
|
|
610
718
|
.describe('"running" is the hypervisor reporting the VM up. "guest" is the software inside it answering, which is what exec and a painted desktop actually need.'),
|
|
611
719
|
timeout_s: z.number().int().min(5).max(900).default(180),
|
|
612
720
|
},
|
|
721
|
+
// No readOnlyHint — this is not a pure read, so the hint would overclaim —
|
|
722
|
+
// but destructiveHint set, because the spec defaults it to TRUE once
|
|
723
|
+
// readOnlyHint is absent and this destroys nothing (OPL-4516). Without it,
|
|
724
|
+
// waiting for a machine to finish booting asks the operator whether
|
|
725
|
+
// destructive updates may be performed.
|
|
726
|
+
//
|
|
727
|
+
// Idempotent: asking again gives the same answer, and a host gating
|
|
728
|
+
// retry-of-a-timed-out-call on this flag should not refuse.
|
|
729
|
+
annotations: { destructiveHint: false, idempotentHint: true },
|
|
613
730
|
}, ({ computer_id, until, timeout_s }, extra) => guarded(async () => {
|
|
614
731
|
const id = session.resolve(computer_id);
|
|
615
732
|
// timeout_s used to gate only the top of the loop, which bounds how
|
|
@@ -627,6 +744,7 @@ export const registerComputers = (server, session, opts) => {
|
|
|
627
744
|
? AbortSignal.any([extra.signal, untilDeadline])
|
|
628
745
|
: untilDeadline;
|
|
629
746
|
const api = session.api.with(signal);
|
|
747
|
+
const beat = heartbeat(extra, server.server);
|
|
630
748
|
let last = 'unknown';
|
|
631
749
|
// Kept so the give-up message can name it. A hypervisor that was
|
|
632
750
|
// unreachable for the whole window is the single most useful thing to
|
|
@@ -665,18 +783,37 @@ export const registerComputers = (server, session, opts) => {
|
|
|
665
783
|
break;
|
|
666
784
|
}
|
|
667
785
|
blocked = err.message;
|
|
786
|
+
await beat(`Waiting for ${id} — the platform could not be asked: ${blocked}`);
|
|
668
787
|
await sleep(POLL_MS, signal);
|
|
669
788
|
continue;
|
|
670
789
|
}
|
|
671
790
|
if (!isTransientForPoll(err))
|
|
672
791
|
throw err;
|
|
673
792
|
blocked = err instanceof Error ? err.message : String(err);
|
|
793
|
+
await beat(`Waiting for ${id} — the platform could not be asked: ${blocked}`);
|
|
674
794
|
await sleep(pollDelay(err), signal);
|
|
675
795
|
continue;
|
|
676
796
|
}
|
|
677
797
|
blocked = undefined;
|
|
678
798
|
session.noteResolution(id, c.resolution);
|
|
679
799
|
last = c.status ?? 'unknown';
|
|
800
|
+
// ONE beat per turn, and this is not it when a guest probe is about to
|
|
801
|
+
// run. Beating here and again in the probe's failure branch sent two
|
|
802
|
+
// notifications per poll — and because the two lines DIFFER, each read
|
|
803
|
+
// as news to the throttle and neither was ever held, so the steady
|
|
804
|
+
// state of the commonest long wait was twice the per-poll rate the
|
|
805
|
+
// interval exists to avoid (/code-review).
|
|
806
|
+
//
|
|
807
|
+
// Fixed by making the two say the SAME thing rather than by silencing
|
|
808
|
+
// this one, which was the first attempt and dropped the wrong half of
|
|
809
|
+
// the pair (/code-review again). The probe below is the longest call
|
|
810
|
+
// in the loop — an undici header timeout or a proxy 524 can hold it
|
|
811
|
+
// for minutes — so the beat that must survive is the one IN FRONT of
|
|
812
|
+
// it. The 409 branch repeats this line and the throttle holds it; a
|
|
813
|
+
// probe that fails some other way says so, which is news and goes out.
|
|
814
|
+
await beat(until === 'guest' && last === 'running'
|
|
815
|
+
? `Waiting for ${id} — running; asking the guest.`
|
|
816
|
+
: `Waiting for ${id} — ${last}.`);
|
|
680
817
|
if (last === 'build-failed') {
|
|
681
818
|
// `refused`, for the reason `cancelled` is: the wait never reached
|
|
682
819
|
// what it was told to wait for, and this one never will. A caller
|
|
@@ -695,12 +832,32 @@ export const registerComputers = (server, session, opts) => {
|
|
|
695
832
|
: ' — the platform gave no reason';
|
|
696
833
|
return refused(`Build failed${why}. This does not resolve on its own.`, withoutCredentials(c));
|
|
697
834
|
}
|
|
835
|
+
// Its files were partly removed and its disk is gone: the platform
|
|
836
|
+
// refuses to start or use it, and only deleting it again clears it.
|
|
837
|
+
// Waiting spent the whole budget and then reported "last seen
|
|
838
|
+
// half-removed", which is the state the caller passed in (Codex
|
|
839
|
+
// review). Not qualified by the pool: nothing can be admitted for a
|
|
840
|
+
// machine with no disk.
|
|
841
|
+
if (last === 'half-removed') {
|
|
842
|
+
return refused(`${id} is half-removed: its files were partly removed, it cannot be started or used again, and delete_computer is what clears it.`, withoutCredentials(c));
|
|
843
|
+
}
|
|
698
844
|
// Neither of the next two resolves on its own, so spinning on either
|
|
699
845
|
// burns the whole timeout waiting for something nobody is going to do.
|
|
700
|
-
|
|
846
|
+
//
|
|
847
|
+
// Unless somebody is. `status` is read from the guest process, so a
|
|
848
|
+
// start that has been ADMITTED reads as `stopped` while it boots and
|
|
849
|
+
// as `suspended` while it resumes — the session record is spent only
|
|
850
|
+
// on the way out of a start that worked. Refusing there tells a model
|
|
851
|
+
// to call start_computer on a computer that is already starting, and
|
|
852
|
+
// the obvious next thing it does is start it a second time.
|
|
853
|
+
//
|
|
854
|
+
// nothingAdmitted is the platform's own word for idle, and it is
|
|
855
|
+
// absent-aware: a host that did not answer has not said nothing is
|
|
856
|
+
// coming, so the wait goes on rather than refusing (OPL-4631).
|
|
857
|
+
if (last === 'suspended' && nothingAdmitted(c)) {
|
|
701
858
|
return refused(`${id} is suspended, and that state does not clear by itself. start_computer resumes the saved session in about a second.`, withoutCredentials(c));
|
|
702
859
|
}
|
|
703
|
-
if (last === 'stopped') {
|
|
860
|
+
if (last === 'stopped' && nothingAdmitted(c)) {
|
|
704
861
|
return refused(`${id} is stopped. start_computer boots it.`, withoutCredentials(c));
|
|
705
862
|
}
|
|
706
863
|
if (last === 'running') {
|
|
@@ -730,11 +887,34 @@ export const registerComputers = (server, session, opts) => {
|
|
|
730
887
|
break;
|
|
731
888
|
}
|
|
732
889
|
blocked = err.message;
|
|
890
|
+
await beat(`Waiting for ${id} — running; the platform could not be asked: ${blocked}`);
|
|
733
891
|
await sleep(POLL_MS, signal);
|
|
734
892
|
continue;
|
|
735
893
|
}
|
|
736
894
|
if (!isTransientForPoll(err))
|
|
737
895
|
throw err;
|
|
896
|
+
// What actually refused, rather than one sentence for every
|
|
897
|
+
// failure. A 409 IS the guest not being up yet — that is the
|
|
898
|
+
// probe working, and it repeats the line beat in front of the
|
|
899
|
+
// probe so the throttle holds it. A 503 is a hypervisor nobody
|
|
900
|
+
// can reach and a transport abort is neither: saying "the guest
|
|
901
|
+
// is not answering" over those tells the person watching the log
|
|
902
|
+
// the one thing this channel exists to get right.
|
|
903
|
+
//
|
|
904
|
+
// And it SETS `blocked`, which it never did — not before this
|
|
905
|
+
// change and not after the first version of it. A wait that spent
|
|
906
|
+
// its whole window failing the probe on 503s gave up saying "was
|
|
907
|
+
// last seen running" and named nothing, while the progress
|
|
908
|
+
// channel had been reporting the hypervisor the entire time: the
|
|
909
|
+
// same contradiction this comment set out to remove, pointed the
|
|
910
|
+
// other way (/code-review). Deliberately not for the 409, where
|
|
911
|
+
// the platform did answer and `blocked` would be a lie about it.
|
|
912
|
+
if (!(err instanceof ConflictError)) {
|
|
913
|
+
blocked = err instanceof Error ? err.message : String(err);
|
|
914
|
+
}
|
|
915
|
+
await beat(err instanceof ConflictError
|
|
916
|
+
? `Waiting for ${id} — running; asking the guest.`
|
|
917
|
+
: `Waiting for ${id} — running; the platform could not be asked: ${blocked}`);
|
|
738
918
|
// The guest probe's own failure decides this turn's interval, for
|
|
739
919
|
// pollDelay's reason. The ordinary path below keeps POLL_MS.
|
|
740
920
|
await sleep(pollDelay(err), signal);
|
|
@@ -761,7 +941,41 @@ export const registerComputers = (server, session, opts) => {
|
|
|
761
941
|
.default(false)
|
|
762
942
|
.describe('Return the full-control URL instead of the watch-only one. It carries a token that is root-equivalent on that machine — the watch-only socket has input dropped by the platform, not merely hidden by the client.'),
|
|
763
943
|
},
|
|
764
|
-
|
|
944
|
+
// NO readOnlyHint, for a reason distinct from both the ones already in
|
|
945
|
+
// this codebase: the SPEND rule (OPL-4499 — cursor_position and read_file
|
|
946
|
+
// resume a suspended computer) and the CONSUMING-READ rule (exec_poll,
|
|
947
|
+
// poll_events and wait_for_event advance a cursor no later call can
|
|
948
|
+
// re-read, and wait_for_file_change also configures). This is a fourth,
|
|
949
|
+
// decided in OPL-4505 and written here so it is not re-litigated.
|
|
950
|
+
//
|
|
951
|
+
// `GET /computers/:id` modifies nothing and spends nothing, so under the
|
|
952
|
+
// rule as first written the hint was accurate. What the rule was missing
|
|
953
|
+
// is that the annotation does not only describe the route: clients treat
|
|
954
|
+
// it as licence to call without asking, which is the reading input.ts
|
|
955
|
+
// already states beside `cursor_position`. This call hands back a
|
|
956
|
+
// credential — with `control: true`, one that is root-equivalent on the
|
|
957
|
+
// machine — so under a host that auto-approves read-only tools, a model
|
|
958
|
+
// could pass out control of a desktop with nobody asked. The description
|
|
959
|
+
// says "these are credentials in a link", but a description is not a gate.
|
|
960
|
+
//
|
|
961
|
+
// So, added to the list: the hint is withheld from a tool that returns a
|
|
962
|
+
// CREDENTIAL-EQUIVALENT. This is the only one today; the next tool that
|
|
963
|
+
// hands back a token, a signed URL or a key belongs here with it rather
|
|
964
|
+
// than in an argument about whether a read is a read.
|
|
965
|
+
//
|
|
966
|
+
// Credential-equivalent, and deliberately not "sensitive". A screenshot
|
|
967
|
+
// of a desktop with a password on it is sensitive, and it is not
|
|
968
|
+
// something this call revealed the keys to — a rule drawn that wide would
|
|
969
|
+
// take the hint off every read there is, which is why `screenshot`,
|
|
970
|
+
// `read_clipboard` and `list_windows` keep theirs.
|
|
971
|
+
//
|
|
972
|
+
// The other two flags are set rather than left to default, for the reason
|
|
973
|
+
// `cursor_position` sets them: the spec defaults destructiveHint to TRUE
|
|
974
|
+
// and idempotentHint to FALSE once readOnlyHint is gone, so leaving them
|
|
975
|
+
// out would have a host asking whether it may perform destructive updates
|
|
976
|
+
// in order to read a URL, and refusing to retry a call that is safe to
|
|
977
|
+
// retry.
|
|
978
|
+
annotations: { destructiveHint: false, idempotentHint: true },
|
|
765
979
|
}, ({ computer_id, control }, extra) => guarded(async () => {
|
|
766
980
|
const id = session.resolve(computer_id);
|
|
767
981
|
const c = unwrapComputer(await session.api.with(extra.signal).json('GET', P.computer(id)));
|
|
@@ -874,7 +1088,7 @@ export const registerComputers = (server, session, opts) => {
|
|
|
874
1088
|
return;
|
|
875
1089
|
server.registerTool('create_computer', {
|
|
876
1090
|
title: 'Create a computer',
|
|
877
|
-
description: 'Build a new cloud desktop and select it for this session. Creating and running a computer costs money on this account.',
|
|
1091
|
+
description: 'Build a new cloud desktop and select it for this session. Creating and running a computer costs money on this account. Continue an image preparation refusal only as its result instructs, retaining all original create arguments, including template, and adding the returned preparation token as an argument. This token is not a create idempotency key. Stop after success; never automatically replay after a lost or ambiguous response.',
|
|
878
1092
|
inputSchema: {
|
|
879
1093
|
name: z.string().optional().describe('A label. The platform picks one if you do not.'),
|
|
880
1094
|
size: z
|
|
@@ -885,6 +1099,11 @@ export const registerComputers = (server, session, opts) => {
|
|
|
885
1099
|
.string()
|
|
886
1100
|
.optional()
|
|
887
1101
|
.describe('From list_templates, e.g. "base" for Linux/Xfce. Defaults to the platform default.'),
|
|
1102
|
+
template_transfer: z
|
|
1103
|
+
.string()
|
|
1104
|
+
.refine((value) => value.trim().length > 0, 'template_transfer must not be blank')
|
|
1105
|
+
.optional()
|
|
1106
|
+
.describe('Nonblank opaque token from an image preparation refusal; preserve it exactly. When the result permits continuation, keep all original create arguments including template, add this token, and wait for the supplied delay. Requires template and cannot be combined with size. Not a create idempotency key: stop after success and never automatically replay after a lost or ambiguous response.'),
|
|
888
1107
|
cpu: z.number().int().min(1).optional(),
|
|
889
1108
|
ram_mb: z.number().int().min(512).optional(),
|
|
890
1109
|
disk_gb: z
|
|
@@ -901,9 +1120,48 @@ export const registerComputers = (server, session, opts) => {
|
|
|
901
1120
|
},
|
|
902
1121
|
annotations: { destructiveHint: false, openWorldHint: true },
|
|
903
1122
|
}, (args, extra) => guarded(async () => {
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
1123
|
+
let data;
|
|
1124
|
+
try {
|
|
1125
|
+
data = await session.api
|
|
1126
|
+
.with(extra.signal)
|
|
1127
|
+
.json('POST', P.COMPUTERS, { body: P.createBody(args) });
|
|
1128
|
+
}
|
|
1129
|
+
catch (error) {
|
|
1130
|
+
const body = error instanceof ConflictError
|
|
1131
|
+
? error.body
|
|
1132
|
+
: undefined;
|
|
1133
|
+
if (body?.code === 'template_image_preparing' && error instanceof ConflictError) {
|
|
1134
|
+
const preparation = body.preparation;
|
|
1135
|
+
const state = preparation && typeof preparation === 'object'
|
|
1136
|
+
? preparation.state
|
|
1137
|
+
: undefined;
|
|
1138
|
+
const tokenPresent = typeof body.template_transfer === 'string' &&
|
|
1139
|
+
body.template_transfer.trim().length > 0;
|
|
1140
|
+
const originalSupportsContinuation = typeof args.template === 'string' &&
|
|
1141
|
+
args.template.trim().length > 0 &&
|
|
1142
|
+
args.size === undefined;
|
|
1143
|
+
const canContinue = originalSupportsContinuation &&
|
|
1144
|
+
tokenPresent &&
|
|
1145
|
+
typeof state === 'string' &&
|
|
1146
|
+
['preparing', 'copying', 'ready'].includes(state) &&
|
|
1147
|
+
error.retryAfterMs !== undefined;
|
|
1148
|
+
const advice = state === 'failed'
|
|
1149
|
+
? 'Image preparation failed. Inspect preparation.error before deciding what to do next; do not automatically wait or retry.'
|
|
1150
|
+
: canContinue
|
|
1151
|
+
? `Wait ${error.retryAfterMs} milliseconds, then repeat create_computer with all original identical arguments, including template, and this template_transfer token. Stop after success.`
|
|
1152
|
+
: !originalSupportsContinuation
|
|
1153
|
+
? 'The original create must include a nonblank template and omit size to continue with this token. Inspect the original request and preparation details; do not automatically wait or retry.'
|
|
1154
|
+
: 'The response does not supply a known continuation state, usable token, and valid delay. Inspect the preparation details; do not automatically wait or retry.';
|
|
1155
|
+
return refused(`${typeof body.error === 'string' ? body.error : 'The template image is not available for this create.'} No computer has been created. ${advice} The token is not a create idempotency key. Never automatically replay after a lost or ambiguous response.`, {
|
|
1156
|
+
code: body.code,
|
|
1157
|
+
template_transfer: body.template_transfer,
|
|
1158
|
+
preparation,
|
|
1159
|
+
retry_after_ms: error.retryAfterMs,
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1162
|
+
throw error;
|
|
1163
|
+
}
|
|
1164
|
+
const c = unwrapComputer(data);
|
|
907
1165
|
// Selection and the sentence claiming it are the same decision. Bound
|
|
908
1166
|
// conditionally and reported unconditionally, a create that came back
|
|
909
1167
|
// without an id left this session pointing at whatever it held before
|
|
@@ -920,7 +1178,9 @@ export const registerComputers = (server, session, opts) => {
|
|
|
920
1178
|
const note = c.start_error
|
|
921
1179
|
? `Created ${describe(c)}, but it did not start: ${c.start_error}\nThe computer exists and is selected. start_computer often works on a second attempt.`
|
|
922
1180
|
: `Created and selected ${describe(c)}.`;
|
|
923
|
-
return said(
|
|
1181
|
+
return said(args.template_transfer === undefined
|
|
1182
|
+
? note
|
|
1183
|
+
: `${note} Stop retrying this create; the template_transfer token is not a create idempotency key.`, withoutCredentials(c));
|
|
924
1184
|
}));
|
|
925
1185
|
server.registerTool('clone_computer', {
|
|
926
1186
|
title: 'Clone a computer',
|
|
@@ -968,7 +1228,7 @@ export const registerComputers = (server, session, opts) => {
|
|
|
968
1228
|
// Not fetched on the caller's behalf, which was the tempting shortcut
|
|
969
1229
|
// and is the wrong one. A fingerprint read a millisecond before the
|
|
970
1230
|
// delete binds the purge to whatever the set is now, not to what anyone
|
|
971
|
-
// agreed to — and the race
|
|
1231
|
+
// agreed to — and the race the expectation exists for is exactly that:
|
|
972
1232
|
// a capture that finishes between the decision and the click, then gets
|
|
973
1233
|
// destroyed by a confirmation that predates it.
|
|
974
1234
|
const fingerprint = expect?.trim() || undefined;
|
|
@@ -1000,8 +1260,32 @@ export const registerComputers = (server, session, opts) => {
|
|
|
1000
1260
|
// Unbound BEFORE the rethrow-or-report decision, because it is true
|
|
1001
1261
|
// either way: whatever this answers, the caller must not be left
|
|
1002
1262
|
// selected on a computer the platform says is gone.
|
|
1003
|
-
if (!(err instanceof NotFoundError))
|
|
1263
|
+
if (!(err instanceof NotFoundError)) {
|
|
1264
|
+
// The VM is destroyed before snapshots are purged, so a failed
|
|
1265
|
+
// DELETE does not establish whether it survived. Ask once, with
|
|
1266
|
+
// the same account/workspace scope, and preserve the primary error
|
|
1267
|
+
// even if this read fails. Neither error prose nor a lost or
|
|
1268
|
+
// unreachable record proves deletion. The short deadline includes
|
|
1269
|
+
// reading the body; reconciliation must not hold the error hostage
|
|
1270
|
+
// to the transport's much longer foreground-exec allowance.
|
|
1271
|
+
if (!extra.signal.aborted) {
|
|
1272
|
+
const signal = AbortSignal.any([extra.signal, AbortSignal.timeout(5_000)]);
|
|
1273
|
+
let absent = false;
|
|
1274
|
+
try {
|
|
1275
|
+
const c = unwrapComputer(await session.api.with(signal).json('GET', P.computer(computer_id)));
|
|
1276
|
+
absent =
|
|
1277
|
+
c.id === computer_id.trim() && c.state === 'deleted' && c.unreachable !== true;
|
|
1278
|
+
}
|
|
1279
|
+
catch (readError) {
|
|
1280
|
+
absent = readError instanceof NotFoundError;
|
|
1281
|
+
}
|
|
1282
|
+
// unbind also drops events and invalidates pending selections
|
|
1283
|
+
// of this id, while preserving a different selected computer.
|
|
1284
|
+
if (absent && !signal.aborted)
|
|
1285
|
+
session.unbind(computer_id);
|
|
1286
|
+
}
|
|
1004
1287
|
throw err;
|
|
1288
|
+
}
|
|
1005
1289
|
session.unbind(computer_id);
|
|
1006
1290
|
// Reported as a success rather than an error, and deliberately not as
|
|
1007
1291
|
// a plain "Deleted": a caller retrying cannot be told its snapshots
|