Skip to content

Commit d97641e

Browse files
Record a computer reset as soon as the profile is gone (#404)
Co-authored-by: David McKay <david@copilotkit.ai>
1 parent d90e6c7 commit d97641e

3 files changed

Lines changed: 85 additions & 7 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,14 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.
88

99
## Unreleased
1010

11+
### Wiping a computer is recorded even if clearing its stored state fails
12+
13+
Resetting a computer destroys the profile first and wrote the audit row last, after two Postgres
14+
deletes. A connection reset, a failover or a statement timeout in either delete threw before the row
15+
was written, so the most destructive button in the product could leave a wiped computer -- every
16+
login gone, no undo -- with nothing on the trail to say who wiped it or when. The row is now written
17+
as soon as the profile is gone, which is the point after which nothing can be put back. A failure in
18+
either delete is still reported to the caller.
1119
### Pressing Stop is recorded as a stop, not as a computer that is not running
1220

1321
The computer transport answered a Stop correctly only when it arrived before the request left. The

‎server/src/computer/gateway.ts‎

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -713,6 +713,22 @@ export function createComputerGateway(
713713
*/
714714
async resetComputer(botId: string, actor: ActionActor) {
715715
const result = await provider.reset(botId);
716+
/*
717+
* The row goes in HERE, before the two deletes below, because this line is the point of no
718+
* return: the profile is already gone and nothing after it can put the logins back.
719+
*
720+
* Both clears are Postgres deletes, and a connection reset, a failover or a statement timeout
721+
* in either used to throw before the row was written -- leaving a computer wiped with nothing
722+
* on the trail to say who wiped it, which is the one outcome the note above rules out. The
723+
* failure still propagates, so the caller is told the clears did not finish.
724+
*/
725+
await writeControlEvent(auditStore, "computer.reset", {
726+
botId,
727+
actor,
728+
reason: result.cleared
729+
? "the computer and its saved state were deleted"
730+
: "no saved state was present to delete",
731+
});
716732
// The refs the last snapshot handed out describe a page that no longer exists, and a fresh
717733
// computer counts generations from one again, so the row has to go with the profile.
718734
await snapshots.clear(botId);
@@ -725,13 +741,6 @@ export function createComputerGateway(
725741
* anything a person would recognise as private.
726742
*/
727743
await pageFrames?.clear(botId);
728-
await writeControlEvent(auditStore, "computer.reset", {
729-
botId,
730-
actor,
731-
reason: result.cleared
732-
? "the computer and its saved state were deleted"
733-
: "no saved state was present to delete",
734-
});
735744
return result;
736745
},
737746

‎server/tests/computer-gateway.test.ts‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -687,6 +687,67 @@ describe("the computer gateway", () => {
687687
expect(rows[0]?.targetId).toBe("bot-2");
688688
});
689689

690+
/*
691+
* "The most destructive button we have. Every login the Bot had is gone and no undo exists, so the
692+
* row is written whatever happens next."
693+
*
694+
* The profile is destroyed by `provider.reset` before either of the two Postgres deletes runs, so
695+
* a delete that fails cannot put it back -- it can only take the row with it, which is the one
696+
* thing that note rules out.
697+
*/
698+
test("resetComputer records the reset even when clearing the stored page fails", async () => {
699+
const { provider, fetchImpl } = fakeComputer({
700+
resetResult: { cleared: true },
701+
});
702+
const { store, rows } = fakeAudit();
703+
const snapshots: SnapshotStore = {
704+
...createInMemorySnapshotStore(),
705+
clear: async () => {
706+
throw new Error("connection reset by peer");
707+
},
708+
};
709+
const gateway = createComputerGateway({
710+
provider,
711+
fetchImpl,
712+
auditStore: store,
713+
policy: () => PERMISSIVE,
714+
snapshots,
715+
});
716+
717+
await expect(gateway.resetComputer("bot-1", ACTOR)).rejects.toThrow(
718+
"connection reset by peer",
719+
);
720+
721+
expect(rows.map((row) => row.eventType)).toContain("computer.reset");
722+
expect(rows[0]?.targetId).toBe("bot-1");
723+
});
724+
725+
test("resetComputer records the reset even when clearing the screenshots fails", async () => {
726+
const { provider, fetchImpl } = fakeComputer({
727+
resetResult: { cleared: true },
728+
});
729+
const { store, rows } = fakeAudit();
730+
const gateway = createComputerGateway({
731+
provider,
732+
fetchImpl,
733+
auditStore: store,
734+
policy: () => PERMISSIVE,
735+
pageFrames: {
736+
clear: async () => {
737+
throw new Error("statement timeout");
738+
},
739+
} as unknown as NonNullable<
740+
Parameters<typeof createComputerGateway>[0]["pageFrames"]
741+
>,
742+
});
743+
744+
await expect(gateway.resetComputer("bot-1", ACTOR)).rejects.toThrow(
745+
"statement timeout",
746+
);
747+
748+
expect(rows.map((row) => row.eventType)).toContain("computer.reset");
749+
});
750+
690751
test("computers maps provider status 'running' and 'stopped' directly and preserves egress distinctions", async () => {
691752
const locations: ComputerLocation[] = [
692753
{

0 commit comments

Comments
 (0)