mandala-computer-mcp 0.3.0 → 0.5.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 +462 -37
- package/dist/api.d.ts +12 -0
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +262 -42
- package/dist/api.js.map +1 -1
- package/dist/artifacts.d.ts +63 -0
- package/dist/artifacts.d.ts.map +1 -0
- package/dist/artifacts.js +81 -0
- package/dist/artifacts.js.map +1 -0
- package/dist/cli.d.ts +4 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +35 -21
- package/dist/cli.js.map +1 -1
- package/dist/credentials.d.ts +33 -0
- package/dist/credentials.d.ts.map +1 -0
- package/dist/credentials.js +395 -0
- package/dist/credentials.js.map +1 -0
- package/dist/errors.d.ts +53 -10
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +122 -35
- package/dist/errors.js.map +1 -1
- package/dist/events.d.ts +11 -2
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +213 -95
- package/dist/events.js.map +1 -1
- package/dist/executions.d.ts +43 -0
- package/dist/executions.d.ts.map +1 -0
- package/dist/executions.js +162 -0
- package/dist/executions.js.map +1 -0
- package/dist/format.d.ts +11 -11
- package/dist/format.d.ts.map +1 -1
- package/dist/format.js +159 -16
- package/dist/format.js.map +1 -1
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +4 -0
- package/dist/http.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/limits.d.ts +17 -0
- package/dist/limits.d.ts.map +1 -0
- package/dist/limits.js +17 -0
- package/dist/limits.js.map +1 -0
- package/dist/paths.d.ts +42 -2
- package/dist/paths.d.ts.map +1 -1
- package/dist/paths.js +54 -3
- package/dist/paths.js.map +1 -1
- package/dist/poll.d.ts +5 -1
- package/dist/poll.d.ts.map +1 -1
- package/dist/poll.js +120 -16
- package/dist/poll.js.map +1 -1
- package/dist/results.d.ts +97 -0
- package/dist/results.d.ts.map +1 -0
- package/dist/results.js +263 -0
- package/dist/results.js.map +1 -0
- package/dist/server.d.ts +3 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +61 -9
- package/dist/server.js.map +1 -1
- package/dist/stdio.d.ts +5 -1
- package/dist/stdio.d.ts.map +1 -1
- package/dist/stdio.js +10 -4
- package/dist/stdio.js.map +1 -1
- package/dist/tool-filters.d.ts +38 -0
- package/dist/tool-filters.d.ts.map +1 -0
- package/dist/tool-filters.js +129 -0
- package/dist/tool-filters.js.map +1 -0
- package/dist/tools/account.d.ts +3 -0
- package/dist/tools/account.d.ts.map +1 -0
- package/dist/tools/account.js +122 -0
- package/dist/tools/account.js.map +1 -0
- package/dist/tools/activities.d.ts +3 -0
- package/dist/tools/activities.d.ts.map +1 -0
- package/dist/tools/activities.js +140 -0
- package/dist/tools/activities.js.map +1 -0
- package/dist/tools/agent.d.ts.map +1 -1
- package/dist/tools/agent.js +72 -5
- package/dist/tools/agent.js.map +1 -1
- package/dist/tools/artifacts.d.ts +3 -0
- package/dist/tools/artifacts.d.ts.map +1 -0
- package/dist/tools/artifacts.js +97 -0
- package/dist/tools/artifacts.js.map +1 -0
- package/dist/tools/chat.d.ts +6 -0
- package/dist/tools/chat.d.ts.map +1 -0
- package/dist/tools/chat.js +192 -0
- package/dist/tools/chat.js.map +1 -0
- package/dist/tools/computers.d.ts.map +1 -1
- package/dist/tools/computers.js +300 -247
- package/dist/tools/computers.js.map +1 -1
- package/dist/tools/directory.d.ts +13 -0
- package/dist/tools/directory.d.ts.map +1 -0
- package/dist/tools/directory.js +88 -0
- package/dist/tools/directory.js.map +1 -0
- package/dist/tools/events.d.ts.map +1 -1
- package/dist/tools/events.js +66 -22
- package/dist/tools/events.js.map +1 -1
- package/dist/tools/executions.d.ts +3 -0
- package/dist/tools/executions.d.ts.map +1 -0
- package/dist/tools/executions.js +87 -0
- package/dist/tools/executions.js.map +1 -0
- package/dist/tools/guest.d.ts.map +1 -1
- package/dist/tools/guest.js +57 -17
- package/dist/tools/guest.js.map +1 -1
- package/dist/tools/input.d.ts.map +1 -1
- package/dist/tools/input.js +63 -5
- package/dist/tools/input.js.map +1 -1
- package/dist/tools/results.d.ts +30 -0
- package/dist/tools/results.d.ts.map +1 -0
- package/dist/tools/results.js +106 -0
- package/dist/tools/results.js.map +1 -0
- package/dist/tools/secrets.d.ts +58 -0
- package/dist/tools/secrets.d.ts.map +1 -0
- package/dist/tools/secrets.js +204 -0
- package/dist/tools/secrets.js.map +1 -0
- package/dist/tools/signals.d.ts +3 -0
- package/dist/tools/signals.d.ts.map +1 -0
- package/dist/tools/signals.js +116 -0
- package/dist/tools/signals.js.map +1 -0
- package/dist/tools/snapshots.d.ts.map +1 -1
- package/dist/tools/snapshots.js +213 -167
- package/dist/tools/snapshots.js.map +1 -1
- package/dist/tools/ssh.d.ts +3 -0
- package/dist/tools/ssh.d.ts.map +1 -0
- package/dist/tools/ssh.js +186 -0
- package/dist/tools/ssh.js.map +1 -0
- package/dist/tools/templates.d.ts.map +1 -1
- package/dist/tools/templates.js +9 -13
- package/dist/tools/templates.js.map +1 -1
- package/package.json +1 -1
package/dist/tools/computers.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { CancelledError, ConflictError, isTransientForPoll, MoveRequiredError, NotFoundError, } from '../errors.js';
|
|
3
|
-
import { describe, guarded, incompleteWarning, json, nothingAdmitted, refused, said, unwrapComputer, withoutCredentials, } from '../format.js';
|
|
3
|
+
import { describe, guarded, incompleteWarning, json, nothingAdmitted, refused, said, unwrapComputer, withErrorMetadata, withoutCredentials, } from '../format.js';
|
|
4
4
|
import * as P from '../paths.js';
|
|
5
5
|
import { heartbeat, POLL_MS, pollDelay, sleep } from '../poll.js';
|
|
6
|
+
import { FILES_DIR, secretBindingsSchema } from './secrets.js';
|
|
6
7
|
const idArg = {
|
|
7
8
|
computer_id: z
|
|
8
9
|
.string()
|
|
@@ -46,7 +47,11 @@ const movesOf = (body) => {
|
|
|
46
47
|
// TypeError for a confident wrong answer, which is the worse of the two.
|
|
47
48
|
// An unreadable ENVELOPE is still `undefined`: a different fact, a different
|
|
48
49
|
// answer, and the one the callers already handle.
|
|
49
|
-
const moves = list.filter((row) => row !== null &&
|
|
50
|
+
const moves = list.filter((row) => row !== null &&
|
|
51
|
+
typeof row === 'object' &&
|
|
52
|
+
!Array.isArray(row) &&
|
|
53
|
+
typeof row.computer_id === 'string' &&
|
|
54
|
+
Boolean(row.computer_id.trim()));
|
|
50
55
|
return { moves, dropped: list.length - moves.length };
|
|
51
56
|
};
|
|
52
57
|
/**
|
|
@@ -83,8 +88,26 @@ const moveOffered = (id, err) => refused(err.movePossible
|
|
|
83
88
|
const moveShape = (m) => [m.cpu && `${m.cpu} vCPU`, m.ram_mb && `${m.ram_mb} MB RAM`, m.disk_gb && `${m.disk_gb} GB disk`]
|
|
84
89
|
.filter(Boolean)
|
|
85
90
|
.join(' · ') || 'no change';
|
|
86
|
-
/**
|
|
87
|
-
|
|
91
|
+
/**
|
|
92
|
+
* One row of list_moves, and the three things `live` can say rather than two.
|
|
93
|
+
*
|
|
94
|
+
* `live` is the flag this tool's own description tells a model to poll on, so
|
|
95
|
+
* the one answer it must never give is a confident "not running" about a row
|
|
96
|
+
* that did not say. A truthy test gave exactly that: a row whose flag was absent
|
|
97
|
+
* or was not a boolean read as finished, and a caller polling for the move to end
|
|
98
|
+
* stops there — while a disk is still being copied between two hosts, and while
|
|
99
|
+
* the platform goes on refusing the next move on this account because this one
|
|
100
|
+
* has not finished. The watch loop already treats an unreadable flag as a poll it
|
|
101
|
+
* could not get an answer to; this is the same fact, said in a listing.
|
|
102
|
+
*/
|
|
103
|
+
const moveLine = (m) => {
|
|
104
|
+
const liveness = m.live === true
|
|
105
|
+
? ' (running)'
|
|
106
|
+
: m.live === false
|
|
107
|
+
? ''
|
|
108
|
+
: ' (LIVENESS UNKNOWN — this row did not say whether the move is still running, so do not read it as finished)';
|
|
109
|
+
return `${m.computer_id}: ${m.state}${liveness} — ${moveShape(m)}${m.detail ? ` — ${m.detail}` : ''}`;
|
|
110
|
+
};
|
|
88
111
|
/**
|
|
89
112
|
* The sentence in front of a usage report, and the reason this tool does not
|
|
90
113
|
* simply hand back the JSON the way list_sizes does.
|
|
@@ -568,89 +591,100 @@ export const registerComputers = (server, session, opts) => {
|
|
|
568
591
|
// tool that says nothing for minutes is one a client cancels — see
|
|
569
592
|
// heartbeat, and OPL-4579 for the wait that proved it.
|
|
570
593
|
const beat = heartbeat(extra, server.server);
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
`
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
594
|
+
try {
|
|
595
|
+
await beat(`Moving ${id} — the platform has accepted it.`);
|
|
596
|
+
let last = started;
|
|
597
|
+
let blocked;
|
|
598
|
+
while (!untilDeadline.aborted) {
|
|
599
|
+
if (extra.signal?.aborted) {
|
|
600
|
+
return refused(`Cancelled while waiting for ${id} to move. THE MOVE IS STILL RUNNING — nothing was stopped, ` +
|
|
601
|
+
`because a disk crossing between two hosts cannot be called back. list_moves says where it ` +
|
|
602
|
+
`got to.`, last);
|
|
603
|
+
}
|
|
604
|
+
let table;
|
|
605
|
+
let raw;
|
|
606
|
+
try {
|
|
607
|
+
raw = await api.json('GET', P.MOVES);
|
|
608
|
+
table = movesOf(raw);
|
|
609
|
+
}
|
|
610
|
+
catch (err) {
|
|
611
|
+
if (extra.signal?.aborted)
|
|
612
|
+
continue;
|
|
613
|
+
if (err instanceof CancelledError) {
|
|
614
|
+
if (untilDeadline.aborted)
|
|
615
|
+
break;
|
|
616
|
+
blocked = err.message;
|
|
617
|
+
await beat(`Moving ${id} — the platform could not be asked: ${blocked}`);
|
|
618
|
+
await sleep(POLL_MS, signal);
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
// The poll reads the control plane's own table, so the statuses
|
|
622
|
+
// worth riding out are the ones that mean "ask again" — exactly
|
|
623
|
+
// wait_for_computer's list. Anything else is a real failure, and
|
|
624
|
+
// the move is still running behind it, which a thrown error's
|
|
625
|
+
// handler has no way to say. So it is said here.
|
|
626
|
+
if (!isTransientForPoll(err)) {
|
|
627
|
+
return refused(`${err instanceof Error ? err.message : String(err)}\n\nTHE MOVE IS STILL RUNNING — this ` +
|
|
628
|
+
`was the poll failing, not the move. list_moves says where it got to.`, last);
|
|
629
|
+
}
|
|
630
|
+
blocked = err instanceof Error ? err.message : String(err);
|
|
631
|
+
await beat(`Moving ${id} — the platform could not be asked: ${blocked}`);
|
|
632
|
+
await sleep(pollDelay(err), signal);
|
|
588
633
|
continue;
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
634
|
+
}
|
|
635
|
+
// A table that is not a list is the platform failing to answer, not
|
|
636
|
+
// an answer that the move is gone. It rides out the same way a poll
|
|
637
|
+
// that threw does, so the deadline's sentence says the platform could
|
|
638
|
+
// not be asked rather than claiming a deletion nothing established.
|
|
639
|
+
if (!table) {
|
|
640
|
+
blocked = `GET /moves answered with ${shapeOf(raw?.moves)}, not a list of moves`;
|
|
593
641
|
await beat(`Moving ${id} — the platform could not be asked: ${blocked}`);
|
|
594
642
|
await sleep(POLL_MS, signal);
|
|
595
643
|
continue;
|
|
596
644
|
}
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
//
|
|
600
|
-
//
|
|
601
|
-
//
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
645
|
+
blocked = undefined;
|
|
646
|
+
const mine = table.moves.find((m) => m.computer_id === id);
|
|
647
|
+
// A move that is no longer listed is one the platform reaped, and it
|
|
648
|
+
// reaps for one reason: the computer was deleted. Not a state to keep
|
|
649
|
+
// polling for.
|
|
650
|
+
//
|
|
651
|
+
// Unless a row could not be READ, in which case absence is not
|
|
652
|
+
// established: the move may be sitting in the row this poll had to
|
|
653
|
+
// drop. That is a poll that could not be answered rather than an
|
|
654
|
+
// answer, so it rides out exactly as a transient failure does, and
|
|
655
|
+
// the deadline's sentence says the platform could not be asked
|
|
656
|
+
// instead of claiming a deletion nothing showed.
|
|
657
|
+
if (!mine && table.dropped) {
|
|
658
|
+
blocked = `GET /moves answered with ${table.dropped} unreadable row(s), so this move may be among them`;
|
|
659
|
+
await beat(`Moving ${id} — the platform could not be asked: ${blocked}`);
|
|
660
|
+
await sleep(POLL_MS, signal);
|
|
661
|
+
continue;
|
|
605
662
|
}
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
}
|
|
621
|
-
blocked = undefined;
|
|
622
|
-
const mine = table.moves.find((m) => m.computer_id === id);
|
|
623
|
-
// A move that is no longer listed is one the platform reaped, and it
|
|
624
|
-
// reaps for one reason: the computer was deleted. Not a state to keep
|
|
625
|
-
// polling for.
|
|
626
|
-
//
|
|
627
|
-
// Unless a row could not be READ, in which case absence is not
|
|
628
|
-
// established: the move may be sitting in the row this poll had to
|
|
629
|
-
// drop. That is a poll that could not be answered rather than an
|
|
630
|
-
// answer, so it rides out exactly as a transient failure does, and
|
|
631
|
-
// the deadline's sentence says the platform could not be asked
|
|
632
|
-
// instead of claiming a deletion nothing showed.
|
|
633
|
-
if (!mine && table.dropped) {
|
|
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}`);
|
|
663
|
+
if (!mine) {
|
|
664
|
+
return refused(`The move of ${id} is no longer listed. That happens when the computer is deleted — check ` +
|
|
665
|
+
`list_computers.`, last);
|
|
666
|
+
}
|
|
667
|
+
if (typeof mine.live !== 'boolean') {
|
|
668
|
+
blocked = `GET /moves answered with an unreadable live flag for ${id}`;
|
|
669
|
+
await beat(`Moving ${id} — the platform could not be asked: ${blocked}`);
|
|
670
|
+
await sleep(POLL_MS, signal);
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
last = mine;
|
|
674
|
+
if (!mine.live)
|
|
675
|
+
return finishedMove(id, mine);
|
|
676
|
+
await beat(`Moving ${id} — ${mine.state}${mine.detail ? `: ${mine.detail}` : ''}`);
|
|
636
677
|
await sleep(POLL_MS, signal);
|
|
637
|
-
continue;
|
|
638
678
|
}
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
await beat(
|
|
647
|
-
await sleep(POLL_MS, signal);
|
|
679
|
+
return refused(blocked
|
|
680
|
+
? `Gave up watching after ${timeout_s}s; the platform could not be asked — the last attempt said: ` +
|
|
681
|
+
`${blocked}. THE MOVE IS STILL RUNNING. list_moves says where it got to.`
|
|
682
|
+
: `Still moving after ${timeout_s}s, which a large disk takes. THE MOVE IS STILL RUNNING and ` +
|
|
683
|
+
`nothing was changed by giving up on the wait. list_moves says where it got to.`, last);
|
|
684
|
+
}
|
|
685
|
+
finally {
|
|
686
|
+
await beat.stop();
|
|
648
687
|
}
|
|
649
|
-
return refused(blocked
|
|
650
|
-
? `Gave up watching after ${timeout_s}s; the platform could not be asked — the last attempt said: ` +
|
|
651
|
-
`${blocked}. THE MOVE IS STILL RUNNING. list_moves says where it got to.`
|
|
652
|
-
: `Still moving after ${timeout_s}s, which a large disk takes. THE MOVE IS STILL RUNNING and ` +
|
|
653
|
-
`nothing was changed by giving up on the wait. list_moves says where it got to.`, last);
|
|
654
688
|
}));
|
|
655
689
|
server.registerTool('list_moves', {
|
|
656
690
|
title: 'List moves in progress and their outcomes',
|
|
@@ -745,191 +779,200 @@ export const registerComputers = (server, session, opts) => {
|
|
|
745
779
|
: untilDeadline;
|
|
746
780
|
const api = session.api.with(signal);
|
|
747
781
|
const beat = heartbeat(extra, server.server);
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
//
|
|
756
|
-
//
|
|
757
|
-
//
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
// out would abort the one tool whose entire job is to keep asking.
|
|
766
|
-
let c;
|
|
767
|
-
try {
|
|
768
|
-
c = unwrapComputer(await api.json('GET', P.computer(id)));
|
|
769
|
-
}
|
|
770
|
-
catch (err) {
|
|
771
|
-
// The caller's own signal is checked first, and by identity rather
|
|
772
|
-
// than by reading the error: the request is now bound to two
|
|
773
|
-
// deadlines, and only one of them means anybody stopped caring.
|
|
782
|
+
try {
|
|
783
|
+
let last = 'unknown';
|
|
784
|
+
// Open the progress channel before the first status read. That read
|
|
785
|
+
// has the same network and response deadlines as every later poll,
|
|
786
|
+
// so it can be the whole wait rather than a quick prelude to it.
|
|
787
|
+
await beat(`Waiting for ${id} — asking the platform for its status.`);
|
|
788
|
+
// Kept so the give-up message can name it. A hypervisor that was
|
|
789
|
+
// unreachable for the whole window is the single most useful thing to
|
|
790
|
+
// report, and swallowing every transient would end the wait saying only
|
|
791
|
+
// that the status was never seen.
|
|
792
|
+
let blocked;
|
|
793
|
+
while (!untilDeadline.aborted) {
|
|
794
|
+
// The caller giving up ends the wait. The signal aborts the request
|
|
795
|
+
// in flight, but nothing about an aborted request stops the next
|
|
796
|
+
// iteration from starting one — so a cancelled call would go on
|
|
797
|
+
// polling the platform for the rest of its timeout_s, up to fifteen
|
|
798
|
+
// minutes of traffic on behalf of nobody.
|
|
774
799
|
if (extra.signal?.aborted)
|
|
775
800
|
return cancelled(id, last);
|
|
776
|
-
//
|
|
777
|
-
//
|
|
778
|
-
//
|
|
779
|
-
//
|
|
780
|
-
|
|
781
|
-
if (untilDeadline.aborted) {
|
|
782
|
-
blocked = `the status read was still in flight when the ${timeout_s}s deadline arrived`;
|
|
783
|
-
break;
|
|
784
|
-
}
|
|
785
|
-
blocked = err.message;
|
|
786
|
-
await beat(`Waiting for ${id} — the platform could not be asked: ${blocked}`);
|
|
787
|
-
await sleep(POLL_MS, signal);
|
|
788
|
-
continue;
|
|
789
|
-
}
|
|
790
|
-
if (!isTransientForPoll(err))
|
|
791
|
-
throw err;
|
|
792
|
-
blocked = err instanceof Error ? err.message : String(err);
|
|
793
|
-
await beat(`Waiting for ${id} — the platform could not be asked: ${blocked}`);
|
|
794
|
-
await sleep(pollDelay(err), signal);
|
|
795
|
-
continue;
|
|
796
|
-
}
|
|
797
|
-
blocked = undefined;
|
|
798
|
-
session.noteResolution(id, c.resolution);
|
|
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}.`);
|
|
817
|
-
if (last === 'build-failed') {
|
|
818
|
-
// `refused`, for the reason `cancelled` is: the wait never reached
|
|
819
|
-
// what it was told to wait for, and this one never will. A caller
|
|
820
|
-
// reading `isError` to decide whether to go on would otherwise see
|
|
821
|
-
// a build that failed and a guest that answered as the same result.
|
|
822
|
-
//
|
|
823
|
-
// `build.source` is what the machine was built *from*, not why the
|
|
824
|
-
// build failed — printed bare after "Build failed:" it reads as the
|
|
825
|
-
// reason and names an image instead of a cause. `start_error` is
|
|
826
|
-
// the field that carries a diagnostic, so prefer it and label the
|
|
827
|
-
// source as the source when that is all there is.
|
|
828
|
-
const why = c.start_error
|
|
829
|
-
? `: ${c.start_error}`
|
|
830
|
-
: c.build?.source
|
|
831
|
-
? ` (built from ${c.build.source}) — the platform gave no reason`
|
|
832
|
-
: ' — the platform gave no reason';
|
|
833
|
-
return refused(`Build failed${why}. This does not resolve on its own.`, withoutCredentials(c));
|
|
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
|
-
}
|
|
844
|
-
// Neither of the next two resolves on its own, so spinning on either
|
|
845
|
-
// burns the whole timeout waiting for something nobody is going to do.
|
|
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)) {
|
|
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));
|
|
859
|
-
}
|
|
860
|
-
if (last === 'stopped' && nothingAdmitted(c)) {
|
|
861
|
-
return refused(`${id} is stopped. start_computer boots it.`, withoutCredentials(c));
|
|
862
|
-
}
|
|
863
|
-
if (last === 'running') {
|
|
864
|
-
if (until === 'running')
|
|
865
|
-
return said(`Running: ${describe(c)}`, withoutCredentials(c));
|
|
866
|
-
// "The guest is up" is not a status the platform reports, so it is
|
|
867
|
-
// asked rather than waited for: a trivial exec either answers, or
|
|
868
|
-
// refuses with the 409 that says the agent is not up yet.
|
|
801
|
+
// The status read is exactly as transient-prone as the guest probe
|
|
802
|
+
// below it — a hypervisor that cannot be reached answers 503, which
|
|
803
|
+
// is the ordinary weather of a machine still coming up. Letting that
|
|
804
|
+
// out would abort the one tool whose entire job is to keep asking.
|
|
805
|
+
let c;
|
|
869
806
|
try {
|
|
870
|
-
await api.
|
|
871
|
-
body: P.execBody({ command: 'true', timeout_s: 5 }),
|
|
872
|
-
});
|
|
873
|
-
return said(`Guest is answering: ${describe(c)}`, withoutCredentials(c));
|
|
807
|
+
c = unwrapComputer(await api.json('GET', P.computer(id)));
|
|
874
808
|
}
|
|
875
809
|
catch (err) {
|
|
876
|
-
// The
|
|
877
|
-
//
|
|
878
|
-
//
|
|
879
|
-
// read as a platform outage instead of saying the caller had
|
|
880
|
-
// hung up. Half the loop knew to check the signal and half did
|
|
881
|
-
// not, which is the worse of the two ways to be inconsistent.
|
|
810
|
+
// The caller's own signal is checked first, and by identity rather
|
|
811
|
+
// than by reading the error: the request is now bound to two
|
|
812
|
+
// deadlines, and only one of them means anybody stopped caring.
|
|
882
813
|
if (extra.signal?.aborted)
|
|
883
814
|
return cancelled(id, last);
|
|
815
|
+
// A body stream can also fail without either signal firing (an
|
|
816
|
+
// undici idle timeout is an AbortError). That is a transport
|
|
817
|
+
// failure, not a cancellation, and is retried below as transient.
|
|
818
|
+
// Only the deadline signal proves the wait's own timer arrived.
|
|
884
819
|
if (err instanceof CancelledError) {
|
|
885
820
|
if (untilDeadline.aborted) {
|
|
886
|
-
blocked = `the
|
|
821
|
+
blocked = `the status read was still in flight when the ${timeout_s}s deadline arrived`;
|
|
887
822
|
break;
|
|
888
823
|
}
|
|
889
824
|
blocked = err.message;
|
|
890
|
-
await beat(`Waiting for ${id} —
|
|
825
|
+
await beat(`Waiting for ${id} — the platform could not be asked: ${blocked}`);
|
|
891
826
|
await sleep(POLL_MS, signal);
|
|
892
827
|
continue;
|
|
893
828
|
}
|
|
894
829
|
if (!isTransientForPoll(err))
|
|
895
830
|
throw err;
|
|
896
|
-
|
|
897
|
-
|
|
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}`);
|
|
918
|
-
// The guest probe's own failure decides this turn's interval, for
|
|
919
|
-
// pollDelay's reason. The ordinary path below keeps POLL_MS.
|
|
831
|
+
blocked = err instanceof Error ? err.message : String(err);
|
|
832
|
+
await beat(`Waiting for ${id} — the platform could not be asked: ${blocked}`);
|
|
920
833
|
await sleep(pollDelay(err), signal);
|
|
921
834
|
continue;
|
|
922
835
|
}
|
|
836
|
+
blocked = undefined;
|
|
837
|
+
session.noteResolution(id, c.resolution);
|
|
838
|
+
last = c.status ?? 'unknown';
|
|
839
|
+
// ONE beat per turn, and this is not it when a guest probe is about to
|
|
840
|
+
// run. Beating here and again in the probe's failure branch sent two
|
|
841
|
+
// notifications per poll — and because the two lines DIFFER, each read
|
|
842
|
+
// as news to the throttle and neither was ever held, so the steady
|
|
843
|
+
// state of the commonest long wait was twice the per-poll rate the
|
|
844
|
+
// interval exists to avoid (/code-review).
|
|
845
|
+
//
|
|
846
|
+
// Fixed by making the two say the SAME thing rather than by silencing
|
|
847
|
+
// this one, which was the first attempt and dropped the wrong half of
|
|
848
|
+
// the pair (/code-review again). The probe below is the longest call
|
|
849
|
+
// in the loop — an undici header timeout or a proxy 524 can hold it
|
|
850
|
+
// for minutes — so the beat that must survive is the one IN FRONT of
|
|
851
|
+
// it. The 409 branch repeats this line and the throttle holds it; a
|
|
852
|
+
// probe that fails some other way says so, which is news and goes out.
|
|
853
|
+
await beat(until === 'guest' && last === 'running'
|
|
854
|
+
? `Waiting for ${id} — running; asking the guest.`
|
|
855
|
+
: `Waiting for ${id} — ${last}.`);
|
|
856
|
+
if (last === 'build-failed') {
|
|
857
|
+
// `refused`, for the reason `cancelled` is: the wait never reached
|
|
858
|
+
// what it was told to wait for, and this one never will. A caller
|
|
859
|
+
// reading `isError` to decide whether to go on would otherwise see
|
|
860
|
+
// a build that failed and a guest that answered as the same result.
|
|
861
|
+
//
|
|
862
|
+
// `build.source` is what the machine was built *from*, not why the
|
|
863
|
+
// build failed — printed bare after "Build failed:" it reads as the
|
|
864
|
+
// reason and names an image instead of a cause. `start_error` is
|
|
865
|
+
// the field that carries a diagnostic, so prefer it and label the
|
|
866
|
+
// source as the source when that is all there is.
|
|
867
|
+
const why = c.start_error
|
|
868
|
+
? `: ${c.start_error}`
|
|
869
|
+
: c.build?.source
|
|
870
|
+
? ` (built from ${c.build.source}) — the platform gave no reason`
|
|
871
|
+
: ' — the platform gave no reason';
|
|
872
|
+
return refused(`Build failed${why}. This does not resolve on its own.`, withoutCredentials(c));
|
|
873
|
+
}
|
|
874
|
+
// Its files were partly removed and its disk is gone: the platform
|
|
875
|
+
// refuses to start or use it, and only deleting it again clears it.
|
|
876
|
+
// Waiting spent the whole budget and then reported "last seen
|
|
877
|
+
// half-removed", which is the state the caller passed in (Codex
|
|
878
|
+
// review). Not qualified by the pool: nothing can be admitted for a
|
|
879
|
+
// machine with no disk.
|
|
880
|
+
if (last === 'half-removed') {
|
|
881
|
+
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));
|
|
882
|
+
}
|
|
883
|
+
// Neither of the next two resolves on its own, so spinning on either
|
|
884
|
+
// burns the whole timeout waiting for something nobody is going to do.
|
|
885
|
+
//
|
|
886
|
+
// Unless somebody is. `status` is read from the guest process, so a
|
|
887
|
+
// start that has been ADMITTED reads as `stopped` while it boots and
|
|
888
|
+
// as `suspended` while it resumes — the session record is spent only
|
|
889
|
+
// on the way out of a start that worked. Refusing there tells a model
|
|
890
|
+
// to call start_computer on a computer that is already starting, and
|
|
891
|
+
// the obvious next thing it does is start it a second time.
|
|
892
|
+
//
|
|
893
|
+
// nothingAdmitted is the platform's own word for idle, and it is
|
|
894
|
+
// absent-aware: a host that did not answer has not said nothing is
|
|
895
|
+
// coming, so the wait goes on rather than refusing (OPL-4631).
|
|
896
|
+
if (last === 'suspended' && nothingAdmitted(c)) {
|
|
897
|
+
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));
|
|
898
|
+
}
|
|
899
|
+
if (last === 'stopped' && nothingAdmitted(c)) {
|
|
900
|
+
return refused(`${id} is stopped. start_computer boots it.`, withoutCredentials(c));
|
|
901
|
+
}
|
|
902
|
+
if (last === 'running') {
|
|
903
|
+
if (until === 'running')
|
|
904
|
+
return said(`Running: ${describe(c)}`, withoutCredentials(c));
|
|
905
|
+
// "The guest is up" is not a status the platform reports, so it is
|
|
906
|
+
// asked rather than waited for: a trivial exec either answers, or
|
|
907
|
+
// refuses with the 409 that says the agent is not up yet.
|
|
908
|
+
try {
|
|
909
|
+
await api.send('POST', P.computerAction(id, 'exec'), {
|
|
910
|
+
body: P.execBody({ command: 'true', timeout_s: 5 }),
|
|
911
|
+
});
|
|
912
|
+
return said(`Guest is answering: ${describe(c)}`, withoutCredentials(c));
|
|
913
|
+
}
|
|
914
|
+
catch (err) {
|
|
915
|
+
// The same two deadlines as the status read above, and for the
|
|
916
|
+
// same reason: this catch used to judge the error alone, so a
|
|
917
|
+
// cancellation during the guest probe left the wait throwing what
|
|
918
|
+
// read as a platform outage instead of saying the caller had
|
|
919
|
+
// hung up. Half the loop knew to check the signal and half did
|
|
920
|
+
// not, which is the worse of the two ways to be inconsistent.
|
|
921
|
+
if (extra.signal?.aborted)
|
|
922
|
+
return cancelled(id, last);
|
|
923
|
+
if (err instanceof CancelledError) {
|
|
924
|
+
if (untilDeadline.aborted) {
|
|
925
|
+
blocked = `the guest probe was still in flight when the ${timeout_s}s deadline arrived`;
|
|
926
|
+
break;
|
|
927
|
+
}
|
|
928
|
+
blocked = err.message;
|
|
929
|
+
await beat(`Waiting for ${id} — running; the platform could not be asked: ${blocked}`);
|
|
930
|
+
await sleep(POLL_MS, signal);
|
|
931
|
+
continue;
|
|
932
|
+
}
|
|
933
|
+
if (!isTransientForPoll(err))
|
|
934
|
+
throw err;
|
|
935
|
+
// What actually refused, rather than one sentence for every
|
|
936
|
+
// failure. A 409 IS the guest not being up yet — that is the
|
|
937
|
+
// probe working, and it repeats the line beat in front of the
|
|
938
|
+
// probe so the throttle holds it. A 503 is a hypervisor nobody
|
|
939
|
+
// can reach and a transport abort is neither: saying "the guest
|
|
940
|
+
// is not answering" over those tells the person watching the log
|
|
941
|
+
// the one thing this channel exists to get right.
|
|
942
|
+
//
|
|
943
|
+
// And it SETS `blocked`, which it never did — not before this
|
|
944
|
+
// change and not after the first version of it. A wait that spent
|
|
945
|
+
// its whole window failing the probe on 503s gave up saying "was
|
|
946
|
+
// last seen running" and named nothing, while the progress
|
|
947
|
+
// channel had been reporting the hypervisor the entire time: the
|
|
948
|
+
// same contradiction this comment set out to remove, pointed the
|
|
949
|
+
// other way (/code-review). Deliberately not for the 409, where
|
|
950
|
+
// the platform did answer and `blocked` would be a lie about it.
|
|
951
|
+
if (!(err instanceof ConflictError)) {
|
|
952
|
+
blocked = err instanceof Error ? err.message : String(err);
|
|
953
|
+
}
|
|
954
|
+
await beat(err instanceof ConflictError
|
|
955
|
+
? `Waiting for ${id} — running; asking the guest.`
|
|
956
|
+
: `Waiting for ${id} — running; the platform could not be asked: ${blocked}`);
|
|
957
|
+
// The guest probe's own failure decides this turn's interval, for
|
|
958
|
+
// pollDelay's reason. The ordinary path below keeps POLL_MS.
|
|
959
|
+
await sleep(pollDelay(err), signal);
|
|
960
|
+
continue;
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
await sleep(POLL_MS, signal);
|
|
923
964
|
}
|
|
924
|
-
|
|
965
|
+
// Also a refusal: the deadline passed without the condition being met,
|
|
966
|
+
// which is the same shape of answer as a cancellation and not the same
|
|
967
|
+
// as success. The message still says to call again, because the state
|
|
968
|
+
// it was waiting on may yet arrive.
|
|
969
|
+
return refused(blocked
|
|
970
|
+
? `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.`
|
|
971
|
+
: `Gave up after ${timeout_s}s; ${id} was last seen ${last}. Nothing was changed — call again to keep waiting.`);
|
|
972
|
+
}
|
|
973
|
+
finally {
|
|
974
|
+
await beat.stop();
|
|
925
975
|
}
|
|
926
|
-
// Also a refusal: the deadline passed without the condition being met,
|
|
927
|
-
// which is the same shape of answer as a cancellation and not the same
|
|
928
|
-
// as success. The message still says to call again, because the state
|
|
929
|
-
// it was waiting on may yet arrive.
|
|
930
|
-
return refused(blocked
|
|
931
|
-
? `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.`
|
|
932
|
-
: `Gave up after ${timeout_s}s; ${id} was last seen ${last}. Nothing was changed — call again to keep waiting.`);
|
|
933
976
|
}));
|
|
934
977
|
server.registerTool('get_desktop_url', {
|
|
935
978
|
title: 'Get a link to watch the desktop',
|
|
@@ -1117,6 +1160,9 @@ export const registerComputers = (server, session, opts) => {
|
|
|
1117
1160
|
.optional()
|
|
1118
1161
|
.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.'),
|
|
1119
1162
|
start: z.boolean().optional().describe('Boot it immediately. True by default.'),
|
|
1163
|
+
secrets: secretBindingsSchema(false)
|
|
1164
|
+
.optional()
|
|
1165
|
+
.describe(`Secrets from the account to deliver into the desktop session each time the computer starts, each as an environment variable (\`env\`) or as a file under ${FILES_DIR} (\`file\`). Only ids and names are sent — never a value. Linux only, and only on a template whose image can receive them. get_computer_secrets and set_computer_secrets read and change them later.`),
|
|
1120
1166
|
},
|
|
1121
1167
|
annotations: { destructiveHint: false, openWorldHint: true },
|
|
1122
1168
|
}, (args, extra) => guarded(async () => {
|
|
@@ -1152,12 +1198,19 @@ export const registerComputers = (server, session, opts) => {
|
|
|
1152
1198
|
: !originalSupportsContinuation
|
|
1153
1199
|
? '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
1200
|
: '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
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1201
|
+
const refusal = withErrorMetadata(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.`), error);
|
|
1202
|
+
return {
|
|
1203
|
+
...refusal,
|
|
1204
|
+
content: [
|
|
1205
|
+
...refusal.content,
|
|
1206
|
+
...said('Template preparation:', {
|
|
1207
|
+
code: body.code,
|
|
1208
|
+
template_transfer: body.template_transfer,
|
|
1209
|
+
preparation,
|
|
1210
|
+
retry_after_ms: error.retryAfterMs,
|
|
1211
|
+
}).content,
|
|
1212
|
+
],
|
|
1213
|
+
};
|
|
1161
1214
|
}
|
|
1162
1215
|
throw error;
|
|
1163
1216
|
}
|