@frockbot/plugin-computer 0.3.4 → 0.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +12 -12
- package/src/agent.test.ts +31 -1
- package/src/agent.ts +40 -2
- package/src/backend.test.ts +1 -1
- package/src/bot.test.ts +104 -1
- package/src/bot.ts +72 -24
- package/src/client/ComputerCard.test.ts +15 -2
- package/src/client/ComputerCard.vue +16 -7
- package/src/client/ComputerViewerOverlay.vue +15 -8
- package/src/client/application.test.ts +40 -3
- package/src/client/application.ts +32 -5
- package/src/client/state-machine.ts +2 -2
- package/src/process-records.test.ts +47 -0
- package/src/process-store.ts +59 -4
- package/src/processes.test.ts +24 -1
- package/src/protocol.ts +14 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/plugin-computer",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.6",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -29,21 +29,21 @@
|
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
31
|
"@cordisjs/client": "0.8.2",
|
|
32
|
-
"@frockbot/client-core": "0.3.
|
|
33
|
-
"@frockbot/client-ui": "0.3.
|
|
34
|
-
"@frockbot/computer-core": "0.3.
|
|
35
|
-
"@frockbot/computer-host-runtime": "0.3.
|
|
36
|
-
"@frockbot/kernel-agent-loop": "0.3.
|
|
37
|
-
"@frockbot/kernel-contracts": "0.3.
|
|
38
|
-
"@frockbot/plugin-prompt": "0.3.
|
|
39
|
-
"@frockbot/plugin-shell": "0.3.
|
|
40
|
-
"@frockbot/plugin-tools": "0.3.
|
|
32
|
+
"@frockbot/client-core": "0.3.6",
|
|
33
|
+
"@frockbot/client-ui": "0.3.6",
|
|
34
|
+
"@frockbot/computer-core": "0.3.6",
|
|
35
|
+
"@frockbot/computer-host-runtime": "0.3.6",
|
|
36
|
+
"@frockbot/kernel-agent-loop": "0.3.6",
|
|
37
|
+
"@frockbot/kernel-contracts": "0.3.6",
|
|
38
|
+
"@frockbot/plugin-prompt": "0.3.6",
|
|
39
|
+
"@frockbot/plugin-shell": "0.3.6",
|
|
40
|
+
"@frockbot/plugin-tools": "0.3.6",
|
|
41
41
|
"cordis": "4.0.0-rc.8",
|
|
42
42
|
"vue": "3.5.41"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
|
-
"@frockbot/plugin-models": "0.3.
|
|
46
|
-
"@frockbot/plugin-testkit": "0.3.
|
|
45
|
+
"@frockbot/plugin-models": "0.3.6",
|
|
46
|
+
"@frockbot/plugin-testkit": "0.3.6",
|
|
47
47
|
"@types/bun": "1.4.0",
|
|
48
48
|
"@types/node": "26.2.0",
|
|
49
49
|
"@vitejs/plugin-vue": "6.0.8",
|
package/src/agent.test.ts
CHANGED
|
@@ -256,12 +256,42 @@ describe("computer agent contribution", () => {
|
|
|
256
256
|
await expect(
|
|
257
257
|
execute(harness, "computer_exec", { command: "pwd" }),
|
|
258
258
|
).resolves.toEqual({
|
|
259
|
-
content: "
|
|
259
|
+
content: "held by human:session-1; do not retry this Turn",
|
|
260
260
|
isError: true,
|
|
261
261
|
});
|
|
262
262
|
await harness.dispose();
|
|
263
263
|
});
|
|
264
264
|
|
|
265
|
+
test("an unconfigured deployment offers no Computer tool and no Computer prompt", async () => {
|
|
266
|
+
const harness = await createPluginHarness([
|
|
267
|
+
ComputerRegistry,
|
|
268
|
+
ToolRegistry,
|
|
269
|
+
SystemPromptRegistry,
|
|
270
|
+
SessionStore,
|
|
271
|
+
]);
|
|
272
|
+
await harness.mount(
|
|
273
|
+
createComputerAgentPlugin({
|
|
274
|
+
userId: "user-1",
|
|
275
|
+
defaultProviderId: "fixture",
|
|
276
|
+
configured: false,
|
|
277
|
+
}),
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
const registered = harness.root.tools.registeredNames?.() ?? [];
|
|
281
|
+
expect(registered.filter((name) => name.startsWith("computer_"))).toEqual(
|
|
282
|
+
[],
|
|
283
|
+
);
|
|
284
|
+
const prompt = await harness.root.systemPrompt.assemble({
|
|
285
|
+
sessionId: "session-1",
|
|
286
|
+
provider: "fixture",
|
|
287
|
+
model: "fixture",
|
|
288
|
+
turnType: "chat",
|
|
289
|
+
});
|
|
290
|
+
expect(prompt.text).not.toContain("Persistent Computer");
|
|
291
|
+
expect(prompt.text).not.toContain("computer_exec");
|
|
292
|
+
await harness.dispose();
|
|
293
|
+
});
|
|
294
|
+
|
|
265
295
|
test("satisfies plugin package conventions", () => {
|
|
266
296
|
expect(verifyPluginPackage({ packageJson, manifest })).toMatchObject({
|
|
267
297
|
name: "@frockbot/plugin-computer",
|
package/src/agent.ts
CHANGED
|
@@ -111,6 +111,16 @@ export interface ComputerWriterIdentityV1 {
|
|
|
111
111
|
export interface ComputerAgentPluginConfig {
|
|
112
112
|
userId: string;
|
|
113
113
|
defaultProviderId: string;
|
|
114
|
+
/**
|
|
115
|
+
* Whether this deployment has a Computer at all.
|
|
116
|
+
*
|
|
117
|
+
* False, and the Package mounts no Computer tool and adds no Computer
|
|
118
|
+
* section to the system prompt: a prompt that promises a persistent Linux
|
|
119
|
+
* Computer where there is none costs the User a Turn of model spend per
|
|
120
|
+
* question and ends in the model guessing at a remedy. Absent means
|
|
121
|
+
* configured, so a host that does not know keeps the tools.
|
|
122
|
+
*/
|
|
123
|
+
configured?: boolean;
|
|
114
124
|
idempotentEffects?: boolean;
|
|
115
125
|
writer?: ComputerWriterIdentityV1;
|
|
116
126
|
/**
|
|
@@ -287,9 +297,13 @@ function decodeBrowser(input: unknown): ComputerBrowserAction | undefined {
|
|
|
287
297
|
function failure(error: unknown): { content: string; isError: true } {
|
|
288
298
|
if (error instanceof ComputerError) {
|
|
289
299
|
if (error.code === "human-control-active") {
|
|
300
|
+
// The holder is named, so a second Bot of the same User — and the User
|
|
301
|
+
// reading the transcript — can tell which session has the desktop.
|
|
302
|
+
const holder = error.message.trim();
|
|
290
303
|
return {
|
|
291
|
-
content:
|
|
292
|
-
|
|
304
|
+
content: holder
|
|
305
|
+
? `${holder}; do not retry this Turn`
|
|
306
|
+
: "The user is controlling this Computer; do not retry this Turn",
|
|
293
307
|
isError: true,
|
|
294
308
|
};
|
|
295
309
|
}
|
|
@@ -534,6 +548,11 @@ export function createComputerAgentPlugin(
|
|
|
534
548
|
}
|
|
535
549
|
|
|
536
550
|
const plugin: Plugin.Function = (ctx) => {
|
|
551
|
+
// A deployment with no Computer offers no Computer tool and no Computer
|
|
552
|
+
// prompt. The alternative — tools that always fail — spends a Turn's model
|
|
553
|
+
// budget discovering what this host already knows, and leaves the model
|
|
554
|
+
// inventing a way for the User to fix it.
|
|
555
|
+
if (config.configured === false) return [];
|
|
537
556
|
// One Computer per User (ADR 0012): the assignment is keyed by the User,
|
|
538
557
|
// and the Bot attaches to it as a tenant.
|
|
539
558
|
const identity = { userId };
|
|
@@ -783,6 +802,11 @@ export function createComputerAgentPlugin(
|
|
|
783
802
|
};
|
|
784
803
|
}
|
|
785
804
|
const store = processes;
|
|
805
|
+
// The intent this call wrote, until the launch that follows it settles.
|
|
806
|
+
// Left as `starting` by a launch that threw, it is a record nothing can
|
|
807
|
+
// ever answer for and nothing can forget — a failing Computer would
|
|
808
|
+
// spend the Bot's whole 100-record budget having run nothing at all.
|
|
809
|
+
let unsettled: ComputerProcessRecordV1 | undefined;
|
|
786
810
|
try {
|
|
787
811
|
return await useComputer(
|
|
788
812
|
await open(context.botId, context.sessionId, context.signal),
|
|
@@ -812,6 +836,7 @@ export function createComputerAgentPlugin(
|
|
|
812
836
|
logPath: "",
|
|
813
837
|
};
|
|
814
838
|
await store.record({ ...intent, cwd: "/", logPath: "/" });
|
|
839
|
+
unsettled = { ...intent, cwd: "/", logPath: "/" };
|
|
815
840
|
const launched = await computer.processes.launch(
|
|
816
841
|
{ processId, command },
|
|
817
842
|
{ signal: context.signal, effectId: context.effectId },
|
|
@@ -825,6 +850,7 @@ export function createComputerAgentPlugin(
|
|
|
825
850
|
pid: launched.pid,
|
|
826
851
|
};
|
|
827
852
|
await store.update(running);
|
|
853
|
+
unsettled = undefined;
|
|
828
854
|
await noteProcess(context.sessionId, turnOf(context), {
|
|
829
855
|
processId,
|
|
830
856
|
action: "launch",
|
|
@@ -845,6 +871,18 @@ export function createComputerAgentPlugin(
|
|
|
845
871
|
},
|
|
846
872
|
);
|
|
847
873
|
} catch (error) {
|
|
874
|
+
if (unsettled) {
|
|
875
|
+
// `unknown`, not deleted: the launch may have started something
|
|
876
|
+
// before it threw, and "recovery can read its outcome or classify it
|
|
877
|
+
// as unknown without repeating it". Terminal, so the record is
|
|
878
|
+
// prunable rather than holding a slot for the life of the Bot.
|
|
879
|
+
try {
|
|
880
|
+
await store.update({ ...unsettled, status: "unknown" });
|
|
881
|
+
} catch {
|
|
882
|
+
// Reconciling the intent is never why a tool call fails; the
|
|
883
|
+
// launch failure below is the answer the model needs.
|
|
884
|
+
}
|
|
885
|
+
}
|
|
848
886
|
if (error instanceof ComputerProcessLimitError) {
|
|
849
887
|
return { content: error.message, isError: true };
|
|
850
888
|
}
|
package/src/backend.test.ts
CHANGED
package/src/bot.test.ts
CHANGED
|
@@ -4,7 +4,11 @@ import type {
|
|
|
4
4
|
ComputerControlLease,
|
|
5
5
|
ComputerHandle,
|
|
6
6
|
} from "@frockbot/computer-core";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
computerBotPathKeyV1,
|
|
9
|
+
COMPUTER_UNCONFIGURED_MESSAGE_V1,
|
|
10
|
+
ComputerError,
|
|
11
|
+
} from "@frockbot/computer-core";
|
|
8
12
|
import {
|
|
9
13
|
COMPUTER_CONNECT_DEFERRAL_MS,
|
|
10
14
|
COMPUTER_CONNECT_START_DELAY_MS,
|
|
@@ -127,6 +131,45 @@ function fakeHandle(options: {
|
|
|
127
131
|
}
|
|
128
132
|
|
|
129
133
|
describe("Computer Bot Durable Object Contribution", () => {
|
|
134
|
+
test("an unconfigured host rejects every command, connect included", async () => {
|
|
135
|
+
const storage = new MemoryStorage();
|
|
136
|
+
const contribution = createComputerBotBackendContribution({
|
|
137
|
+
storage,
|
|
138
|
+
configured: false,
|
|
139
|
+
providerLabel: "Fake Computer",
|
|
140
|
+
openComputer: () => {
|
|
141
|
+
throw new Error("an unconfigured host must not reach a provider");
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
for (const type of [
|
|
146
|
+
"connect",
|
|
147
|
+
"takeControl",
|
|
148
|
+
"releaseControl",
|
|
149
|
+
"runDoctor",
|
|
150
|
+
] as const) {
|
|
151
|
+
const receipt = await contribution.execute("user-1", "scout", {
|
|
152
|
+
version: 1,
|
|
153
|
+
commandId: `command-${type}`,
|
|
154
|
+
botId: "scout",
|
|
155
|
+
type,
|
|
156
|
+
});
|
|
157
|
+
expect(receipt).toMatchObject({
|
|
158
|
+
version: 1,
|
|
159
|
+
type,
|
|
160
|
+
status: "rejected",
|
|
161
|
+
failure: COMPUTER_UNCONFIGURED_MESSAGE_V1,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
// Nothing was admitted, so the projection never claims work is under way.
|
|
165
|
+
expect(await contribution.read("user-1", "scout")).toMatchObject({
|
|
166
|
+
phase: "unconfigured",
|
|
167
|
+
message: COMPUTER_UNCONFIGURED_MESSAGE_V1,
|
|
168
|
+
});
|
|
169
|
+
// The refusal is written for the person reading it.
|
|
170
|
+
expect(COMPUTER_UNCONFIGURED_MESSAGE_V1).not.toContain("SPRITES_TOKEN");
|
|
171
|
+
});
|
|
172
|
+
|
|
130
173
|
test("records provider progress durably and projects its ordered steps", async () => {
|
|
131
174
|
const storage = new MemoryStorage();
|
|
132
175
|
let contribution: ReturnType<typeof createComputerBotBackendContribution>;
|
|
@@ -703,6 +746,66 @@ describe("Computer Bot Durable Object Contribution", () => {
|
|
|
703
746
|
expect(storage.values.has(COMPUTER_CONTROL_RECORD_KEY)).toBe(false);
|
|
704
747
|
});
|
|
705
748
|
|
|
749
|
+
test("a release the Computer refuses still drops the durable lease", async () => {
|
|
750
|
+
const storage = new MemoryStorage();
|
|
751
|
+
const now = new Date("2026-09-02T00:00:00.000Z");
|
|
752
|
+
const host = {
|
|
753
|
+
storage,
|
|
754
|
+
configured: true,
|
|
755
|
+
providerLabel: "Fake Computer",
|
|
756
|
+
now: () => now,
|
|
757
|
+
newId: () => "owner-1",
|
|
758
|
+
openComputer: () =>
|
|
759
|
+
Promise.resolve(
|
|
760
|
+
fakeHandle({
|
|
761
|
+
acquire: (ownerId) =>
|
|
762
|
+
Promise.resolve({
|
|
763
|
+
id: ownerId,
|
|
764
|
+
expiresAt: "2026-09-02T00:01:30.000Z",
|
|
765
|
+
}),
|
|
766
|
+
renew: (lease) => Promise.resolve({ id: lease.id, expiresAt: "" }),
|
|
767
|
+
release: () => Promise.reject(new Error("Sprite is unreachable")),
|
|
768
|
+
}),
|
|
769
|
+
),
|
|
770
|
+
};
|
|
771
|
+
const contribution = createComputerBotBackendContribution(host);
|
|
772
|
+
await contribution.execute(
|
|
773
|
+
"user-1",
|
|
774
|
+
"scout",
|
|
775
|
+
command("takeControl", "take-1"),
|
|
776
|
+
);
|
|
777
|
+
expect(storage.values.has(COMPUTER_CONTROL_RECORD_KEY)).toBe(true);
|
|
778
|
+
|
|
779
|
+
const receipt = await contribution.execute(
|
|
780
|
+
"user-1",
|
|
781
|
+
"scout",
|
|
782
|
+
command("releaseControl", "release-1"),
|
|
783
|
+
);
|
|
784
|
+
|
|
785
|
+
// The failure reaches the User rather than being swallowed...
|
|
786
|
+
expect(receipt).toMatchObject({
|
|
787
|
+
status: "rejected",
|
|
788
|
+
failure: "Sprite is unreachable",
|
|
789
|
+
});
|
|
790
|
+
// ...and the User-wide fence is gone, so no heartbeat can renew it and no
|
|
791
|
+
// other Bot of this User is locked out of the Computer forever.
|
|
792
|
+
expect(storage.values.has(COMPUTER_CONTROL_RECORD_KEY)).toBe(false);
|
|
793
|
+
const projection = await contribution.read("user-1", "scout");
|
|
794
|
+
expect(projection.controlLease).toBeUndefined();
|
|
795
|
+
expect(projection.phase).toBe("error");
|
|
796
|
+
// A heartbeat that arrives after the failed release has nothing to renew.
|
|
797
|
+
await expect(
|
|
798
|
+
contribution.execute(
|
|
799
|
+
"user-1",
|
|
800
|
+
"scout",
|
|
801
|
+
command("refreshControl", "refresh-1"),
|
|
802
|
+
),
|
|
803
|
+
).resolves.toMatchObject({
|
|
804
|
+
status: "rejected",
|
|
805
|
+
failure: "No control lease is active",
|
|
806
|
+
});
|
|
807
|
+
});
|
|
808
|
+
|
|
706
809
|
test("reclaims a stale lease under a new durable owner", async () => {
|
|
707
810
|
const storage = new MemoryStorage();
|
|
708
811
|
let now = new Date("2026-09-02T00:00:00.000Z");
|
package/src/bot.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// the URL is held in this Contribution instance until a read projects it.
|
|
7
7
|
import {
|
|
8
8
|
computerBotPathKeyV1,
|
|
9
|
+
COMPUTER_UNCONFIGURED_MESSAGE_V1,
|
|
9
10
|
ComputerError,
|
|
10
11
|
decodeComputerDoctorReportV1,
|
|
11
12
|
type ComputerConnectionProgressV1,
|
|
@@ -413,6 +414,25 @@ function failureText(error: unknown): string {
|
|
|
413
414
|
);
|
|
414
415
|
}
|
|
415
416
|
|
|
417
|
+
/**
|
|
418
|
+
* One stored record, or nothing when the codec refuses it.
|
|
419
|
+
*
|
|
420
|
+
* A projection degrades; it does not fail. Losing one record's contribution to
|
|
421
|
+
* the card is better than a Bot whose Computer surface throws forever because
|
|
422
|
+
* a single key holds a shape this version does not know.
|
|
423
|
+
*/
|
|
424
|
+
function decoded<T>(
|
|
425
|
+
value: unknown,
|
|
426
|
+
decode: (input: unknown) => T,
|
|
427
|
+
): T | undefined {
|
|
428
|
+
if (value === undefined) return undefined;
|
|
429
|
+
try {
|
|
430
|
+
return decode(value);
|
|
431
|
+
} catch {
|
|
432
|
+
return undefined;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
416
436
|
function isFresh(expiresAt: string, now: Date): boolean {
|
|
417
437
|
return Date.parse(expiresAt) > now.getTime();
|
|
418
438
|
}
|
|
@@ -685,6 +705,21 @@ export class ComputerBotBackendContribution {
|
|
|
685
705
|
"Computer command does not match Bot registration",
|
|
686
706
|
);
|
|
687
707
|
}
|
|
708
|
+
// A host with no Computer rejects every command with the same reason,
|
|
709
|
+
// before admission. `connect` in particular must not answer `accepted`:
|
|
710
|
+
// admitting work that provably cannot be done leaves the User watching a
|
|
711
|
+
// projection that will never move, and there is nothing to reconcile
|
|
712
|
+
// later because nothing was ever started.
|
|
713
|
+
if (!this.host.configured) {
|
|
714
|
+
return {
|
|
715
|
+
version: 1,
|
|
716
|
+
commandId: command.commandId,
|
|
717
|
+
type: command.type,
|
|
718
|
+
status: "rejected",
|
|
719
|
+
completedAt: this.now().toISOString(),
|
|
720
|
+
failure: COMPUTER_UNCONFIGURED_MESSAGE_V1,
|
|
721
|
+
};
|
|
722
|
+
}
|
|
688
723
|
const admitted = await this.admit(userId, command);
|
|
689
724
|
if ("replay" in admitted) return admitted.replay;
|
|
690
725
|
if (command.type === "connect") {
|
|
@@ -1081,17 +1116,31 @@ export class ComputerBotBackendContribution {
|
|
|
1081
1116
|
);
|
|
1082
1117
|
if (currentValue === undefined) return;
|
|
1083
1118
|
const current = decodeStoredComputerControlV1(currentValue);
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1119
|
+
try {
|
|
1120
|
+
await this.withComputer(userId, command, async (computer) => {
|
|
1121
|
+
if (!computer.control) {
|
|
1122
|
+
throw new Error(
|
|
1123
|
+
"The selected Computer does not support human control",
|
|
1124
|
+
);
|
|
1125
|
+
}
|
|
1126
|
+
await computer.control.release(
|
|
1127
|
+
{ id: current.ownerId, expiresAt: current.expiresAt },
|
|
1128
|
+
{ scope: "desktop-gui", ownerId: current.ownerId },
|
|
1129
|
+
{ effectId: `computer:${command.commandId}:release-control` },
|
|
1130
|
+
);
|
|
1131
|
+
});
|
|
1132
|
+
} finally {
|
|
1133
|
+
// The durable record goes whatever the provider answered. It is the
|
|
1134
|
+
// User-wide `desktop-gui` fence: left behind by a release the Computer
|
|
1135
|
+
// could not confirm, it is renewed by the client heartbeat forever, and
|
|
1136
|
+
// every Bot of this User loses the Computer with no way back. The
|
|
1137
|
+
// provider's own lease expires on its side; this one has no expiry a
|
|
1138
|
+
// failing host can be trusted to reach. The failure still reaches the
|
|
1139
|
+
// User — it rejects the receipt and records the `error` phase — but it
|
|
1140
|
+
// never becomes a lease nobody can drop.
|
|
1141
|
+
// Prior art: `releaseDesktopLease` in `@frockbot/plugin-subagents`.
|
|
1142
|
+
await this.host.storage.delete(COMPUTER_CONTROL_RECORD_KEY);
|
|
1143
|
+
}
|
|
1095
1144
|
}
|
|
1096
1145
|
|
|
1097
1146
|
/**
|
|
@@ -1279,16 +1328,15 @@ export class ComputerBotBackendContribution {
|
|
|
1279
1328
|
this.screenshots(userId, botId),
|
|
1280
1329
|
this.doctor(userId, botId),
|
|
1281
1330
|
]);
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
const
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
: decodeStoredProvider(providerValue);
|
|
1331
|
+
// A record the codec refuses is treated as absent, the way `doctor()`
|
|
1332
|
+
// above and `ComputerProcessStore.list` already do. These three decoders
|
|
1333
|
+
// throw on any unexpected shape — a field a future version adds included —
|
|
1334
|
+
// and this is the read behind the card, the overlay and the poll: one bad
|
|
1335
|
+
// value made `GET /api/bots/:id/computer` throw for that Bot forever, with
|
|
1336
|
+
// nothing in the product able to clear the key.
|
|
1337
|
+
const viewer = decoded(viewerValue, decodeStoredViewer);
|
|
1338
|
+
const control = decoded(controlValue, decodeStoredComputerControlV1);
|
|
1339
|
+
const provider = decoded(providerValue, decodeStoredProvider);
|
|
1292
1340
|
const liveViewer =
|
|
1293
1341
|
viewer &&
|
|
1294
1342
|
this.#liveViewer?.id === viewer.id &&
|
|
@@ -1303,7 +1351,7 @@ export class ComputerBotBackendContribution {
|
|
|
1303
1351
|
let message: string;
|
|
1304
1352
|
if (!this.host.configured) {
|
|
1305
1353
|
phase = "unconfigured";
|
|
1306
|
-
message =
|
|
1354
|
+
message = COMPUTER_UNCONFIGURED_MESSAGE_V1;
|
|
1307
1355
|
} else if (provider?.phase === "disconnected") {
|
|
1308
1356
|
phase = "disconnected";
|
|
1309
1357
|
message = provider.message;
|
|
@@ -1325,8 +1373,8 @@ export class ComputerBotBackendContribution {
|
|
|
1325
1373
|
} else {
|
|
1326
1374
|
phase = "idle";
|
|
1327
1375
|
message = viewer
|
|
1328
|
-
? "Reconnect to
|
|
1329
|
-
: "
|
|
1376
|
+
? "Reconnect to pick up where you left off"
|
|
1377
|
+
: "Ready to start";
|
|
1330
1378
|
}
|
|
1331
1379
|
const viewerSession: ComputerViewerSessionViewV1 | undefined = liveViewer
|
|
1332
1380
|
? {
|
|
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import { parse } from "@vue/compiler-sfc";
|
|
4
4
|
import type { ComputerState } from "../shared.js";
|
|
5
|
+
import { COMPUTER_COLD_PROVISION_EXPECTATION } from "../protocol.js";
|
|
5
6
|
import {
|
|
6
7
|
createComputerViewerActions,
|
|
7
8
|
decodeComputerViewerFrameMessageV1,
|
|
@@ -111,7 +112,7 @@ describe("Computer viewer", () => {
|
|
|
111
112
|
expect(template).toContain('@click="actions.requestTakeControl"');
|
|
112
113
|
expect(template).toContain('role="alertdialog"');
|
|
113
114
|
expect(template).toContain(
|
|
114
|
-
"The Bot
|
|
115
|
+
"The Bot won't touch this desktop until you release control.",
|
|
115
116
|
);
|
|
116
117
|
});
|
|
117
118
|
|
|
@@ -121,7 +122,19 @@ describe("Computer viewer", () => {
|
|
|
121
122
|
const template = parsed.descriptor.template?.content ?? "";
|
|
122
123
|
|
|
123
124
|
expect(cardSource).toContain("Setting up your computer for the first time");
|
|
124
|
-
|
|
125
|
+
// An unconfigured card is not a button — clicking it opened a full-screen
|
|
126
|
+
// modal that repeated the sentence already on the card — and no card
|
|
127
|
+
// renders until a Bot exists to have a Computer.
|
|
128
|
+
expect(cardSource).toContain(':disabled="busy || unconfigured"');
|
|
129
|
+
expect(cardSource).toContain('<section v-if="hasBot"');
|
|
130
|
+
// The viewer header names the Bot, not its slug and not the Computer
|
|
131
|
+
// vendor: both are architecture leaking onto the User's screen.
|
|
132
|
+
expect(overlaySource).toContain("{{ botName }}");
|
|
133
|
+
expect(overlaySource).not.toContain("state.providerLabel");
|
|
134
|
+
expect(COMPUTER_COLD_PROVISION_EXPECTATION).toBe(
|
|
135
|
+
"This usually takes 2-3 minutes",
|
|
136
|
+
);
|
|
137
|
+
expect(cardSource).toContain("COMPUTER_COLD_PROVISION_EXPECTATION");
|
|
125
138
|
expect(cardSource).toContain("Updating your computer");
|
|
126
139
|
expect(template).toContain('role="progressbar"');
|
|
127
140
|
expect(template).toContain(':aria-valuenow="progressValueNow"');
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
import { useRpc } from "@cordisjs/client";
|
|
3
3
|
import { UiIcon } from "@frockbot/client-ui";
|
|
4
4
|
import { computed, inject, ref } from "vue";
|
|
5
|
+
import { frockBotWebDataKey } from "@frockbot/plugin-shell/shared";
|
|
5
6
|
import { computerKey, type ComputerState } from "../shared.ts";
|
|
7
|
+
import { COMPUTER_COLD_PROVISION_EXPECTATION } from "../protocol.ts";
|
|
6
8
|
import {
|
|
7
9
|
computerProgressElapsedMs,
|
|
8
10
|
computerProgressFrame,
|
|
@@ -12,7 +14,16 @@ import {
|
|
|
12
14
|
const computer = inject(computerKey) ?? useRpc<ComputerState>();
|
|
13
15
|
const state = computed(() => computer.value);
|
|
14
16
|
const busy = ref(false);
|
|
17
|
+
// No Bot, no Computer card. The client machine seeds a placeholder projection
|
|
18
|
+
// before any Bot exists, and rendering it told a brand-new User about the
|
|
19
|
+
// Computer of a Bot they had not created yet.
|
|
20
|
+
const shell = inject(frockBotWebDataKey, undefined);
|
|
21
|
+
const hasBot = computed(() => Boolean(shell?.value.activeBotId));
|
|
15
22
|
const screenshot = computed(() => state.value.screenshots?.[0]);
|
|
23
|
+
// A card that says there is no Computer opens nothing: a full-screen modal
|
|
24
|
+
// repeating the same sentence is a click that costs the User a step and
|
|
25
|
+
// answers nothing.
|
|
26
|
+
const unconfigured = computed(() => state.value.phase === "unconfigured");
|
|
16
27
|
const opening = computed(
|
|
17
28
|
() =>
|
|
18
29
|
state.value.phase === "provisioning" || state.value.phase === "updating",
|
|
@@ -32,7 +43,7 @@ const openingHeading = computed(() => {
|
|
|
32
43
|
});
|
|
33
44
|
const setupExpectation = computed(() =>
|
|
34
45
|
progressRunKind.value === "cold-provision"
|
|
35
|
-
?
|
|
46
|
+
? COMPUTER_COLD_PROVISION_EXPECTATION
|
|
36
47
|
: undefined,
|
|
37
48
|
);
|
|
38
49
|
const progressPhaseLabel = computed(
|
|
@@ -92,11 +103,11 @@ async function open(): Promise<void> {
|
|
|
92
103
|
</script>
|
|
93
104
|
|
|
94
105
|
<template>
|
|
95
|
-
<section class="computer-card">
|
|
106
|
+
<section v-if="hasBot" class="computer-card">
|
|
96
107
|
<button
|
|
97
108
|
type="button"
|
|
98
109
|
class="computer-screen computer-screen-thumbnail"
|
|
99
|
-
:disabled="busy"
|
|
110
|
+
:disabled="busy || unconfigured"
|
|
100
111
|
aria-label="Open computer in full window"
|
|
101
112
|
@click="open"
|
|
102
113
|
>
|
|
@@ -137,13 +148,11 @@ async function open(): Promise<void> {
|
|
|
137
148
|
</template>
|
|
138
149
|
<template v-else>
|
|
139
150
|
<UiIcon name="sparkle" size="lg" />
|
|
140
|
-
<strong v-if="state.phase === 'unconfigured'"
|
|
141
|
-
>Computer not configured</strong
|
|
142
|
-
>
|
|
151
|
+
<strong v-if="state.phase === 'unconfigured'">No computer</strong>
|
|
143
152
|
<strong v-else-if="state.phase === 'disconnected'"
|
|
144
153
|
>Viewer disconnected</strong
|
|
145
154
|
>
|
|
146
|
-
<strong v-else>
|
|
155
|
+
<strong v-else>Computer</strong>
|
|
147
156
|
<span class="computer-placeholder-message">{{ state.message }}</span>
|
|
148
157
|
</template>
|
|
149
158
|
</span>
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
ref,
|
|
11
11
|
watch,
|
|
12
12
|
} from "vue";
|
|
13
|
+
import { frockBotWebDataKey } from "@frockbot/plugin-shell/shared";
|
|
13
14
|
import { computerKey, type ComputerState } from "../shared.ts";
|
|
14
15
|
import { dialogFocusWrapTarget } from "./dialog-focus.ts";
|
|
15
16
|
import { computerProgressFrame, computerProgressRunKind } from "./progress.ts";
|
|
@@ -23,11 +24,17 @@ import {
|
|
|
23
24
|
const computer = inject(computerKey) ?? useRpc<ComputerState>();
|
|
24
25
|
const state = computed(() => computer.value);
|
|
25
26
|
const busy = ref(false);
|
|
27
|
+
// The Bot as its User named it. A raw slug and an infrastructure vendor are
|
|
28
|
+
// architecture, not identity, and the viewer header is the User's screen.
|
|
29
|
+
const shell = inject(frockBotWebDataKey, undefined);
|
|
30
|
+
const botName = computed(
|
|
31
|
+
() => shell?.value.botSettings?.profile.name ?? "Computer",
|
|
32
|
+
);
|
|
26
33
|
const confirming = ref(false);
|
|
27
34
|
const confirmDialog = ref<HTMLElement>();
|
|
28
35
|
const viewerFrame = ref<HTMLIFrameElement>();
|
|
29
36
|
const frameState = ref<"loading" | ComputerViewerFrameStateV1>("loading");
|
|
30
|
-
const frameMessage = ref("Loading
|
|
37
|
+
const frameMessage = ref("Loading…");
|
|
31
38
|
const elapsedSeconds = ref(0);
|
|
32
39
|
let elapsedTimer: ReturnType<typeof setInterval> | undefined;
|
|
33
40
|
let localProgressStartedAt = Date.now();
|
|
@@ -183,7 +190,9 @@ async function closeViewer(escape = false): Promise<void> {
|
|
|
183
190
|
try {
|
|
184
191
|
await (escape ? actions.escape() : actions.closeViewer());
|
|
185
192
|
} catch {
|
|
186
|
-
//
|
|
193
|
+
// The overlay collapses either way: a release the Computer refused is
|
|
194
|
+
// recorded durably and shown on the card, and is never a reason to trap
|
|
195
|
+
// the User in a full-screen viewer.
|
|
187
196
|
}
|
|
188
197
|
}
|
|
189
198
|
|
|
@@ -275,7 +284,7 @@ onBeforeUnmount(() => {
|
|
|
275
284
|
<header class="computer-overlay-toolbar">
|
|
276
285
|
<div class="computer-overlay-identity">
|
|
277
286
|
<strong>Computer</strong>
|
|
278
|
-
<small>{{
|
|
287
|
+
<small>{{ botName }}</small>
|
|
279
288
|
</div>
|
|
280
289
|
<div class="computer-overlay-actions">
|
|
281
290
|
<span class="computer-status" :class="`status-${state.phase}`">
|
|
@@ -327,9 +336,7 @@ onBeforeUnmount(() => {
|
|
|
327
336
|
@load="handleFrameLoad"
|
|
328
337
|
/>
|
|
329
338
|
<div v-else class="computer-placeholder">
|
|
330
|
-
<strong v-if="state.phase === 'unconfigured'"
|
|
331
|
-
>Computer not configured</strong
|
|
332
|
-
>
|
|
339
|
+
<strong v-if="state.phase === 'unconfigured'">No computer</strong>
|
|
333
340
|
<template v-else-if="opening">
|
|
334
341
|
<strong>{{ openingHeading }}</strong>
|
|
335
342
|
<p v-if="setupExpectation" class="computer-setup-expectation">
|
|
@@ -371,7 +378,7 @@ onBeforeUnmount(() => {
|
|
|
371
378
|
<strong v-else-if="state.phase === 'disconnected'"
|
|
372
379
|
>Viewer disconnected</strong
|
|
373
380
|
>
|
|
374
|
-
<strong v-else>
|
|
381
|
+
<strong v-else>Computer</strong>
|
|
375
382
|
<p v-if="!opening">{{ state.message }}</p>
|
|
376
383
|
<UiButton
|
|
377
384
|
v-if="state.phase === 'idle' || state.phase === 'disconnected'"
|
|
@@ -409,7 +416,7 @@ onBeforeUnmount(() => {
|
|
|
409
416
|
>
|
|
410
417
|
<h2 id="computer-confirm-title">Take control of this Computer?</h2>
|
|
411
418
|
<p id="computer-confirm-detail">
|
|
412
|
-
The Bot
|
|
419
|
+
The Bot won't touch this desktop until you release control.
|
|
413
420
|
</p>
|
|
414
421
|
<div class="computer-confirm-actions">
|
|
415
422
|
<UiButton @click="actions.cancelTakeControl">Cancel</UiButton>
|
|
@@ -74,6 +74,7 @@ function mountHostedProvider(options: { stateChannel?: boolean } = {}) {
|
|
|
74
74
|
let hostUpdating = false;
|
|
75
75
|
let controlHeld = false;
|
|
76
76
|
let renewFails = false;
|
|
77
|
+
let releaseFails = false;
|
|
77
78
|
let heldClose: { release: () => void; pending: Promise<void> } | undefined;
|
|
78
79
|
let state: { value: ComputerState } | undefined;
|
|
79
80
|
const slots: ClientSlotRegistration[] = [];
|
|
@@ -118,7 +119,7 @@ function mountHostedProvider(options: { stateChannel?: boolean } = {}) {
|
|
|
118
119
|
phase = "human-control";
|
|
119
120
|
controlHeld = true;
|
|
120
121
|
}
|
|
121
|
-
if (command.type === "releaseControl") {
|
|
122
|
+
if (command.type === "releaseControl" && !releaseFails) {
|
|
122
123
|
controlHeld = false;
|
|
123
124
|
if (phase !== "disconnected") phase = "ready";
|
|
124
125
|
}
|
|
@@ -130,13 +131,17 @@ function mountHostedProvider(options: { stateChannel?: boolean } = {}) {
|
|
|
130
131
|
commandId: command.commandId,
|
|
131
132
|
type: command.type,
|
|
132
133
|
status:
|
|
133
|
-
command.type === "refreshViewer" && renewFails
|
|
134
|
+
(command.type === "refreshViewer" && renewFails) ||
|
|
135
|
+
(command.type === "releaseControl" && releaseFails)
|
|
134
136
|
? "rejected"
|
|
135
137
|
: "applied",
|
|
136
138
|
completedAt: "2026-09-02T00:00:00.000Z",
|
|
137
139
|
...(command.type === "refreshViewer" && renewFails
|
|
138
140
|
? { failure: "viewer session expired" }
|
|
139
141
|
: {}),
|
|
142
|
+
...(command.type === "releaseControl" && releaseFails
|
|
143
|
+
? { failure: "Sprite is unreachable" }
|
|
144
|
+
: {}),
|
|
140
145
|
};
|
|
141
146
|
if (command.type === "closeViewer" && heldClose) {
|
|
142
147
|
return heldClose.pending.then(() => receipt);
|
|
@@ -150,7 +155,7 @@ function mountHostedProvider(options: { stateChannel?: boolean } = {}) {
|
|
|
150
155
|
phase,
|
|
151
156
|
message:
|
|
152
157
|
phase === "idle"
|
|
153
|
-
? "
|
|
158
|
+
? "Ready to start"
|
|
154
159
|
: phase === "updating"
|
|
155
160
|
? "Updating the Computer runtime"
|
|
156
161
|
: "Computer ready",
|
|
@@ -226,6 +231,9 @@ function mountHostedProvider(options: { stateChannel?: boolean } = {}) {
|
|
|
226
231
|
failRenewal() {
|
|
227
232
|
renewFails = true;
|
|
228
233
|
},
|
|
234
|
+
failRelease() {
|
|
235
|
+
releaseFails = true;
|
|
236
|
+
},
|
|
229
237
|
setUpdating() {
|
|
230
238
|
phase = "updating";
|
|
231
239
|
hostUpdating = true;
|
|
@@ -285,6 +293,35 @@ describe("hosted Computer provider", () => {
|
|
|
285
293
|
mounted.dispose();
|
|
286
294
|
});
|
|
287
295
|
|
|
296
|
+
test("a failed release stops the heartbeat and still closes the viewer", async () => {
|
|
297
|
+
const mounted = mountHostedProvider();
|
|
298
|
+
await flush();
|
|
299
|
+
await mounted.state.openViewer();
|
|
300
|
+
await mounted.state.takeControl();
|
|
301
|
+
expect(mounted.state.phase).toBe("human-control");
|
|
302
|
+
|
|
303
|
+
mounted.failRelease();
|
|
304
|
+
await expect(mounted.state.releaseControl()).rejects.toThrow(
|
|
305
|
+
"Sprite is unreachable",
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
// The projection here keeps reporting the lease — the worst case, a host
|
|
309
|
+
// that cannot drop it. The client must still stop renewing: a takeover
|
|
310
|
+
// whose release failed is one the User can never cancel otherwise.
|
|
311
|
+
const before = postedTypes(mounted.calls).length;
|
|
312
|
+
mounted.runtime.tick(VIEWER_REFRESH_INTERVAL_MS);
|
|
313
|
+
await flush();
|
|
314
|
+
expect(postedTypes(mounted.calls).slice(before)).not.toContain(
|
|
315
|
+
"refreshControl",
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
// And the full-screen viewer is closable, rather than trapping the User.
|
|
319
|
+
await mounted.state.closeViewer();
|
|
320
|
+
await flush();
|
|
321
|
+
expect(mounted.state.expanded).toBe(false);
|
|
322
|
+
mounted.dispose();
|
|
323
|
+
});
|
|
324
|
+
|
|
288
325
|
test("refreshes the viewer only while the overlay is expanded", async () => {
|
|
289
326
|
const mounted = mountHostedProvider();
|
|
290
327
|
await flush();
|
|
@@ -93,6 +93,8 @@ export function createComputerClientPlugin(
|
|
|
93
93
|
let stopStateChannel: (() => void) | undefined;
|
|
94
94
|
let updateRejoin: unknown;
|
|
95
95
|
let controlRequest: Promise<void> | undefined;
|
|
96
|
+
/** Set by a release the backend refused; no heartbeat renews after it. */
|
|
97
|
+
let controlAbandoned = false;
|
|
96
98
|
|
|
97
99
|
const state = ref<ComputerState>({
|
|
98
100
|
...machine,
|
|
@@ -122,7 +124,11 @@ export function createComputerClientPlugin(
|
|
|
122
124
|
}
|
|
123
125
|
|
|
124
126
|
function syncControlHeartbeat(): void {
|
|
125
|
-
|
|
127
|
+
// A release the backend could not confirm ends the heartbeat for good.
|
|
128
|
+
// Renewing a lease the User has already asked to drop is how a takeover
|
|
129
|
+
// becomes one that cannot be cancelled; the next explicit `takeControl`
|
|
130
|
+
// is what starts it again.
|
|
131
|
+
if (machine.phase !== "human-control" || controlAbandoned) {
|
|
126
132
|
stopControlHeartbeat();
|
|
127
133
|
return;
|
|
128
134
|
}
|
|
@@ -262,7 +268,7 @@ export function createComputerClientPlugin(
|
|
|
262
268
|
),
|
|
263
269
|
);
|
|
264
270
|
if (projection.botId !== selectedBotId) {
|
|
265
|
-
throw new Error("
|
|
271
|
+
throw new Error("This computer belongs to a different Bot.");
|
|
266
272
|
}
|
|
267
273
|
apply({ type: "projection-received", projection });
|
|
268
274
|
}
|
|
@@ -329,6 +335,8 @@ export function createComputerClientPlugin(
|
|
|
329
335
|
|
|
330
336
|
async function closeViewer(): Promise<void> {
|
|
331
337
|
if (!machine.expanded) return;
|
|
338
|
+
// A host with no Computer never had a viewer session to close.
|
|
339
|
+
const hadComputer = machine.phase !== "unconfigured";
|
|
332
340
|
if (controlRequest) {
|
|
333
341
|
try {
|
|
334
342
|
await controlRequest;
|
|
@@ -337,7 +345,15 @@ export function createComputerClientPlugin(
|
|
|
337
345
|
// lease to release before this explicit close finishes.
|
|
338
346
|
}
|
|
339
347
|
}
|
|
340
|
-
if (machine.takingControl)
|
|
348
|
+
if (machine.takingControl) {
|
|
349
|
+
try {
|
|
350
|
+
await releaseControl();
|
|
351
|
+
} catch {
|
|
352
|
+
// A release the Computer refused is already recorded durably and
|
|
353
|
+
// has stopped the heartbeat. It is not a reason to keep the User
|
|
354
|
+
// inside a full-screen viewer they asked to leave.
|
|
355
|
+
}
|
|
356
|
+
}
|
|
341
357
|
// Collapse first. The capture the backend files on close is
|
|
342
358
|
// opportunistic, and it crosses a service binding to take a screenshot
|
|
343
359
|
// on the Sprite; an overlay that stayed on screen waiting for that would
|
|
@@ -345,6 +361,9 @@ export function createComputerClientPlugin(
|
|
|
345
361
|
// than execute, so a refused capture never projects a failure onto a
|
|
346
362
|
// Computer the User has already stopped watching.
|
|
347
363
|
apply({ type: "viewer-collapsed" });
|
|
364
|
+
// A viewer that never existed has nothing to close, and the capture the
|
|
365
|
+
// command files is of a session that was never opened.
|
|
366
|
+
if (!hadComputer) return;
|
|
348
367
|
void (async () => {
|
|
349
368
|
try {
|
|
350
369
|
await post("closeViewer");
|
|
@@ -357,6 +376,7 @@ export function createComputerClientPlugin(
|
|
|
357
376
|
|
|
358
377
|
function takeControl(): Promise<void> {
|
|
359
378
|
if (controlRequest) return controlRequest;
|
|
379
|
+
controlAbandoned = false;
|
|
360
380
|
const pending = (async () => {
|
|
361
381
|
if (!machine.viewerUrl) await connect("connect-requested");
|
|
362
382
|
if (!machine.viewerUrl) return;
|
|
@@ -370,7 +390,13 @@ export function createComputerClientPlugin(
|
|
|
370
390
|
}
|
|
371
391
|
|
|
372
392
|
async function releaseControl(): Promise<void> {
|
|
373
|
-
|
|
393
|
+
try {
|
|
394
|
+
await execute("releaseControl");
|
|
395
|
+
} catch (error) {
|
|
396
|
+
controlAbandoned = true;
|
|
397
|
+
syncControlHeartbeat();
|
|
398
|
+
throw error;
|
|
399
|
+
}
|
|
374
400
|
}
|
|
375
401
|
|
|
376
402
|
async function refreshControl(): Promise<void> {
|
|
@@ -380,7 +406,7 @@ export function createComputerClientPlugin(
|
|
|
380
406
|
} catch (error) {
|
|
381
407
|
apply({
|
|
382
408
|
type: "failed",
|
|
383
|
-
message: `
|
|
409
|
+
message: `You lost control of this computer: ${errorMessage(error)}`,
|
|
384
410
|
takingControl: false,
|
|
385
411
|
});
|
|
386
412
|
}
|
|
@@ -404,6 +430,7 @@ export function createComputerClientPlugin(
|
|
|
404
430
|
stopControlHeartbeat();
|
|
405
431
|
stopViewerHeartbeat();
|
|
406
432
|
stopUpdateRejoin();
|
|
433
|
+
controlAbandoned = false;
|
|
407
434
|
machine = initialComputerMachineState();
|
|
408
435
|
Object.assign(state.value, machine);
|
|
409
436
|
watchStateChannel(selectedBotId);
|
|
@@ -46,7 +46,7 @@ export function initialComputerMachineState(): ComputerMachineState {
|
|
|
46
46
|
phase: "unconfigured",
|
|
47
47
|
botId: "unconfigured",
|
|
48
48
|
providerLabel: "unconfigured",
|
|
49
|
-
message: "
|
|
49
|
+
message: "This deployment has no computer.",
|
|
50
50
|
progress: undefined,
|
|
51
51
|
viewerUrl: undefined,
|
|
52
52
|
expanded: false,
|
|
@@ -108,7 +108,7 @@ export function transitionComputerState(
|
|
|
108
108
|
return {
|
|
109
109
|
...state,
|
|
110
110
|
phase: "taking-control",
|
|
111
|
-
message: "Pausing
|
|
111
|
+
message: "Pausing the Bot…",
|
|
112
112
|
progress: undefined,
|
|
113
113
|
};
|
|
114
114
|
case "control-acquired":
|
|
@@ -102,6 +102,53 @@ describe("the process store", () => {
|
|
|
102
102
|
expect((await store.read(record.processId))?.status).toBe("exited");
|
|
103
103
|
});
|
|
104
104
|
|
|
105
|
+
test("prunes finished records rather than disabling background exec forever", async () => {
|
|
106
|
+
const held = storage();
|
|
107
|
+
const store = new ComputerProcessStore(held);
|
|
108
|
+
for (let index = 0; index < COMPUTER_PROCESS_LIMIT_PER_BOT; index += 1) {
|
|
109
|
+
await store.record({
|
|
110
|
+
...record,
|
|
111
|
+
processId: `p-${index}`,
|
|
112
|
+
// Ordered oldest first, so the prune has an unambiguous victim.
|
|
113
|
+
startedAt: new Date(Date.UTC(2026, 7, 31, 0, 0, index)).toISOString(),
|
|
114
|
+
status: "exited",
|
|
115
|
+
exitCode: 0,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
expect(held.map.size).toBe(COMPUTER_PROCESS_LIMIT_PER_BOT);
|
|
119
|
+
|
|
120
|
+
// The 101st launch is admitted: a finished process's record has already
|
|
121
|
+
// answered everything anyone can ask of it.
|
|
122
|
+
await store.record({ ...record, processId: "p-next", status: "starting" });
|
|
123
|
+
|
|
124
|
+
expect(await store.read("p-next")).toMatchObject({ status: "starting" });
|
|
125
|
+
expect(await store.read("p-0")).toBeUndefined();
|
|
126
|
+
expect(await store.read("p-1")).toMatchObject({ status: "exited" });
|
|
127
|
+
expect(held.map.size).toBe(COMPUTER_PROCESS_LIMIT_PER_BOT);
|
|
128
|
+
|
|
129
|
+
// A record that has not finished is never pruned to make room: it is the
|
|
130
|
+
// only thing that remembers the process exists.
|
|
131
|
+
for (let index = 1; index < COMPUTER_PROCESS_LIMIT_PER_BOT; index += 1) {
|
|
132
|
+
await store.update({
|
|
133
|
+
...record,
|
|
134
|
+
processId: `p-${index}`,
|
|
135
|
+
status: "running",
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
await expect(
|
|
139
|
+
store.record({ ...record, processId: "p-one-too-many" }),
|
|
140
|
+
).rejects.toBeInstanceOf(ComputerProcessLimitError);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("forgets one record on request", async () => {
|
|
144
|
+
const held = storage();
|
|
145
|
+
const store = new ComputerProcessStore(held);
|
|
146
|
+
await store.record(record);
|
|
147
|
+
await store.delete(record.processId);
|
|
148
|
+
expect(await store.read(record.processId)).toBeUndefined();
|
|
149
|
+
expect(held.map.size).toBe(0);
|
|
150
|
+
});
|
|
151
|
+
|
|
105
152
|
test("drops a stored value the codec refuses rather than failing the listing", async () => {
|
|
106
153
|
const held = storage();
|
|
107
154
|
const store = new ComputerProcessStore(held);
|
package/src/process-store.ts
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
// outlives its Turn, and after a Durable Object eviction the record is the only
|
|
7
7
|
// thing that knows the process was ever launched.
|
|
8
8
|
//
|
|
9
|
-
// A deep, small module: `record`, `read`, `update`, `list`. Every
|
|
10
|
-
// through a decoder, so a value that reaches storage is a value the codec
|
|
9
|
+
// A deep, small module: `record`, `read`, `update`, `delete`, `list`. Every
|
|
10
|
+
// write goes through a decoder, so a value that reaches storage is a value the codec
|
|
11
11
|
// accepts, and a stored record the codec later refuses is a visible failure
|
|
12
12
|
// rather than a silently reshaped one.
|
|
13
13
|
import {
|
|
@@ -41,21 +41,76 @@ export class ComputerProcessStore {
|
|
|
41
41
|
*/
|
|
42
42
|
async record(intent: ComputerProcessRecordV1): Promise<void> {
|
|
43
43
|
const decoded = decodeComputerProcessRecordV1(intent);
|
|
44
|
-
|
|
44
|
+
let held = await this.storage.list<unknown>({
|
|
45
45
|
prefix: COMPUTER_PROCESS_PREFIX,
|
|
46
46
|
limit: COMPUTER_PROCESS_LIMIT_PER_BOT + 1,
|
|
47
47
|
});
|
|
48
|
+
if (
|
|
49
|
+
held.size >= COMPUTER_PROCESS_LIMIT_PER_BOT &&
|
|
50
|
+
!held.has(computerProcessKeyV1(decoded.processId))
|
|
51
|
+
) {
|
|
52
|
+
// A finished process's record has already answered every question
|
|
53
|
+
// anyone can ask of it. Without this prune the hundredth background
|
|
54
|
+
// command a Bot ever ran disabled `computer_exec{background:true}`
|
|
55
|
+
// permanently: no tool, no command and no UI could forget a record.
|
|
56
|
+
held = await this.prune(held, decoded.processId);
|
|
57
|
+
}
|
|
48
58
|
if (
|
|
49
59
|
held.size >= COMPUTER_PROCESS_LIMIT_PER_BOT &&
|
|
50
60
|
!held.has(computerProcessKeyV1(decoded.processId))
|
|
51
61
|
) {
|
|
52
62
|
throw new ComputerProcessLimitError(
|
|
53
|
-
`this Bot already
|
|
63
|
+
`this Bot already has ${COMPUTER_PROCESS_LIMIT_PER_BOT} background processes that have not finished; stop one with computer_process_stop first`,
|
|
54
64
|
);
|
|
55
65
|
}
|
|
56
66
|
await this.storage.put(computerProcessKeyV1(decoded.processId), decoded);
|
|
57
67
|
}
|
|
58
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Drops finished records, oldest first, until the cap has room again.
|
|
71
|
+
*
|
|
72
|
+
* Terminal only: a `starting` or `running` record is the only thing that
|
|
73
|
+
* remembers the process exists, so it is never pruned to make space. An
|
|
74
|
+
* undecodable row goes too — it can answer nothing, and leaving it would let
|
|
75
|
+
* one bad value hold a slot for the life of the Bot.
|
|
76
|
+
*/
|
|
77
|
+
private async prune(
|
|
78
|
+
held: Map<string, unknown>,
|
|
79
|
+
incoming: string,
|
|
80
|
+
): Promise<Map<string, unknown>> {
|
|
81
|
+
const terminal: Array<{ key: string; startedAt: string }> = [];
|
|
82
|
+
for (const [key, value] of held) {
|
|
83
|
+
if (key === computerProcessKeyV1(incoming)) continue;
|
|
84
|
+
try {
|
|
85
|
+
const record = decodeComputerProcessRecordV1(value);
|
|
86
|
+
if (record.status === "exited" || record.status === "unknown") {
|
|
87
|
+
terminal.push({ key, startedAt: record.startedAt });
|
|
88
|
+
}
|
|
89
|
+
} catch (error) {
|
|
90
|
+
if (!(error instanceof ComputerProcessDecodeError)) throw error;
|
|
91
|
+
terminal.push({ key, startedAt: "" });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
terminal.sort((left, right) =>
|
|
95
|
+
left.startedAt.localeCompare(right.startedAt),
|
|
96
|
+
);
|
|
97
|
+
const remaining = new Map(held);
|
|
98
|
+
for (const { key } of terminal) {
|
|
99
|
+
if (remaining.size < COMPUTER_PROCESS_LIMIT_PER_BOT) break;
|
|
100
|
+
await this.storage.delete(key);
|
|
101
|
+
remaining.delete(key);
|
|
102
|
+
}
|
|
103
|
+
return remaining;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Forgets one record. The process it described is over: the caller has read
|
|
108
|
+
* a terminal status, or is reconciling an intent whose launch never ran.
|
|
109
|
+
*/
|
|
110
|
+
async delete(processId: string): Promise<void> {
|
|
111
|
+
await this.storage.delete(computerProcessKeyV1(processId));
|
|
112
|
+
}
|
|
113
|
+
|
|
59
114
|
async read(processId: string): Promise<ComputerProcessRecordV1 | undefined> {
|
|
60
115
|
const held = await this.storage.get<unknown>(
|
|
61
116
|
computerProcessKeyV1(processId),
|
package/src/processes.test.ts
CHANGED
|
@@ -53,7 +53,7 @@ interface Computer {
|
|
|
53
53
|
workspace: FakeWorkspace;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
function fakeComputer(): Computer {
|
|
56
|
+
function fakeComputer(options: { launchFails?: boolean } = {}): Computer {
|
|
57
57
|
const calls: string[] = [];
|
|
58
58
|
const workspace = new FakeWorkspace();
|
|
59
59
|
const computer: Computer = {
|
|
@@ -72,6 +72,9 @@ function fakeComputer(): Computer {
|
|
|
72
72
|
processes: {
|
|
73
73
|
launch: (request) => {
|
|
74
74
|
calls.push(`launch:${request.processId}:${request.command}`);
|
|
75
|
+
if (options.launchFails) {
|
|
76
|
+
return Promise.reject(new Error("Sprite is unreachable"));
|
|
77
|
+
}
|
|
75
78
|
return Promise.resolve({
|
|
76
79
|
pid: 4321,
|
|
77
80
|
logPath: `/processes/${request.processId}/log`,
|
|
@@ -174,6 +177,26 @@ describe("computer_exec with background:true", () => {
|
|
|
174
177
|
await harness.dispose();
|
|
175
178
|
});
|
|
176
179
|
|
|
180
|
+
test("settles the intent a failed launch left behind", async () => {
|
|
181
|
+
const computer = fakeComputer({ launchFails: true });
|
|
182
|
+
const held = storage();
|
|
183
|
+
const harness = await mount(computer, held);
|
|
184
|
+
|
|
185
|
+
const result = await call(harness, "computer_exec", {
|
|
186
|
+
command: "npm run build",
|
|
187
|
+
background: true,
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
expect(result).toMatchObject({ isError: true });
|
|
191
|
+
// The record survives — the launch may have started something before it
|
|
192
|
+
// threw — but it is terminal, so the store can prune it. Left `starting`,
|
|
193
|
+
// a failing Computer would burn the Bot's 100-record budget having run
|
|
194
|
+
// nothing at all, and permanently disable background exec.
|
|
195
|
+
const records = [...held.map.values()] as Array<{ status: string }>;
|
|
196
|
+
expect(records).toMatchObject([{ status: "unknown" }]);
|
|
197
|
+
await harness.dispose();
|
|
198
|
+
});
|
|
199
|
+
|
|
177
200
|
test("is refused where there is nowhere durable to record it", async () => {
|
|
178
201
|
const computer = fakeComputer();
|
|
179
202
|
const harness = await createPluginHarness([
|
package/src/protocol.ts
CHANGED
|
@@ -108,6 +108,20 @@ export type ComputerPhase = (typeof COMPUTER_PHASES)[number];
|
|
|
108
108
|
|
|
109
109
|
export const COMPUTER_UPDATE_MESSAGE_PREFIX = "Updating the Computer: ";
|
|
110
110
|
|
|
111
|
+
/**
|
|
112
|
+
* What the Computer card promises a first-ever cold provision will take, as
|
|
113
|
+
* copy and as the number that copy means.
|
|
114
|
+
*
|
|
115
|
+
* The number is here rather than in the card because a provider's `open`
|
|
116
|
+
* deadline has to outlast it: a wake that takes exactly as long as the product
|
|
117
|
+
* promised must not abort on our own clock and reach the User as a broken
|
|
118
|
+
* Computer. The provider Package's test asserts that relationship, so neither
|
|
119
|
+
* side can drift alone.
|
|
120
|
+
*/
|
|
121
|
+
export const COMPUTER_COLD_PROVISION_EXPECTATION_MS = 180_000;
|
|
122
|
+
export const COMPUTER_COLD_PROVISION_EXPECTATION =
|
|
123
|
+
"This usually takes 2-3 minutes";
|
|
124
|
+
|
|
111
125
|
/** Extracts the provider's update phase label without coupling to a provider. */
|
|
112
126
|
export function computerUpdateLabelV1(
|
|
113
127
|
message: string | undefined,
|