mandala-computer-mcp 0.1.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/LICENSE +21 -0
- package/README.md +544 -0
- package/dist/api.d.ts +186 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +932 -0
- package/dist/api.js.map +1 -0
- package/dist/cli.d.ts +55 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +292 -0
- package/dist/cli.js.map +1 -0
- package/dist/errors.d.ts +560 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +873 -0
- package/dist/errors.js.map +1 -0
- package/dist/events.d.ts +406 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +1679 -0
- package/dist/events.js.map +1 -0
- package/dist/format.d.ts +125 -0
- package/dist/format.d.ts.map +1 -0
- package/dist/format.js +180 -0
- package/dist/format.js.map +1 -0
- package/dist/http.d.ts +46 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +792 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/paths.d.ts +394 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +677 -0
- package/dist/paths.js.map +1 -0
- package/dist/server.d.ts +18 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +97 -0
- package/dist/server.js.map +1 -0
- package/dist/session.d.ts +78 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +166 -0
- package/dist/session.js.map +1 -0
- package/dist/stdio.d.ts +11 -0
- package/dist/stdio.d.ts.map +1 -0
- package/dist/stdio.js +43 -0
- package/dist/stdio.js.map +1 -0
- package/dist/tools/agent.d.ts +16 -0
- package/dist/tools/agent.d.ts.map +1 -0
- package/dist/tools/agent.js +147 -0
- package/dist/tools/agent.js.map +1 -0
- package/dist/tools/computers.d.ts +3 -0
- package/dist/tools/computers.d.ts.map +1 -0
- package/dist/tools/computers.js +1037 -0
- package/dist/tools/computers.js.map +1 -0
- package/dist/tools/events.d.ts +3 -0
- package/dist/tools/events.d.ts.map +1 -0
- package/dist/tools/events.js +1077 -0
- package/dist/tools/events.js.map +1 -0
- package/dist/tools/guest.d.ts +3 -0
- package/dist/tools/guest.d.ts.map +1 -0
- package/dist/tools/guest.js +761 -0
- package/dist/tools/guest.js.map +1 -0
- package/dist/tools/input.d.ts +3 -0
- package/dist/tools/input.d.ts.map +1 -0
- package/dist/tools/input.js +240 -0
- package/dist/tools/input.js.map +1 -0
- package/dist/tools/snapshots.d.ts +3 -0
- package/dist/tools/snapshots.d.ts.map +1 -0
- package/dist/tools/snapshots.js +333 -0
- package/dist/tools/snapshots.js.map +1 -0
- package/dist/tools/templates.d.ts +3 -0
- package/dist/tools/templates.d.ts.map +1 -0
- package/dist/tools/templates.js +492 -0
- package/dist/tools/templates.js.map +1 -0
- package/dist/tools/types.d.ts +18 -0
- package/dist/tools/types.d.ts.map +1 -0
- package/dist/tools/types.js +2 -0
- package/dist/tools/types.js.map +1 -0
- package/dist/tools/webhooks.d.ts +3 -0
- package/dist/tools/webhooks.d.ts.map +1 -0
- package/dist/tools/webhooks.js +260 -0
- package/dist/tools/webhooks.js.map +1 -0
- package/package.json +59 -0
|
@@ -0,0 +1,1037 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { CancelledError, isTransientForPoll, MoveRequiredError, NotFoundError, RateLimitError, } from '../errors.js';
|
|
3
|
+
import { describe, guarded, incompleteWarning, json, refused, said, unwrapComputer, withoutCredentials, } from '../format.js';
|
|
4
|
+
import * as P from '../paths.js';
|
|
5
|
+
const idArg = {
|
|
6
|
+
computer_id: z
|
|
7
|
+
.string()
|
|
8
|
+
.optional()
|
|
9
|
+
.describe('Which computer. Defaults to the one selected with use_computer.'),
|
|
10
|
+
};
|
|
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
|
+
/**
|
|
46
|
+
* The answer to a wait the caller ended.
|
|
47
|
+
*
|
|
48
|
+
* `refused`, not `said`: the wait never reached what it was told to wait for,
|
|
49
|
+
* and a caller reading `isError` to decide whether the step worked would
|
|
50
|
+
* otherwise be unable to tell a cancellation from a computer that came up.
|
|
51
|
+
*/
|
|
52
|
+
const cancelled = (id, last) => refused(`Cancelled after waiting for ${id}; it was last seen ${last}. Nothing was changed.`);
|
|
53
|
+
/**
|
|
54
|
+
* The moves table, or `undefined` when the platform did not send one.
|
|
55
|
+
*
|
|
56
|
+
* An envelope without a `moves` array is not an account with no moves in it,
|
|
57
|
+
* and that difference is the whole reason this returns `undefined` rather than
|
|
58
|
+
* `[]`. Both callers below read emptiness as a fact about the account: one
|
|
59
|
+
* reports a quiet account, the other concludes the computer was deleted and
|
|
60
|
+
* stops watching a move that is still running. An unreadable body establishes
|
|
61
|
+
* neither.
|
|
62
|
+
*/
|
|
63
|
+
const movesOf = (body) => {
|
|
64
|
+
const list = body?.moves;
|
|
65
|
+
if (!Array.isArray(list))
|
|
66
|
+
return undefined;
|
|
67
|
+
// Rows shape-checked, as list_computers and list_snapshots check theirs. Both
|
|
68
|
+
// callers reach straight into a row — `m.computer_id`, and moveLine's field
|
|
69
|
+
// reads — so a `null` in the array threw a TypeError. In the poll that throw
|
|
70
|
+
// landed OUTSIDE the try/catch that exists to say "THE MOVE IS STILL
|
|
71
|
+
// RUNNING", replacing the one sentence that stops a caller concluding a
|
|
72
|
+
// multi-minute disk copy had failed.
|
|
73
|
+
//
|
|
74
|
+
// The COUNT comes back with them, and that is the half a bare filter would
|
|
75
|
+
// get wrong. Both callers read an empty result as a fact about the account —
|
|
76
|
+
// one reports a quiet account, the other concludes the computer was deleted
|
|
77
|
+
// and stops watching a move that is still running — and a row this function
|
|
78
|
+
// could not read establishes neither. Silently dropping it would trade a
|
|
79
|
+
// TypeError for a confident wrong answer, which is the worse of the two.
|
|
80
|
+
// An unreadable ENVELOPE is still `undefined`: a different fact, a different
|
|
81
|
+
// answer, and the one the callers already handle.
|
|
82
|
+
const moves = list.filter((row) => row !== null && typeof row === 'object' && !Array.isArray(row));
|
|
83
|
+
return { moves, dropped: list.length - moves.length };
|
|
84
|
+
};
|
|
85
|
+
/** What arrived where a list was expected, for a refusal that names it. */
|
|
86
|
+
const shapeOf = (v) => v === undefined ? 'no body at all' : v === null ? 'null' : typeof v;
|
|
87
|
+
/**
|
|
88
|
+
* The resize refusal that is an OFFER, turned into a next step (OPL-3775).
|
|
89
|
+
*
|
|
90
|
+
* The platform's own sentence first, because it is the one that says what will
|
|
91
|
+
* not fit and what moving costs — written for whoever has to agree to it. What
|
|
92
|
+
* this adds is the two things that sentence cannot know: that retrying is
|
|
93
|
+
* pointless, and the name of the tool that takes the offer up.
|
|
94
|
+
*
|
|
95
|
+
* Both halves matter. Without the first, a model reads 409, reads "worth
|
|
96
|
+
* retrying" in every other refusal it has met, and loops. Without the second it
|
|
97
|
+
* has been told a way out exists and has nothing to call — which is the whole
|
|
98
|
+
* defect this closes, and which is worse for a model than for a person, since a
|
|
99
|
+
* person can go and look at the dashboard.
|
|
100
|
+
*/
|
|
101
|
+
const moveOffered = (id, err) => refused(err.movePossible
|
|
102
|
+
? `${err.message}\n\nThis does not clear by itself: retrying the same resize gets the same answer for ` +
|
|
103
|
+
`as long as ${id} is on that host. move_computer applies exactly this size and moves the computer ` +
|
|
104
|
+
`to a host in the region that can run it. Say what that costs before you call it — the computer's ` +
|
|
105
|
+
`disk is copied to different hardware, and it has to be stopped first.`
|
|
106
|
+
: `${err.message}\n\nThis does not clear by itself, and there is nothing to move to: no host in this ` +
|
|
107
|
+
`region can run that size at all. Ask for less RAM.`);
|
|
108
|
+
/** The size a move is applying, for a line a person can read. */
|
|
109
|
+
const moveShape = (m) => [m.cpu && `${m.cpu} vCPU`, m.ram_mb && `${m.ram_mb} MB RAM`, m.disk_gb && `${m.disk_gb} GB disk`]
|
|
110
|
+
.filter(Boolean)
|
|
111
|
+
.join(' · ') || 'no change';
|
|
112
|
+
/** One row of list_moves. */
|
|
113
|
+
const moveLine = (m) => `${m.computer_id}: ${m.state}${m.live ? ' (running)' : ''} — ${moveShape(m)}${m.detail ? ` — ${m.detail}` : ''}`;
|
|
114
|
+
/**
|
|
115
|
+
* The sentence in front of a usage report, and the reason this tool does not
|
|
116
|
+
* simply hand back the JSON the way list_sizes does.
|
|
117
|
+
*
|
|
118
|
+
* Two of these fields are caveats on every number beside them, and a model
|
|
119
|
+
* reading a body top to bottom will act on `vcpu_hours` long before it reaches
|
|
120
|
+
* `degraded`. Every figure is a sum across the hypervisors the account's
|
|
121
|
+
* computers are on, so a host that did not contribute leaves a total that is
|
|
122
|
+
* quietly too small rather than an obviously missing row — the one failure mode
|
|
123
|
+
* a spend check must never present as fact. Saying it first is the difference
|
|
124
|
+
* between a caveat that is present and a caveat that is read.
|
|
125
|
+
*
|
|
126
|
+
* The two are kept apart because only one of them clears: `degraded` is a host
|
|
127
|
+
* that could not be reached and comes right when it comes back, `unmetered` is a
|
|
128
|
+
* host running a daemon older than the meter, and telling a caller to wait for
|
|
129
|
+
* that one is advice that never comes true.
|
|
130
|
+
*/
|
|
131
|
+
const usageLine = (u) => {
|
|
132
|
+
const t = u.usage ?? {};
|
|
133
|
+
const window = u.from && u.to ? `${u.from} to ${u.to}` : 'this billing period';
|
|
134
|
+
const head = `${t.vcpu_hours ?? 0} vCPU-hours, ${t.run_hours ?? 0} running hours and ` +
|
|
135
|
+
`${t.disk_gb_months ?? 0} GB-months of disk over ${window}.`;
|
|
136
|
+
const short = [
|
|
137
|
+
u.degraded && 'a hypervisor could not be reached (retry — this one clears)',
|
|
138
|
+
u.unmetered &&
|
|
139
|
+
'a hypervisor is running a daemon older than the meter (waiting will not fix it)',
|
|
140
|
+
].filter(Boolean);
|
|
141
|
+
if (!short.length)
|
|
142
|
+
return head;
|
|
143
|
+
return (`THESE TOTALS MAY BE TOO LOW: ${short.join(', and ')}. Every figure is a sum across the fleet, so ` +
|
|
144
|
+
`a host that did not answer leaves a short total rather than a missing row — do not reconcile ` +
|
|
145
|
+
`this against an invoice while it says so.\n\n${head}`);
|
|
146
|
+
};
|
|
147
|
+
/**
|
|
148
|
+
* A move that has stopped, read as the four different things it can be.
|
|
149
|
+
*
|
|
150
|
+
* The states are not four flavours of the same outcome and reading them that way
|
|
151
|
+
* is the mistake worth designing against, because the recovery differs and two
|
|
152
|
+
* of them are not failures at all:
|
|
153
|
+
*
|
|
154
|
+
* done the computer is on the new host at the new size. Success.
|
|
155
|
+
* moved the computer IS on another host, at its OLD size. The move landed
|
|
156
|
+
* and the resize did not. Not "the move failed" — saying so would send
|
|
157
|
+
* a caller looking for a machine that is no longer where it was — and
|
|
158
|
+
* recoverable with an ordinary update_computer, because it is now on a
|
|
159
|
+
* host that can run the size.
|
|
160
|
+
* failed nothing happened. The computer is where it was, untouched.
|
|
161
|
+
* lost we stopped watching. It may well have completed; go and look.
|
|
162
|
+
*
|
|
163
|
+
* `moved`, `failed` and `lost` are refusals so that a caller reading `isError`
|
|
164
|
+
* to decide whether its resize happened gets the right answer — and `moved`
|
|
165
|
+
* carries the loudest instruction of the three, because it is the one where the
|
|
166
|
+
* computer has genuinely changed and the caller might not notice.
|
|
167
|
+
*/
|
|
168
|
+
const finishedMove = (id, m) => {
|
|
169
|
+
const detail = m.detail ? ` ${m.detail}` : '';
|
|
170
|
+
if (m.state === 'done')
|
|
171
|
+
return said(`${id} moved and is now ${moveShape(m)}.`, m);
|
|
172
|
+
if (m.state === 'moved') {
|
|
173
|
+
return refused(`${id} MOVED to another host but was NOT resized — it is still at its old size.${detail} The move ` +
|
|
174
|
+
`itself is done and does not need repeating; it is now on a host that can run the size, so ` +
|
|
175
|
+
`update_computer resizes it where it is.`, m);
|
|
176
|
+
}
|
|
177
|
+
if (m.state === 'failed') {
|
|
178
|
+
return refused(`${id} was not moved and was not resized — it is where it was, untouched.${detail}`, m);
|
|
179
|
+
}
|
|
180
|
+
return refused(`The move of ${id} stopped being watched, so we cannot say whether it finished.${detail} Read ` +
|
|
181
|
+
`get_computer to see which size it is at now before doing anything else.`, m);
|
|
182
|
+
};
|
|
183
|
+
const POWER_DESCRIPTIONS = {
|
|
184
|
+
start: 'Boot a computer, or resume a suspended one — a resume restores the saved session, same processes and windows, in about a second.',
|
|
185
|
+
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.',
|
|
186
|
+
suspend: "Write the guest's RAM to disk and give the host its memory back. A pause, not a stop: start_computer resumes the same session.",
|
|
187
|
+
restart: 'Reset the computer. Refused while a session is suspended, since it would have to guess whether you meant to resume or discard it.',
|
|
188
|
+
};
|
|
189
|
+
export const registerComputers = (server, session, opts) => {
|
|
190
|
+
server.registerTool('list_templates', {
|
|
191
|
+
title: 'List templates',
|
|
192
|
+
description: 'The base images a computer can be created from — name, OS, and the default CPU, RAM and disk each one implies.',
|
|
193
|
+
inputSchema: {},
|
|
194
|
+
annotations: { readOnlyHint: true },
|
|
195
|
+
}, (_args, extra) => guarded(async () => json(await session.api.with(extra.signal).json('GET', P.TEMPLATES))));
|
|
196
|
+
server.registerTool('list_sizes', {
|
|
197
|
+
title: 'List sizes',
|
|
198
|
+
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. ` +
|
|
199
|
+
'`allowed` says whether this account’s plan admits a row; when false, `cheapest_plan` names the plan that would.',
|
|
200
|
+
inputSchema: {},
|
|
201
|
+
annotations: { readOnlyHint: true },
|
|
202
|
+
}, (_args, extra) => guarded(async () => json(await session.api.with(extra.signal).json('GET', P.SIZES))));
|
|
203
|
+
server.registerTool('list_computers', {
|
|
204
|
+
title: 'List computers',
|
|
205
|
+
description: 'Every computer on this account. Desktop credentials are deliberately not included — use get_desktop_url for those.',
|
|
206
|
+
inputSchema: {
|
|
207
|
+
allow_partial: z
|
|
208
|
+
.boolean()
|
|
209
|
+
.optional()
|
|
210
|
+
.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.'),
|
|
211
|
+
},
|
|
212
|
+
annotations: { readOnlyHint: true },
|
|
213
|
+
}, ({ allow_partial }, extra) => guarded(async () => {
|
|
214
|
+
// listing, not json: with allow_partial the platform will hand over an
|
|
215
|
+
// inventory it knows is short, and says so in X-GC-Incomplete. Reading
|
|
216
|
+
// the body and dropping the header turns "here is part of the fleet"
|
|
217
|
+
// into "here is the fleet".
|
|
218
|
+
const { items, incomplete } = await session.api
|
|
219
|
+
.with(extra.signal)
|
|
220
|
+
.listing(P.COMPUTERS, {
|
|
221
|
+
query: { allow_partial: allow_partial ? 1 : undefined },
|
|
222
|
+
});
|
|
223
|
+
// Checked rather than asserted. `listing<unknown[]>` is a claim about
|
|
224
|
+
// what the platform sends, not a guarantee — a proxy or a future
|
|
225
|
+
// paginated envelope answers with an object, and `.length` on that is
|
|
226
|
+
// `undefined`, which reads as an empty account: the duplicate-create the
|
|
227
|
+
// rest of this handler goes to some length to prevent, arrived at from
|
|
228
|
+
// the other side.
|
|
229
|
+
//
|
|
230
|
+
// An absent body is the same mistake in its quietest form. `listing`
|
|
231
|
+
// returns `undefined` for a 204 or a zero-length 200 — a gateway
|
|
232
|
+
// answering with nothing at all — and `items ?? []` would turn that
|
|
233
|
+
// silence into the very sentence below about an account with no
|
|
234
|
+
// computers in it. The platform sending no inventory is not the
|
|
235
|
+
// platform sending an empty one, and only one of the two is an
|
|
236
|
+
// invitation to create.
|
|
237
|
+
if (!Array.isArray(items)) {
|
|
238
|
+
const got = items === undefined ? 'no body at all' : items === null ? 'null' : typeof items;
|
|
239
|
+
return refused(`GET /computers answered with ${got}, not a list of computers. This is not an empty account — do not create a computer on the strength of it.`, items);
|
|
240
|
+
}
|
|
241
|
+
const malformed = items.filter((item) => item === null || typeof item !== 'object' || Array.isArray(item)).length;
|
|
242
|
+
const list = items.filter((item) => item !== null && typeof item === 'object' && !Array.isArray(item));
|
|
243
|
+
const warning = incompleteWarning('computers', incomplete) +
|
|
244
|
+
(malformed
|
|
245
|
+
? `WARNING: ignored ${malformed} malformed computer entr${malformed === 1 ? 'y' : 'ies'} from the platform.\n\n`
|
|
246
|
+
: '');
|
|
247
|
+
if (!list.length) {
|
|
248
|
+
if (malformed) {
|
|
249
|
+
return refused(`${warning}No valid computers remained. This is not an empty account — do not create a computer on the strength of a malformed listing.`, items);
|
|
250
|
+
}
|
|
251
|
+
// Two different empty answers, and telling them apart is the whole
|
|
252
|
+
// point of reading the header. A workspace-scoped key gets no
|
|
253
|
+
// unreachable placeholder rows at all — the platform withholds them
|
|
254
|
+
// rather than name computers in other workspaces — so header-present
|
|
255
|
+
// with nothing in the array is the ORDINARY shape of an outage for
|
|
256
|
+
// such a key, not a rare one.
|
|
257
|
+
//
|
|
258
|
+
// Saying "no computers yet, create one" there is the duplicate-create
|
|
259
|
+
// this warning exists to prevent: the model is told in one sentence
|
|
260
|
+
// that an unknown number are missing and in the next that the account
|
|
261
|
+
// is empty, and only one of those suggests an action.
|
|
262
|
+
if (incomplete !== null) {
|
|
263
|
+
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
|
+
}
|
|
265
|
+
// Named only when it is there to call. Under MANDALA_NO_LIFECYCLE
|
|
266
|
+
// create_computer is not registered, and this is the one place the
|
|
267
|
+
// name reached the model at RUN time rather than in a description —
|
|
268
|
+
// an invitation, in a sentence, to call a tool that does not exist.
|
|
269
|
+
return said(opts.lifecycle
|
|
270
|
+
? 'No computers on this account yet. create_computer makes one; list_templates says what from.'
|
|
271
|
+
: 'No computers on this account yet. This server cannot make one — it was started with the lifecycle tools withheld — so one has to be created elsewhere before there is anything here to drive.');
|
|
272
|
+
}
|
|
273
|
+
const lines = list.map((c) => `- ${describe(c)}`).join('\n');
|
|
274
|
+
return said(`${warning}${list.length} computer(s):\n${lines}`, list.map((c) => withoutCredentials(c)));
|
|
275
|
+
}));
|
|
276
|
+
server.registerTool('get_computer', {
|
|
277
|
+
title: 'Get a computer',
|
|
278
|
+
description: 'Everything the platform knows about one computer: status, size, and the screen resolution every click and screenshot is measured in.',
|
|
279
|
+
inputSchema: { ...idArg },
|
|
280
|
+
annotations: { readOnlyHint: true },
|
|
281
|
+
}, ({ computer_id }, extra) => guarded(async () => {
|
|
282
|
+
const id = session.resolve(computer_id);
|
|
283
|
+
const c = unwrapComputer(await session.api.with(extra.signal).json('GET', P.computer(id)));
|
|
284
|
+
session.noteResolution(id, c.resolution);
|
|
285
|
+
return said(describe(c), withoutCredentials(c));
|
|
286
|
+
}));
|
|
287
|
+
server.registerTool('use_computer', {
|
|
288
|
+
title: 'Select a computer',
|
|
289
|
+
description: 'Bind a computer to this session, so every later call can leave computer_id out. Answers with its status and screen resolution.',
|
|
290
|
+
inputSchema: {
|
|
291
|
+
computer_id: z.string().describe('The id from list_computers.'),
|
|
292
|
+
},
|
|
293
|
+
}, ({ computer_id }, extra) => guarded(async () => {
|
|
294
|
+
const selectionVersion = session.beginSelection(computer_id);
|
|
295
|
+
try {
|
|
296
|
+
// Read it before binding. Binding an id the platform does not recognise
|
|
297
|
+
// would send every subsequent call to a 404 with no clue why, and the
|
|
298
|
+
// read costs one round trip against a session that will make hundreds.
|
|
299
|
+
const c = unwrapComputer(await session.api.with(extra.signal).json('GET', P.computer(computer_id)));
|
|
300
|
+
// The id the platform echoed back, not the one that was typed. They can
|
|
301
|
+
// differ — `P.segment` trims before the call, so " vm-1 " reaches the
|
|
302
|
+
// API as vm-1 — and `unbind` and `noteResolution` compare with `===`,
|
|
303
|
+
// so binding the untrimmed form leaves a later delete_computer("vm-1")
|
|
304
|
+
// unable to clear the selection it just destroyed. The other two bind
|
|
305
|
+
// sites already use `c.id`.
|
|
306
|
+
if (!session.bindIfCurrent(c.id ?? computer_id, c.resolution, selectionVersion)) {
|
|
307
|
+
return refused(`${c.id ?? computer_id} was deleted while it was being selected. The session selection was not changed.`);
|
|
308
|
+
}
|
|
309
|
+
return said(`Selected ${describe(c)}. Later calls need no computer_id.` +
|
|
310
|
+
(c.status === 'running'
|
|
311
|
+
? ''
|
|
312
|
+
: `\n\nIt is ${c.status ?? 'not running'} — start_computer before driving it.`), withoutCredentials(c));
|
|
313
|
+
}
|
|
314
|
+
finally {
|
|
315
|
+
session.endSelection(computer_id);
|
|
316
|
+
}
|
|
317
|
+
}));
|
|
318
|
+
// Power. Not behind the lifecycle gate: a server that may only attach to
|
|
319
|
+
// computers somebody else made still has to be able to bring one up, and a
|
|
320
|
+
// stopped computer refuses every other tool here.
|
|
321
|
+
//
|
|
322
|
+
// Four tools around one request, and stop registered on its own below it:
|
|
323
|
+
// stop is the only power action with a second argument to take, and folding
|
|
324
|
+
// an optional one into the loop would leave the other three advertising a
|
|
325
|
+
// parameter their route does not read.
|
|
326
|
+
const power = (action, computer_id, extra, opts = {}) => guarded(async () => {
|
|
327
|
+
const id = session.resolve(computer_id);
|
|
328
|
+
const c = unwrapComputer(await session.api
|
|
329
|
+
.with(extra.signal)
|
|
330
|
+
.json('POST', P.computerAction(id, action), { query: opts.query }));
|
|
331
|
+
session.noteResolution(id, c.resolution);
|
|
332
|
+
return said(`${action}: ${describe(c)}${opts.note ?? ''}`, withoutCredentials(c));
|
|
333
|
+
});
|
|
334
|
+
for (const action of ['start', 'suspend', 'restart']) {
|
|
335
|
+
server.registerTool(`${action}_computer`, {
|
|
336
|
+
title: `${action[0].toUpperCase()}${action.slice(1)} a computer`,
|
|
337
|
+
description: POWER_DESCRIPTIONS[action],
|
|
338
|
+
inputSchema: { ...idArg },
|
|
339
|
+
}, ({ computer_id }, extra) => power(action, computer_id, extra));
|
|
340
|
+
}
|
|
341
|
+
server.registerTool('stop_computer', {
|
|
342
|
+
title: 'Stop a computer',
|
|
343
|
+
description: POWER_DESCRIPTIONS.stop,
|
|
344
|
+
inputSchema: {
|
|
345
|
+
...idArg,
|
|
346
|
+
force: z
|
|
347
|
+
.boolean()
|
|
348
|
+
.optional()
|
|
349
|
+
.describe('Pull the power instead of asking, the way holding the button in does. Anything the guest had not written to disk is lost, so this is the second attempt and not the first: stop it politely, and reach for `force` when what comes back is a computer still running — a hung X session, a modal "unsaved changes" dialog, or a service that ignores SIGTERM will refuse the polite stop identically every time it is asked.'),
|
|
350
|
+
},
|
|
351
|
+
}, ({ computer_id, force }, extra) => power('stop', computer_id, extra, {
|
|
352
|
+
// The platform's schema for this one is `enum: ['true']` — a string,
|
|
353
|
+
// with no false in it — so an unforced stop omits the parameter rather
|
|
354
|
+
// than sending `force=false`, the way `allow_partial` and
|
|
355
|
+
// `snapshots=delete` are omitted here already.
|
|
356
|
+
query: { force: force ? 'true' : undefined },
|
|
357
|
+
// Said in the answer and not only in the schema, because the two stops
|
|
358
|
+
// are indistinguishable afterwards: both leave a stopped computer with
|
|
359
|
+
// its disk, and only one of them threw away what was in RAM. A
|
|
360
|
+
// transcript that does not say which happened is a transcript nobody
|
|
361
|
+
// can debug the missing work from.
|
|
362
|
+
note: force
|
|
363
|
+
? '\n\nThe power was pulled rather than asked for: whatever the guest had not written to disk is gone.'
|
|
364
|
+
: undefined,
|
|
365
|
+
}));
|
|
366
|
+
server.registerTool('update_computer', {
|
|
367
|
+
title: 'Rename or resize a computer',
|
|
368
|
+
description: "Change a computer's name, its size, or its idle window. The platform refuses these in combination on purpose — a resize needs the computer stopped and the other two do not, so one request cannot honour both without applying half of it. A SUSPENDED computer counts as stopped for a resize, and its saved desktop cannot survive one: the vCPU count and the memory size are part of the saved state, so it is discarded and the next start is a cold boot. Resume it and finish what is open before resizing, or say so before you do it.",
|
|
369
|
+
inputSchema: {
|
|
370
|
+
...idArg,
|
|
371
|
+
name: z.string().optional(),
|
|
372
|
+
cpu: z.number().int().min(1).optional().describe('Needs the computer stopped.'),
|
|
373
|
+
ram_mb: z.number().int().min(512).optional().describe('Needs the computer stopped.'),
|
|
374
|
+
disk_gb: z
|
|
375
|
+
.number()
|
|
376
|
+
.int()
|
|
377
|
+
.min(1)
|
|
378
|
+
.optional()
|
|
379
|
+
.describe('Needs the computer stopped. Disks grow only.'),
|
|
380
|
+
idle_suspend_min: z
|
|
381
|
+
.number()
|
|
382
|
+
.int()
|
|
383
|
+
.min(0)
|
|
384
|
+
.nullable()
|
|
385
|
+
.optional()
|
|
386
|
+
.describe("Minutes untouched before the host suspends it. null follows the host's own window; send this on its own."),
|
|
387
|
+
},
|
|
388
|
+
}, ({ computer_id, ...fields }, extra) => guarded(async () => {
|
|
389
|
+
const id = session.resolve(computer_id);
|
|
390
|
+
// `null` is meaningful for idle_suspend_min and must survive the filter;
|
|
391
|
+
// every other absent field is dropped so the platform leaves it alone.
|
|
392
|
+
const body = Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== undefined));
|
|
393
|
+
if (!Object.keys(body).length) {
|
|
394
|
+
return refused('Nothing to change — give at least one of name, cpu, ram_mb, disk_gb, idle_suspend_min.');
|
|
395
|
+
}
|
|
396
|
+
let c;
|
|
397
|
+
try {
|
|
398
|
+
c = unwrapComputer(await session.api.with(extra.signal).json('PATCH', P.computer(id), { body }));
|
|
399
|
+
}
|
|
400
|
+
catch (err) {
|
|
401
|
+
// The one refusal on this route that is an offer rather than an end
|
|
402
|
+
// (OPL-3775). Caught here and nowhere else because this is the only
|
|
403
|
+
// route that produces it, and because the next step names a tool —
|
|
404
|
+
// which the error class, shared with every embedder, has no business
|
|
405
|
+
// knowing about.
|
|
406
|
+
if (err instanceof MoveRequiredError)
|
|
407
|
+
return moveOffered(id, err);
|
|
408
|
+
throw err;
|
|
409
|
+
}
|
|
410
|
+
session.noteResolution(id, c.resolution);
|
|
411
|
+
return said(describe(c), withoutCredentials(c));
|
|
412
|
+
}));
|
|
413
|
+
server.registerTool('move_computer', {
|
|
414
|
+
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 it if the wait runs out.',
|
|
416
|
+
inputSchema: {
|
|
417
|
+
...idArg,
|
|
418
|
+
// Required, unlike every other field here, and unlike the same argument
|
|
419
|
+
// on update_computer. A move exists to escape a RAM ceiling: the
|
|
420
|
+
// platform fills an omitted ram_mb from the computer's current size and
|
|
421
|
+
// then refuses the move for not needing one, so a call without it can
|
|
422
|
+
// only ever be refused. Requiring it here turns a guaranteed 409 into a
|
|
423
|
+
// schema the model cannot get wrong.
|
|
424
|
+
ram_mb: z
|
|
425
|
+
.number()
|
|
426
|
+
.int()
|
|
427
|
+
.min(512)
|
|
428
|
+
.describe('The size that did not fit. Must be MORE than the computer has now.'),
|
|
429
|
+
cpu: z
|
|
430
|
+
.number()
|
|
431
|
+
.int()
|
|
432
|
+
.min(1)
|
|
433
|
+
.optional()
|
|
434
|
+
.describe('Applied with the move. Omit to leave alone.'),
|
|
435
|
+
disk_gb: z
|
|
436
|
+
.number()
|
|
437
|
+
.int()
|
|
438
|
+
.min(1)
|
|
439
|
+
.optional()
|
|
440
|
+
.describe('Applied with the move, after the copy. Disks grow only.'),
|
|
441
|
+
timeout_s: z
|
|
442
|
+
.number()
|
|
443
|
+
.int()
|
|
444
|
+
.min(5)
|
|
445
|
+
.max(900)
|
|
446
|
+
.default(300)
|
|
447
|
+
.describe('How long to wait for the move to finish before handing back and letting you poll.'),
|
|
448
|
+
},
|
|
449
|
+
}, ({ computer_id, ram_mb, cpu, disk_gb, timeout_s }, extra) => guarded(async () => {
|
|
450
|
+
const id = session.resolve(computer_id);
|
|
451
|
+
const body = {
|
|
452
|
+
ram_mb,
|
|
453
|
+
...(cpu !== undefined && { cpu }),
|
|
454
|
+
...(disk_gb !== undefined && { disk_gb }),
|
|
455
|
+
};
|
|
456
|
+
// One deadline for the whole call, armed before the POST rather than
|
|
457
|
+
// after it, for the reason wait_for_computer gives: timeout_s is a
|
|
458
|
+
// promise about when this comes back, and a per-poll timer bounds how
|
|
459
|
+
// often it asks instead.
|
|
460
|
+
const untilDeadline = AbortSignal.timeout(timeout_s * 1000);
|
|
461
|
+
const signal = extra.signal
|
|
462
|
+
? AbortSignal.any([extra.signal, untilDeadline])
|
|
463
|
+
: untilDeadline;
|
|
464
|
+
const api = session.api.with(signal);
|
|
465
|
+
// The 202. Its body is the move as it stood the moment it was accepted,
|
|
466
|
+
// and it is kept because it is the only description of this move that
|
|
467
|
+
// does not depend on a later read succeeding.
|
|
468
|
+
const started = (await api.json('POST', P.computerAction(id, 'move'), { body }));
|
|
469
|
+
let last = started;
|
|
470
|
+
let blocked;
|
|
471
|
+
while (!untilDeadline.aborted) {
|
|
472
|
+
if (extra.signal?.aborted) {
|
|
473
|
+
return refused(`Cancelled while waiting for ${id} to move. THE MOVE IS STILL RUNNING — nothing was stopped, ` +
|
|
474
|
+
`because a disk crossing between two hosts cannot be called back. list_moves says where it ` +
|
|
475
|
+
`got to.`, last);
|
|
476
|
+
}
|
|
477
|
+
let table;
|
|
478
|
+
let raw;
|
|
479
|
+
try {
|
|
480
|
+
raw = await api.json('GET', P.MOVES);
|
|
481
|
+
table = movesOf(raw);
|
|
482
|
+
}
|
|
483
|
+
catch (err) {
|
|
484
|
+
if (extra.signal?.aborted)
|
|
485
|
+
continue;
|
|
486
|
+
if (err instanceof CancelledError) {
|
|
487
|
+
if (untilDeadline.aborted)
|
|
488
|
+
break;
|
|
489
|
+
blocked = err.message;
|
|
490
|
+
await sleep(POLL_MS, signal);
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
// The poll reads the control plane's own table, so the statuses
|
|
494
|
+
// worth riding out are the ones that mean "ask again" — exactly
|
|
495
|
+
// wait_for_computer's list. Anything else is a real failure, and
|
|
496
|
+
// the move is still running behind it, which a thrown error's
|
|
497
|
+
// handler has no way to say. So it is said here.
|
|
498
|
+
if (!isTransientForPoll(err)) {
|
|
499
|
+
return refused(`${err instanceof Error ? err.message : String(err)}\n\nTHE MOVE IS STILL RUNNING — this ` +
|
|
500
|
+
`was the poll failing, not the move. list_moves says where it got to.`, last);
|
|
501
|
+
}
|
|
502
|
+
blocked = err instanceof Error ? err.message : String(err);
|
|
503
|
+
await sleep(pollDelay(err), signal);
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
// A table that is not a list is the platform failing to answer, not
|
|
507
|
+
// an answer that the move is gone. It rides out the same way a poll
|
|
508
|
+
// that threw does, so the deadline's sentence says the platform could
|
|
509
|
+
// not be asked rather than claiming a deletion nothing established.
|
|
510
|
+
if (!table) {
|
|
511
|
+
blocked = `GET /moves answered with ${shapeOf(raw?.moves)}, not a list of moves`;
|
|
512
|
+
await sleep(POLL_MS, signal);
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
blocked = undefined;
|
|
516
|
+
const mine = table.moves.find((m) => m.computer_id === id);
|
|
517
|
+
// A move that is no longer listed is one the platform reaped, and it
|
|
518
|
+
// reaps for one reason: the computer was deleted. Not a state to keep
|
|
519
|
+
// polling for.
|
|
520
|
+
//
|
|
521
|
+
// Unless a row could not be READ, in which case absence is not
|
|
522
|
+
// established: the move may be sitting in the row this poll had to
|
|
523
|
+
// drop. That is a poll that could not be answered rather than an
|
|
524
|
+
// answer, so it rides out exactly as a transient failure does, and
|
|
525
|
+
// the deadline's sentence says the platform could not be asked
|
|
526
|
+
// instead of claiming a deletion nothing showed.
|
|
527
|
+
if (!mine && table.dropped) {
|
|
528
|
+
blocked = `GET /moves answered with ${table.dropped} unreadable row(s), so this move may be among them`;
|
|
529
|
+
await sleep(POLL_MS, signal);
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
if (!mine) {
|
|
533
|
+
return refused(`The move of ${id} is no longer listed. That happens when the computer is deleted — check ` +
|
|
534
|
+
`list_computers.`, last);
|
|
535
|
+
}
|
|
536
|
+
last = mine;
|
|
537
|
+
if (!mine.live)
|
|
538
|
+
return finishedMove(id, mine);
|
|
539
|
+
await sleep(POLL_MS, signal);
|
|
540
|
+
}
|
|
541
|
+
return refused(blocked
|
|
542
|
+
? `Gave up watching after ${timeout_s}s; the platform could not be asked — the last attempt said: ` +
|
|
543
|
+
`${blocked}. THE MOVE IS STILL RUNNING. list_moves says where it got to.`
|
|
544
|
+
: `Still moving after ${timeout_s}s, which a large disk takes. THE MOVE IS STILL RUNNING and ` +
|
|
545
|
+
`nothing was changed by giving up on the wait. list_moves says where it got to.`, last);
|
|
546
|
+
}));
|
|
547
|
+
server.registerTool('list_moves', {
|
|
548
|
+
title: 'List moves in progress and their outcomes',
|
|
549
|
+
description: 'Every move on this account: the ones running and the ones that finished in the last day. Read this after move_computer if the wait ran out, and read it when a move is refused because another computer on the account is already being moved — only one runs at a time, and this says which one and how far along. `live` is the flag to poll on.',
|
|
550
|
+
inputSchema: {},
|
|
551
|
+
annotations: { readOnlyHint: true },
|
|
552
|
+
}, (_args, extra) => guarded(async () => {
|
|
553
|
+
const body = await session.api.with(extra.signal).json('GET', P.MOVES);
|
|
554
|
+
const moves = movesOf(body);
|
|
555
|
+
if (!moves) {
|
|
556
|
+
return refused(`GET /moves answered with ${shapeOf(body?.moves)}, not a list of moves. This is not an account with no moves on it — a move you started may still be running.`, body);
|
|
557
|
+
}
|
|
558
|
+
// Said the way list_computers and list_snapshots say it: a dropped row
|
|
559
|
+
// is reported, and a listing left with nothing but dropped rows is a
|
|
560
|
+
// refusal rather than an empty account. "No moves on this account" is
|
|
561
|
+
// an affirmative claim, and it is the one the refusal above exists to
|
|
562
|
+
// avoid making from an unreadable answer.
|
|
563
|
+
const warning = moves.dropped
|
|
564
|
+
? `WARNING: ignored ${moves.dropped} malformed move entr${moves.dropped === 1 ? 'y' : 'ies'} from the platform.\n\n`
|
|
565
|
+
: '';
|
|
566
|
+
if (!moves.moves.length && moves.dropped) {
|
|
567
|
+
return refused(`${warning}No readable moves remained. This is not an account with no moves on it — a move you started may still be running.`, body);
|
|
568
|
+
}
|
|
569
|
+
if (!moves.moves.length)
|
|
570
|
+
return said('No moves on this account.', []);
|
|
571
|
+
return said(`${warning}${moves.moves.map(moveLine).join('\n')}`, moves.moves);
|
|
572
|
+
}));
|
|
573
|
+
server.registerTool('get_usage', {
|
|
574
|
+
title: 'Read what this account has used',
|
|
575
|
+
description: 'What this account has spent: running hours weighted by cores and memory, the storage it holds, and the per-computer breakdown behind the totals — the same figures the billing page shows. Read it before and after a batch of computers to know what one cost, and read it when asked how much anything has cost. Defaults to the current billing period, which is the window an invoice covers. READ THE FIRST LINE OF THE ANSWER: a hypervisor that could not be reached makes every total too LOW rather than absent, and that line is the only thing that says so.',
|
|
576
|
+
inputSchema: {
|
|
577
|
+
from: z
|
|
578
|
+
.string()
|
|
579
|
+
.optional()
|
|
580
|
+
.describe('Start of the window, RFC 3339 with a time zone — "2026-08-01T00:00:00Z". Omit for the start of the billing period, and send it WITH `to` when you are asking about a period that has closed — `to` on its own is measured from the current period and is refused. A timestamp without a zone is refused rather than guessed at. Records go back 399 days.'),
|
|
581
|
+
to: z
|
|
582
|
+
.string()
|
|
583
|
+
.optional()
|
|
584
|
+
.describe('End of the window, same format. Omit for now. A time in the future is answered as now, and the answer says which instant it used. The window itself may be at most 62 days — every hypervisor replays its ledger a day at a time to answer — so read an older period by naming both bounds rather than by widening this one.'),
|
|
585
|
+
},
|
|
586
|
+
annotations: { readOnlyHint: true },
|
|
587
|
+
}, ({ from, to }, extra) => guarded(async () => {
|
|
588
|
+
const body = (await session.api
|
|
589
|
+
.with(extra.signal)
|
|
590
|
+
.json('GET', P.USAGE, { query: P.usageQuery(from, to) }));
|
|
591
|
+
// `0 vCPU-hours` is a bill, and a missing totals object is not one. The
|
|
592
|
+
// fields inside it still default — a meter that sent no `disk_gb_months`
|
|
593
|
+
// is saying zero — but the object holding them has to have arrived, or
|
|
594
|
+
// this answers with a figure nobody metered in the shape of one somebody
|
|
595
|
+
// did, on the one call whose output is compared against an invoice.
|
|
596
|
+
const totals = body?.usage;
|
|
597
|
+
if (totals === null || typeof totals !== 'object' || Array.isArray(totals)) {
|
|
598
|
+
return refused(`GET /usage answered with ${shapeOf(totals)} where the totals object goes, so there are no figures to report. This is NOT an account that used nothing.`, body);
|
|
599
|
+
}
|
|
600
|
+
return said(usageLine(body), body);
|
|
601
|
+
}));
|
|
602
|
+
server.registerTool('wait_for_computer', {
|
|
603
|
+
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.',
|
|
605
|
+
inputSchema: {
|
|
606
|
+
...idArg,
|
|
607
|
+
until: z
|
|
608
|
+
.enum(['running', 'guest'])
|
|
609
|
+
.default('guest')
|
|
610
|
+
.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
|
+
timeout_s: z.number().int().min(5).max(900).default(180),
|
|
612
|
+
},
|
|
613
|
+
}, ({ computer_id, until, timeout_s }, extra) => guarded(async () => {
|
|
614
|
+
const id = session.resolve(computer_id);
|
|
615
|
+
// timeout_s used to gate only the top of the loop, which bounds how
|
|
616
|
+
// often this asks and not how long any one ask may take. Node's fetch
|
|
617
|
+
// has no response deadline of its own beyond undici's five-minute
|
|
618
|
+
// header timeout, so a single stalled poll could hold a wait told to
|
|
619
|
+
// give up in thirty seconds for minutes past its word — and the tool's
|
|
620
|
+
// whole contract is that it comes back when it said it would.
|
|
621
|
+
//
|
|
622
|
+
// One signal for the whole wait rather than one per poll: the deadline
|
|
623
|
+
// is a property of the wait, and arming a fresh timer on every turn
|
|
624
|
+
// would leave hundreds of them live across a fifteen-minute window.
|
|
625
|
+
const untilDeadline = AbortSignal.timeout(timeout_s * 1000);
|
|
626
|
+
const signal = extra.signal
|
|
627
|
+
? AbortSignal.any([extra.signal, untilDeadline])
|
|
628
|
+
: untilDeadline;
|
|
629
|
+
const api = session.api.with(signal);
|
|
630
|
+
let last = 'unknown';
|
|
631
|
+
// Kept so the give-up message can name it. A hypervisor that was
|
|
632
|
+
// unreachable for the whole window is the single most useful thing to
|
|
633
|
+
// report, and swallowing every transient would end the wait saying only
|
|
634
|
+
// that the status was never seen.
|
|
635
|
+
let blocked;
|
|
636
|
+
while (!untilDeadline.aborted) {
|
|
637
|
+
// The caller giving up ends the wait. The signal aborts the request
|
|
638
|
+
// in flight, but nothing about an aborted request stops the next
|
|
639
|
+
// iteration from starting one — so a cancelled call would go on
|
|
640
|
+
// polling the platform for the rest of its timeout_s, up to fifteen
|
|
641
|
+
// minutes of traffic on behalf of nobody.
|
|
642
|
+
if (extra.signal?.aborted)
|
|
643
|
+
return cancelled(id, last);
|
|
644
|
+
// The status read is exactly as transient-prone as the guest probe
|
|
645
|
+
// below it — a hypervisor that cannot be reached answers 503, which
|
|
646
|
+
// is the ordinary weather of a machine still coming up. Letting that
|
|
647
|
+
// out would abort the one tool whose entire job is to keep asking.
|
|
648
|
+
let c;
|
|
649
|
+
try {
|
|
650
|
+
c = unwrapComputer(await api.json('GET', P.computer(id)));
|
|
651
|
+
}
|
|
652
|
+
catch (err) {
|
|
653
|
+
// The caller's own signal is checked first, and by identity rather
|
|
654
|
+
// than by reading the error: the request is now bound to two
|
|
655
|
+
// deadlines, and only one of them means anybody stopped caring.
|
|
656
|
+
if (extra.signal?.aborted)
|
|
657
|
+
return cancelled(id, last);
|
|
658
|
+
// A body stream can also fail without either signal firing (an
|
|
659
|
+
// undici idle timeout is an AbortError). That is a transport
|
|
660
|
+
// failure, not a cancellation, and is retried below as transient.
|
|
661
|
+
// Only the deadline signal proves the wait's own timer arrived.
|
|
662
|
+
if (err instanceof CancelledError) {
|
|
663
|
+
if (untilDeadline.aborted) {
|
|
664
|
+
blocked = `the status read was still in flight when the ${timeout_s}s deadline arrived`;
|
|
665
|
+
break;
|
|
666
|
+
}
|
|
667
|
+
blocked = err.message;
|
|
668
|
+
await sleep(POLL_MS, signal);
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
if (!isTransientForPoll(err))
|
|
672
|
+
throw err;
|
|
673
|
+
blocked = err instanceof Error ? err.message : String(err);
|
|
674
|
+
await sleep(pollDelay(err), signal);
|
|
675
|
+
continue;
|
|
676
|
+
}
|
|
677
|
+
blocked = undefined;
|
|
678
|
+
session.noteResolution(id, c.resolution);
|
|
679
|
+
last = c.status ?? 'unknown';
|
|
680
|
+
if (last === 'build-failed') {
|
|
681
|
+
// `refused`, for the reason `cancelled` is: the wait never reached
|
|
682
|
+
// what it was told to wait for, and this one never will. A caller
|
|
683
|
+
// reading `isError` to decide whether to go on would otherwise see
|
|
684
|
+
// a build that failed and a guest that answered as the same result.
|
|
685
|
+
//
|
|
686
|
+
// `build.source` is what the machine was built *from*, not why the
|
|
687
|
+
// build failed — printed bare after "Build failed:" it reads as the
|
|
688
|
+
// reason and names an image instead of a cause. `start_error` is
|
|
689
|
+
// the field that carries a diagnostic, so prefer it and label the
|
|
690
|
+
// source as the source when that is all there is.
|
|
691
|
+
const why = c.start_error
|
|
692
|
+
? `: ${c.start_error}`
|
|
693
|
+
: c.build?.source
|
|
694
|
+
? ` (built from ${c.build.source}) — the platform gave no reason`
|
|
695
|
+
: ' — the platform gave no reason';
|
|
696
|
+
return refused(`Build failed${why}. This does not resolve on its own.`, withoutCredentials(c));
|
|
697
|
+
}
|
|
698
|
+
// Neither of the next two resolves on its own, so spinning on either
|
|
699
|
+
// burns the whole timeout waiting for something nobody is going to do.
|
|
700
|
+
if (last === 'suspended') {
|
|
701
|
+
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
|
+
}
|
|
703
|
+
if (last === 'stopped') {
|
|
704
|
+
return refused(`${id} is stopped. start_computer boots it.`, withoutCredentials(c));
|
|
705
|
+
}
|
|
706
|
+
if (last === 'running') {
|
|
707
|
+
if (until === 'running')
|
|
708
|
+
return said(`Running: ${describe(c)}`, withoutCredentials(c));
|
|
709
|
+
// "The guest is up" is not a status the platform reports, so it is
|
|
710
|
+
// asked rather than waited for: a trivial exec either answers, or
|
|
711
|
+
// refuses with the 409 that says the agent is not up yet.
|
|
712
|
+
try {
|
|
713
|
+
await api.send('POST', P.computerAction(id, 'exec'), {
|
|
714
|
+
body: P.execBody({ command: 'true', timeout_s: 5 }),
|
|
715
|
+
});
|
|
716
|
+
return said(`Guest is answering: ${describe(c)}`, withoutCredentials(c));
|
|
717
|
+
}
|
|
718
|
+
catch (err) {
|
|
719
|
+
// The same two deadlines as the status read above, and for the
|
|
720
|
+
// same reason: this catch used to judge the error alone, so a
|
|
721
|
+
// cancellation during the guest probe left the wait throwing what
|
|
722
|
+
// read as a platform outage instead of saying the caller had
|
|
723
|
+
// hung up. Half the loop knew to check the signal and half did
|
|
724
|
+
// not, which is the worse of the two ways to be inconsistent.
|
|
725
|
+
if (extra.signal?.aborted)
|
|
726
|
+
return cancelled(id, last);
|
|
727
|
+
if (err instanceof CancelledError) {
|
|
728
|
+
if (untilDeadline.aborted) {
|
|
729
|
+
blocked = `the guest probe was still in flight when the ${timeout_s}s deadline arrived`;
|
|
730
|
+
break;
|
|
731
|
+
}
|
|
732
|
+
blocked = err.message;
|
|
733
|
+
await sleep(POLL_MS, signal);
|
|
734
|
+
continue;
|
|
735
|
+
}
|
|
736
|
+
if (!isTransientForPoll(err))
|
|
737
|
+
throw err;
|
|
738
|
+
// The guest probe's own failure decides this turn's interval, for
|
|
739
|
+
// pollDelay's reason. The ordinary path below keeps POLL_MS.
|
|
740
|
+
await sleep(pollDelay(err), signal);
|
|
741
|
+
continue;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
await sleep(POLL_MS, signal);
|
|
745
|
+
}
|
|
746
|
+
// Also a refusal: the deadline passed without the condition being met,
|
|
747
|
+
// which is the same shape of answer as a cancellation and not the same
|
|
748
|
+
// as success. The message still says to call again, because the state
|
|
749
|
+
// it was waiting on may yet arrive.
|
|
750
|
+
return refused(blocked
|
|
751
|
+
? `Gave up after ${timeout_s}s; the platform could not be asked about ${id} for the whole wait — the last attempt said: ${blocked}. Nothing was changed — call again to keep waiting.`
|
|
752
|
+
: `Gave up after ${timeout_s}s; ${id} was last seen ${last}. Nothing was changed — call again to keep waiting.`);
|
|
753
|
+
}));
|
|
754
|
+
server.registerTool('get_desktop_url', {
|
|
755
|
+
title: 'Get a link to watch the desktop',
|
|
756
|
+
description: "A URL that opens this computer's live desktop in a browser. Watch-only by default. These are credentials in a link: anyone holding one has that desktop until the computer restarts.",
|
|
757
|
+
inputSchema: {
|
|
758
|
+
...idArg,
|
|
759
|
+
control: z
|
|
760
|
+
.boolean()
|
|
761
|
+
.default(false)
|
|
762
|
+
.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
|
+
},
|
|
764
|
+
annotations: { readOnlyHint: true },
|
|
765
|
+
}, ({ computer_id, control }, extra) => guarded(async () => {
|
|
766
|
+
const id = session.resolve(computer_id);
|
|
767
|
+
const c = unwrapComputer(await session.api.with(extra.signal).json('GET', P.computer(id)));
|
|
768
|
+
const vnc = c.vnc;
|
|
769
|
+
if (!vnc) {
|
|
770
|
+
// `refused`: the caller asked for a URL and there is none. Said as a
|
|
771
|
+
// success, an orchestrator reading `isError` cannot tell a link from
|
|
772
|
+
// the absence of one, and hands the next step a sentence where it
|
|
773
|
+
// expected an address.
|
|
774
|
+
return refused(`No desktop credentials on ${id} right now. The platform omits them when the computer is not running, or when its hypervisor could not be reached — a URL built over nothing looks exactly like a working one until it is used.`);
|
|
775
|
+
}
|
|
776
|
+
// A `vnc` object that is missing the requested key is the same answer as
|
|
777
|
+
// no `vnc` at all, and has to read like it. `JSON.stringify` drops an
|
|
778
|
+
// undefined value rather than recording it, so handing the object
|
|
779
|
+
// straight over would print `{}` underneath a sentence promising full
|
|
780
|
+
// control of the machine — the reader is told a link was given and
|
|
781
|
+
// shown nothing to reconcile that against.
|
|
782
|
+
// Read as strings rather than trusted as them. `vnc` carries a boolean
|
|
783
|
+
// now (`clipboard`, below), so the object is no longer one type — and a
|
|
784
|
+
// number or an object arriving where a URL goes has to read as an absent
|
|
785
|
+
// link rather than be handed on as one, which is what the check below
|
|
786
|
+
// decides.
|
|
787
|
+
const link = (v) => (typeof v === 'string' && v ? v : undefined);
|
|
788
|
+
const links = control
|
|
789
|
+
? { url: link(vnc.url) }
|
|
790
|
+
: { view_url: link(vnc.view_url), embed_url: link(vnc.embed_url) };
|
|
791
|
+
if (!Object.values(links).some(Boolean)) {
|
|
792
|
+
return refused(`The platform is holding desktop credentials for ${id} but sent no ${control ? 'control' : 'watch-only'} URL among them. ${control ? 'Ask without control: true for the watch-only link.' : 'Try again in a moment, or ask with control: true.'}`);
|
|
793
|
+
}
|
|
794
|
+
// Whether the CLIPBOARD crosses this socket, which the platform now
|
|
795
|
+
// answers (OPL-3870). Three terms this server cannot see — the vdagent
|
|
796
|
+
// channel QEMU was given at its last cold boot, whether the image the
|
|
797
|
+
// computer was built from was verified to carry the agent, and the
|
|
798
|
+
// guest's OS — resolved into one boolean on the same body the links
|
|
799
|
+
// came from, so it costs no second call and no cache.
|
|
800
|
+
//
|
|
801
|
+
// Absent reads as false, deliberately, and not as "unknown": the two
|
|
802
|
+
// ways to be wrong are not symmetric. A false about a working bridge
|
|
803
|
+
// costs the model nothing, since read_clipboard and write_clipboard work
|
|
804
|
+
// there too, while a true about an absent one is the silently dropped
|
|
805
|
+
// paste the field exists to end. mandala-computer-python's `VncConnect`
|
|
806
|
+
// defaults it the same way and for the same reason.
|
|
807
|
+
const bridged = vnc.clipboard === true;
|
|
808
|
+
const socket = bridged
|
|
809
|
+
? 'THE CLIPBOARD CROSSES THIS SOCKET on this computer — the platform says so, so a noVNC ' +
|
|
810
|
+
'client that negotiates the extended cut text pseudo-encoding copies and pastes over this ' +
|
|
811
|
+
'link on its own and nothing here has to be called. That is the transport being OPEN ' +
|
|
812
|
+
'rather than a paste being guaranteed: the FIRST paste of a session is often dropped, ' +
|
|
813
|
+
'because the guest PULLS the text and the agent inside it may not own the selection yet — ' +
|
|
814
|
+
"send it again — and a browser will not hand the guest's clipboard back without focus and " +
|
|
815
|
+
'permission. It is also what was PROVISIONED rather than a live check. Somebody with root ' +
|
|
816
|
+
'in that guest can stop or remove the agent afterwards and this answer does not move, so ' +
|
|
817
|
+
'treat it as stale after anything that modified the guest, and fall back to read_clipboard ' +
|
|
818
|
+
'and write_clipboard — which work there too, and do not fight over the selection, because ' +
|
|
819
|
+
'they write the same one the agent then offers onward.'
|
|
820
|
+
: 'THE CLIPBOARD DOES NOT CROSS THIS SOCKET on this computer — the platform says so. Text ' +
|
|
821
|
+
'pasted into it reaches QEMU and stops, silently, with nothing to catch, so do not ask a ' +
|
|
822
|
+
'person to paste into this desktop. read_clipboard and write_clipboard are the answer and ' +
|
|
823
|
+
'need none of the hardware. If you want the socket half anyway, it has two halves and they ' +
|
|
824
|
+
'are acquired separately. The CHANNEL comes from a COLD start — stop_computer then ' +
|
|
825
|
+
'start_computer, or restart_computer on a computer that is already stopped, which starts ' +
|
|
826
|
+
'it; restart_computer on a RUNNING one does NOT do it, because that resets the guest ' +
|
|
827
|
+
'rather than rebuilding the machine QEMU was given, and a resumed or snapshot-restored ' +
|
|
828
|
+
'session keeps the topology of the capture it came from. The AGENT comes from the IMAGE ' +
|
|
829
|
+
'the computer was created from, and nothing moves an existing computer onto a newer one: ' +
|
|
830
|
+
'installing spice-vdagent in the guest may make a paste work but does not change this ' +
|
|
831
|
+
'answer, an image the platform has not verified reads the same way even where the agent is ' +
|
|
832
|
+
'present, and a Windows guest never has it whatever the hardware says. So if you cold-start ' +
|
|
833
|
+
'for this, the order is stop_computer, start_computer, wait_for_computer until the guest is ' +
|
|
834
|
+
'up, then get_desktop_url again to read the new answer — and test with a sentinel string ' +
|
|
835
|
+
'rather than with text you cannot afford to lose. This link keeps working across that: a ' +
|
|
836
|
+
'stop and a start do NOT reissue the credentials, and restart_computer is the only thing ' +
|
|
837
|
+
'that does, so use restart_computer on a stopped computer when you want the cold boot AND ' +
|
|
838
|
+
'a fresh credential. It is refused while a session is suspended, and a computer starting ' +
|
|
839
|
+
'for the FIRST time may load the boot capture it was created from instead, which carries ' +
|
|
840
|
+
'that capture topology.';
|
|
841
|
+
return control
|
|
842
|
+
? said('Full control — keyboard and pointer. Treat this link as a password for that ' +
|
|
843
|
+
'desktop. Of the tools here, restart_computer is the only one that ends it: a stop ' +
|
|
844
|
+
'and a start leave it working, so a restart is what revokes one that has leaked.\n\n' +
|
|
845
|
+
'TO MOVE TEXT, use read_clipboard and write_clipboard. They need nothing of the ' +
|
|
846
|
+
'hardware, but they require a Linux desktop image with xclip installed. An older or ' +
|
|
847
|
+
'custom image without xclip gets a permanent 400 from both tools; changing runtime ' +
|
|
848
|
+
'state or retrying cannot fix that image dependency. Where it is present, prefer the ' +
|
|
849
|
+
'tools, and do not start by asking a person to paste into the desktop. They are ' +
|
|
850
|
+
'refused outright on Windows. Do NOT reach for xclip through exec instead: exec runs a login shell, so ' +
|
|
851
|
+
"the guest user's profile prints onto the same output your command does, ahead of it, " +
|
|
852
|
+
'which corrupts a read you are trying to parse; and a write through exec has to leave ' +
|
|
853
|
+
'xclip resident, redirect its output, travel base64 to survive an apostrophe, and then ' +
|
|
854
|
+
'be polled for, because being granted an X selection is asynchronous and a detached ' +
|
|
855
|
+
'xclip gives up its exit status. write_clipboard does all of that in one call and ' +
|
|
856
|
+
'confirms the selection was taken before it answers.\n\n' +
|
|
857
|
+
socket, links)
|
|
858
|
+
: said('Watch-only. The platform drops input on this socket, so it is safe to hand to ' +
|
|
859
|
+
"somebody. The guest's clipboard does not come back over it either, and that is " +
|
|
860
|
+
'enforced rather than asked for: the daemon takes the clipboard capability out of the ' +
|
|
861
|
+
'connection as it is negotiated, so a patched client gains nothing by asking. What the ' +
|
|
862
|
+
'person at the desktop COPIES does not reach whoever holds this link — though the ' +
|
|
863
|
+
'screen still does, so a password visible on it is not protected by this. The ' +
|
|
864
|
+
"platform's own clipboard answer for a watch-only link is therefore always no, " +
|
|
865
|
+
'whatever the computer itself can do: it describes the socket you were handed rather ' +
|
|
866
|
+
'than the machine, so ask with control: true before concluding anything about the ' +
|
|
867
|
+
'computer.', links);
|
|
868
|
+
}));
|
|
869
|
+
// Making and destroying machines. Registered by default — a one-line install
|
|
870
|
+
// that cannot produce a desktop is not much of a demo — and absent rather
|
|
871
|
+
// than present-and-refusing when an operator turns them off, because a tool a
|
|
872
|
+
// model can see is a tool it will try. See ToolOptions.lifecycle.
|
|
873
|
+
if (!opts.lifecycle)
|
|
874
|
+
return;
|
|
875
|
+
server.registerTool('create_computer', {
|
|
876
|
+
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.',
|
|
878
|
+
inputSchema: {
|
|
879
|
+
name: z.string().optional().describe('A label. The platform picks one if you do not.'),
|
|
880
|
+
size: z
|
|
881
|
+
.string()
|
|
882
|
+
.optional()
|
|
883
|
+
.describe('A named size from list_sizes, e.g. "large" — the fast path, since these are the shapes the platform keeps pre-booted. It sets template, cpu, ram_mb and disk_gb together, so send it alone or the explicit fields alone, never both.'),
|
|
884
|
+
template: z
|
|
885
|
+
.string()
|
|
886
|
+
.optional()
|
|
887
|
+
.describe('From list_templates, e.g. "base" for Linux/Xfce. Defaults to the platform default.'),
|
|
888
|
+
cpu: z.number().int().min(1).optional(),
|
|
889
|
+
ram_mb: z.number().int().min(512).optional(),
|
|
890
|
+
disk_gb: z
|
|
891
|
+
.number()
|
|
892
|
+
.int()
|
|
893
|
+
.min(1)
|
|
894
|
+
.optional()
|
|
895
|
+
.describe("The template's own disk is a FLOOR, not a default — read it from list_templates. A smaller number is raised to it silently and the account is charged the raised figure, so asking for less than the template needs spends more of the plan's disk pool than the number here suggests and can be refused outright."),
|
|
896
|
+
resolution: z
|
|
897
|
+
.string()
|
|
898
|
+
.optional()
|
|
899
|
+
.describe('WIDTHxHEIGHT or WIDTHxHEIGHTxDEPTH, 640x480 to 3840x2160, even numbers. Create-time only — the display is a QEMU property and there is no route that changes it later. Defaults to 1280x800x24.'),
|
|
900
|
+
start: z.boolean().optional().describe('Boot it immediately. True by default.'),
|
|
901
|
+
},
|
|
902
|
+
annotations: { destructiveHint: false, openWorldHint: true },
|
|
903
|
+
}, (args, extra) => guarded(async () => {
|
|
904
|
+
const c = unwrapComputer(await session.api
|
|
905
|
+
.with(extra.signal)
|
|
906
|
+
.json('POST', P.COMPUTERS, { body: P.createBody(args) }));
|
|
907
|
+
// Selection and the sentence claiming it are the same decision. Bound
|
|
908
|
+
// conditionally and reported unconditionally, a create that came back
|
|
909
|
+
// without an id left this session pointing at whatever it held before
|
|
910
|
+
// while telling the model the new machine was selected — so the next
|
|
911
|
+
// call drove the old computer, or none.
|
|
912
|
+
if (!c.id) {
|
|
913
|
+
return refused(`Created ${describe(c)}, but the platform sent no id back, so nothing was selected and this session is still bound to whatever it was before. The machine may exist and be billable — list_computers will say.`, withoutCredentials(c));
|
|
914
|
+
}
|
|
915
|
+
session.bind(c.id, c.resolution);
|
|
916
|
+
// A create whose guest was made and then would not boot is not an
|
|
917
|
+
// error: the machine exists and is billable, so it comes back stopped
|
|
918
|
+
// with the reason on it. Saying so plainly is the difference between a
|
|
919
|
+
// model retrying the start and a model creating a second computer.
|
|
920
|
+
const note = c.start_error
|
|
921
|
+
? `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
|
+
: `Created and selected ${describe(c)}.`;
|
|
923
|
+
return said(note, withoutCredentials(c));
|
|
924
|
+
}));
|
|
925
|
+
server.registerTool('clone_computer', {
|
|
926
|
+
title: 'Clone a computer',
|
|
927
|
+
description: 'Copy a computer to a new one — the fork half of snapshot-and-fork. The copy inherits the resolution, because its disk carries a desktop laid out at that size.',
|
|
928
|
+
inputSchema: {
|
|
929
|
+
...idArg,
|
|
930
|
+
name: z.string().optional().describe('A name for the copy.'),
|
|
931
|
+
},
|
|
932
|
+
}, ({ computer_id, name }, extra) => guarded(async () => {
|
|
933
|
+
const id = session.resolve(computer_id);
|
|
934
|
+
const c = unwrapComputer(await session.api.with(extra.signal).json('POST', P.computerAction(id, 'clone'), {
|
|
935
|
+
body: name === undefined ? {} : { name },
|
|
936
|
+
}));
|
|
937
|
+
if (!c.id) {
|
|
938
|
+
return refused(`The platform accepted the clone of ${id} but sent no id back, so the copy cannot be identified. It may exist and be billable — list_computers will say. The original stays selected.`, withoutCredentials(c));
|
|
939
|
+
}
|
|
940
|
+
return said(`Cloned ${id} to ${describe(c)}. The original stays selected; use_computer to switch.`, withoutCredentials(c));
|
|
941
|
+
}));
|
|
942
|
+
server.registerTool('delete_computer', {
|
|
943
|
+
title: 'Delete a computer',
|
|
944
|
+
description: 'Destroy a computer and its disk. Irreversible. Its snapshots are kept by default and become orphans, which can still be cloned but not restored. To destroy those too, read snapshot_holdings first and pass its fingerprint as `expect`.',
|
|
945
|
+
inputSchema: {
|
|
946
|
+
computer_id: z
|
|
947
|
+
.string()
|
|
948
|
+
.describe('Required in full, even when one is selected — a delete is not a call to infer a target for.'),
|
|
949
|
+
confirm: z
|
|
950
|
+
.literal(true)
|
|
951
|
+
.describe('Must be true. This destroys the disk and everything on it.'),
|
|
952
|
+
delete_snapshots: z
|
|
953
|
+
.boolean()
|
|
954
|
+
.default(false)
|
|
955
|
+
.describe('Also destroy every snapshot of this computer. Requires `expect`. Opt-in because the wrong answer here is unrecoverable: a snapshot kept by mistake costs storage, one destroyed by mistake costs the disk it was the last copy of.'),
|
|
956
|
+
expect: z
|
|
957
|
+
.string()
|
|
958
|
+
.optional()
|
|
959
|
+
.describe('The fingerprint from snapshot_holdings. The purge is refused unless it still names the same set, so a capture that finished after you looked cannot be swept up in a decision that was never about it.'),
|
|
960
|
+
},
|
|
961
|
+
annotations: { destructiveHint: true, idempotentHint: true },
|
|
962
|
+
}, ({ computer_id, delete_snapshots, expect }, extra) => guarded(async () => {
|
|
963
|
+
// The platform makes `expect` optional, for callers that cannot read the
|
|
964
|
+
// holdings and so were never shown a set to be held to. An MCP caller
|
|
965
|
+
// can read them — snapshot_holdings is right there — so here it is
|
|
966
|
+
// required, and the refusal names the tool that produces it.
|
|
967
|
+
//
|
|
968
|
+
// Not fetched on the caller's behalf, which was the tempting shortcut
|
|
969
|
+
// and is the wrong one. A fingerprint read a millisecond before the
|
|
970
|
+
// delete binds the purge to whatever the set is now, not to what anyone
|
|
971
|
+
// agreed to — and the race checkExpectation exists for is exactly that:
|
|
972
|
+
// a capture that finishes between the decision and the click, then gets
|
|
973
|
+
// destroyed by a confirmation that predates it.
|
|
974
|
+
const fingerprint = expect?.trim() || undefined;
|
|
975
|
+
if (delete_snapshots && !fingerprint) {
|
|
976
|
+
return refused('Refusing to purge snapshots without a fingerprint. Call snapshot_holdings on this computer, ' +
|
|
977
|
+
'check that the count and size are what you meant to destroy, and pass its fingerprint as `expect`. ' +
|
|
978
|
+
'Nothing has been deleted.');
|
|
979
|
+
}
|
|
980
|
+
let res;
|
|
981
|
+
try {
|
|
982
|
+
res = await session.api
|
|
983
|
+
.with(extra.signal)
|
|
984
|
+
.send('DELETE', P.computer(computer_id), {
|
|
985
|
+
query: {
|
|
986
|
+
snapshots: delete_snapshots ? 'delete' : undefined,
|
|
987
|
+
expect: delete_snapshots ? fingerprint : undefined,
|
|
988
|
+
},
|
|
989
|
+
});
|
|
990
|
+
}
|
|
991
|
+
catch (err) {
|
|
992
|
+
// A 404 means the computer is not there, which is the state this call
|
|
993
|
+
// was asking for. The platform answers it for an id it cannot scope,
|
|
994
|
+
// and this tool is annotated `idempotentHint`, so the retry after a
|
|
995
|
+
// lost 2xx is one a client is invited to make — and it was the one
|
|
996
|
+
// path that reached `send`, threw, and skipped `unbind` entirely,
|
|
997
|
+
// leaving the session driving a machine that no longer exists. That
|
|
998
|
+
// ghost is the whole reason unbind exists.
|
|
999
|
+
//
|
|
1000
|
+
// Unbound BEFORE the rethrow-or-report decision, because it is true
|
|
1001
|
+
// either way: whatever this answers, the caller must not be left
|
|
1002
|
+
// selected on a computer the platform says is gone.
|
|
1003
|
+
if (!(err instanceof NotFoundError))
|
|
1004
|
+
throw err;
|
|
1005
|
+
session.unbind(computer_id);
|
|
1006
|
+
// Reported as a success rather than an error, and deliberately not as
|
|
1007
|
+
// a plain "Deleted": a caller retrying cannot be told its snapshots
|
|
1008
|
+
// were purged when this call is not what purged them, and the count
|
|
1009
|
+
// is a thing only the response that actually did the work carries.
|
|
1010
|
+
//
|
|
1011
|
+
// It does not claim a deletion happened, either. A 404 is equally the
|
|
1012
|
+
// platform's answer for an id that was never on this account — a typo,
|
|
1013
|
+
// or a computer belonging to somebody else — and "deleted" said over
|
|
1014
|
+
// a mistyped id leaves a caller believing a machine is gone while the
|
|
1015
|
+
// real one keeps running and billing. Both readings are named, and the
|
|
1016
|
+
// one call that settles which is the one to make next.
|
|
1017
|
+
return said(`Nothing was deleted: the platform has no computer with the id ${computer_id} on this account. ` +
|
|
1018
|
+
'Either it was already destroyed — if this is a retry, the first call is the one that did it, and ' +
|
|
1019
|
+
'whatever that call did with the snapshots is what happened to them — or the id is not one on this ' +
|
|
1020
|
+
'account, in which case NO computer of yours has been touched and a real one may still be running ' +
|
|
1021
|
+
'under the id you meant. list_computers says which of the two this is. The session is no longer ' +
|
|
1022
|
+
'bound to that id either way.');
|
|
1023
|
+
}
|
|
1024
|
+
session.unbind(computer_id);
|
|
1025
|
+
// A count only when the platform sent one. `?? 0` here would turn "it
|
|
1026
|
+
// did not say" into the affirmative claim that nothing was destroyed —
|
|
1027
|
+
// a false statement about an irreversible act, in the tool that goes to
|
|
1028
|
+
// the most trouble of any here not to misrepresent one.
|
|
1029
|
+
const purged = res?.snapshots_deleted === undefined
|
|
1030
|
+
? 'its snapshots'
|
|
1031
|
+
: `${res.snapshots_deleted} of its snapshot(s)`;
|
|
1032
|
+
return said(delete_snapshots
|
|
1033
|
+
? `Deleted ${computer_id} and ${purged}.`
|
|
1034
|
+
: `Deleted ${computer_id}. Its disk is gone; any snapshots it had remain, as orphans that can be cloned but not restored.`);
|
|
1035
|
+
}));
|
|
1036
|
+
};
|
|
1037
|
+
//# sourceMappingURL=computers.js.map
|