@frockbot/plugin-fly-sprite 0.0.0 → 0.1.1
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/frockbot.json +16 -0
- package/package.json +42 -6
- package/src/agent.ts +6 -0
- package/src/computer.test.ts +562 -0
- package/src/computer.ts +1394 -0
- package/src/doctor.test.ts +154 -0
- package/src/host-client.test.ts +618 -0
- package/src/host-client.ts +794 -0
- package/src/host-double.ts +268 -0
- package/src/host.ts +331 -0
- package/src/index.ts +7 -0
- package/src/manifest.ts +3 -0
- package/src/processes.test.ts +176 -0
- package/src/provider.ts +591 -0
- package/src/screenshot.test.ts +145 -0
- package/src/sync.test.ts +1060 -0
- package/src/sync.ts +1275 -0
- package/src/workspace.test.ts +928 -0
- package/src/workspace.ts +782 -0
- package/tsconfig.json +15 -0
- package/README.md +0 -3
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/// <reference types="bun" />
|
|
2
|
+
|
|
3
|
+
// Background processes as the provider drives them.
|
|
4
|
+
//
|
|
5
|
+
// The subject is the shape of the three scripts, because each one encodes a
|
|
6
|
+
// decision that is invisible anywhere else: `setsid` makes the command a
|
|
7
|
+
// process group leader so a later stop can end its whole tree; the stop
|
|
8
|
+
// escalates TERM to KILL rather than assuming a signal was obeyed; and the
|
|
9
|
+
// read carries no human-control guard, because collecting the outcome of a
|
|
10
|
+
// long job is not the Bot acting on the screen.
|
|
11
|
+
import { describe, expect, test } from "bun:test";
|
|
12
|
+
import {
|
|
13
|
+
computerBotKey,
|
|
14
|
+
FlySpriteComputer,
|
|
15
|
+
PROCESS_STOP_GRACE_SECONDS,
|
|
16
|
+
} from "./computer.ts";
|
|
17
|
+
import { FakeComputerHost } from "./host-double.ts";
|
|
18
|
+
|
|
19
|
+
const KEY = computerBotKey("worker");
|
|
20
|
+
const DIR = `/home/box/.frockbot/bots/${KEY}/processes/p-1`;
|
|
21
|
+
|
|
22
|
+
function signal(): AbortSignal {
|
|
23
|
+
return new AbortController().signal;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function computerOn(host: FakeComputerHost): FlySpriteComputer {
|
|
27
|
+
return new FlySpriteComputer({
|
|
28
|
+
identity: { userId: "owner" },
|
|
29
|
+
host: host.factory,
|
|
30
|
+
spriteName: "frockbot-test",
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A Computer that answers the launch with a pid and the read with a state. */
|
|
35
|
+
function fakeComputer(
|
|
36
|
+
answers: {
|
|
37
|
+
pid?: number;
|
|
38
|
+
alive?: boolean;
|
|
39
|
+
exitCode?: number;
|
|
40
|
+
log?: string;
|
|
41
|
+
} = {},
|
|
42
|
+
): FakeComputerHost {
|
|
43
|
+
return new FakeComputerHost((script) => {
|
|
44
|
+
if (script.includes("setsid nohup")) {
|
|
45
|
+
return { stdout: `__FROCKBOT_PROCESS__${answers.pid ?? 4242}\n` };
|
|
46
|
+
}
|
|
47
|
+
if (script.includes("%salive=%s")) {
|
|
48
|
+
return {
|
|
49
|
+
stdout: [
|
|
50
|
+
`__FROCKBOT_PROCESS__alive=${answers.alive ? 1 : 0}`,
|
|
51
|
+
...(answers.exitCode === undefined
|
|
52
|
+
? []
|
|
53
|
+
: [`__FROCKBOT_PROCESS__exit=${answers.exitCode}`]),
|
|
54
|
+
"__FROCKBOT_PROCESS__log",
|
|
55
|
+
answers.log ?? "",
|
|
56
|
+
].join("\n"),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
return {};
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe("launching a background process", () => {
|
|
64
|
+
test("detaches it into its own process group behind the control guard", async () => {
|
|
65
|
+
const host = fakeComputer({ pid: 9182 });
|
|
66
|
+
const bot = computerOn(host).bot("worker");
|
|
67
|
+
await bot.ensure(signal());
|
|
68
|
+
|
|
69
|
+
const launched = await bot.launchProcess("p-1", "npm run build", signal());
|
|
70
|
+
|
|
71
|
+
const script = host.scripts.find((candidate) =>
|
|
72
|
+
candidate.includes("setsid nohup"),
|
|
73
|
+
)!;
|
|
74
|
+
expect(script).toBeDefined();
|
|
75
|
+
// Guarded like every other Bot command: a launch is a mutation.
|
|
76
|
+
expect(script.indexOf("control.sh assert-agent")).toBeLessThan(
|
|
77
|
+
script.indexOf("setsid nohup"),
|
|
78
|
+
);
|
|
79
|
+
// `setsid` is what makes `kill -TERM -$PID` reach the whole pipeline
|
|
80
|
+
// later, instead of orphaning the children of a shell.
|
|
81
|
+
expect(script).toContain("setsid nohup bash -c");
|
|
82
|
+
expect(script).toContain("bounded-log.sh");
|
|
83
|
+
// The exit code recorded is the command's, not the logger's.
|
|
84
|
+
expect(script).toContain("PIPESTATUS[0]");
|
|
85
|
+
expect(script).toContain(`> "$DIR/pid"`);
|
|
86
|
+
expect(launched).toEqual({
|
|
87
|
+
pid: 9182,
|
|
88
|
+
logPath: `${DIR}/log`,
|
|
89
|
+
cwd: `/workspaces/${KEY}`,
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("refuses a launch the Computer did not start", async () => {
|
|
94
|
+
const host = new FakeComputerHost(() => ({ stdout: "" }));
|
|
95
|
+
const bot = computerOn(host).bot("worker");
|
|
96
|
+
await bot.ensure(signal());
|
|
97
|
+
|
|
98
|
+
await expect(
|
|
99
|
+
bot.launchProcess("p-1", "sleep 60", signal()),
|
|
100
|
+
).rejects.toThrow(/started no background process/);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("is refused while a human holds the takeover lease", async () => {
|
|
104
|
+
const host = fakeComputer();
|
|
105
|
+
const computer = computerOn(host);
|
|
106
|
+
const bot = computer.bot("worker");
|
|
107
|
+
await bot.ensure(signal());
|
|
108
|
+
host.leases.set(KEY, { owner: "a-human", fresh: true });
|
|
109
|
+
|
|
110
|
+
await expect(
|
|
111
|
+
bot.launchProcess("p-1", "sleep 60", signal()),
|
|
112
|
+
).rejects.toThrow(/controlling this agent's computer/);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("reading a background process", () => {
|
|
117
|
+
test("answers liveness, exit code and the bounded log", async () => {
|
|
118
|
+
const host = fakeComputer({
|
|
119
|
+
alive: false,
|
|
120
|
+
exitCode: 3,
|
|
121
|
+
log: "build failed",
|
|
122
|
+
});
|
|
123
|
+
const bot = computerOn(host).bot("worker");
|
|
124
|
+
await bot.ensure(signal());
|
|
125
|
+
|
|
126
|
+
const state = await bot.inspectProcess("p-1", signal());
|
|
127
|
+
|
|
128
|
+
expect(state).toMatchObject({ alive: false, exitCode: 3 });
|
|
129
|
+
expect(state.logTail).toContain("build failed");
|
|
130
|
+
const script = host.scripts.at(-1)!;
|
|
131
|
+
// Both halves of the capped log are composed at read time.
|
|
132
|
+
expect(script).toContain('"$DIR/log.head"');
|
|
133
|
+
expect(script).toContain('"$DIR/log.tail"');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("carries no human-control guard, so a Routine can still collect it", async () => {
|
|
137
|
+
const host = fakeComputer({ alive: true });
|
|
138
|
+
const computer = computerOn(host);
|
|
139
|
+
const bot = computer.bot("worker");
|
|
140
|
+
await bot.ensure(signal());
|
|
141
|
+
host.leases.set(KEY, { owner: "a-human", fresh: true });
|
|
142
|
+
|
|
143
|
+
// A human holding the screen must not stop a Bot from learning what
|
|
144
|
+
// became of a job it started before the takeover.
|
|
145
|
+
const state = await bot.inspectProcess("p-1", signal());
|
|
146
|
+
|
|
147
|
+
expect(state.alive).toBe(true);
|
|
148
|
+
const script = host.scripts.at(-1)!;
|
|
149
|
+
expect(script).not.toContain("assert-agent");
|
|
150
|
+
// The slot stamp still runs: a tenant with a running process is live.
|
|
151
|
+
expect(script).toContain("last-seen");
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("stopping a background process", () => {
|
|
156
|
+
test("escalates TERM to KILL over the process group and records an exit", async () => {
|
|
157
|
+
const host = fakeComputer({ alive: false, exitCode: 143 });
|
|
158
|
+
const bot = computerOn(host).bot("worker");
|
|
159
|
+
await bot.ensure(signal());
|
|
160
|
+
|
|
161
|
+
const state = await bot.stopProcess("p-1", signal());
|
|
162
|
+
|
|
163
|
+
const script = host.scripts.find((candidate) =>
|
|
164
|
+
candidate.includes("kill -TERM"),
|
|
165
|
+
)!;
|
|
166
|
+
expect(script).toBeDefined();
|
|
167
|
+
// The negative pid is the group: a pipeline's children go with it.
|
|
168
|
+
expect(script).toContain('kill -TERM -"$PID"');
|
|
169
|
+
expect(script).toContain('kill -KILL -"$PID"');
|
|
170
|
+
expect(script).toContain(`seq 1 ${PROCESS_STOP_GRACE_SECONDS * 10}`);
|
|
171
|
+
// A stopped process must not read back as `unknown` for want of an exit
|
|
172
|
+
// file the signal never let it write.
|
|
173
|
+
expect(script).toContain(`'143\\n' > "$DIR/exit"`);
|
|
174
|
+
expect(state).toMatchObject({ alive: false, exitCode: 143 });
|
|
175
|
+
});
|
|
176
|
+
});
|