Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions server/computer-control.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
// The who-is-driving record. What these tests pin is the authority split:
// the person's three moves (take, release, dismiss) all work, the bot's one
// move (requestHelp) never grants anything, and a release settles the help
// request in the same change the person made.
import { describe, expect, it } from "vitest";

import { ComputerControl, type ControlSnapshot } from "./computer-control.ts";

function tracked() {
const changes: Array<{ botId: string; snapshot: ControlSnapshot }> = [];
const control = new ComputerControl((botId, snapshot) => changes.push({ botId, snapshot }));
return { control, changes };
}

describe("computer control", () => {
it("starts disengaged for an unknown bot", () => {
const { control } = tracked();
expect(control.snapshot("b1")).toEqual({ held: false, helpReason: null, heldSinceMs: null });
});

it("take → held; release → disengaged, each broadcast once", () => {
const { control, changes } = tracked();
const held = control.take("b1");
expect(held.held).toBe(true);
expect(held.heldSinceMs).not.toBeNull();
expect(control.release("b1").held).toBe(false);
expect(changes.map((c) => c.snapshot.held)).toEqual([true, false]);
});

it("a second take does not reset how long the hold has lasted", () => {
let clock = 1000;
const control = new ComputerControl(() => {}, () => clock);
control.take("b1");
clock = 5000;
expect(control.take("b1").heldSinceMs).toBe(1000);
});

it("requestHelp surfaces the plea but never grants control", () => {
const { control } = tracked();
const snapshot = control.requestHelp("b1", " please log in for me ");
expect(snapshot.held).toBe(false);
expect(snapshot.helpReason).toBe("please log in for me");
});

it("an empty reason still reads as a plea", () => {
const { control } = tracked();
expect(control.requestHelp("b1", undefined).helpReason).toBe("the bot asked you to take over");
});

it("a shouted second reason cannot clobber the one the person is reading", () => {
const { control } = tracked();
control.requestHelp("b1", "first");
expect(control.requestHelp("b1", "second").helpReason).toBe("first");
});

it("expires only the help request that owns the timeout", () => {
const { control, changes } = tracked();
const first = control.requestHelpLease("b1", "first");
expect(control.expireHelp("b1", "some-older-request").helpReason).toBe("first");
expect(changes).toHaveLength(1);
expect(control.expireHelp("b1", first.requestId).helpReason).toBeNull();
expect(changes).toHaveLength(2);
});

it("an old timeout cannot dismiss a newer plea", () => {
const { control } = tracked();
const first = control.requestHelpLease("b1", "first");
control.dismissHelp("b1");
const second = control.requestHelpLease("b1", "second");
expect(second.requestId).not.toBe(first.requestId);
expect(control.expireHelp("b1", first.requestId).helpReason).toBe("second");
});

it("a novel-length reason is cut to card size", () => {
const { control } = tracked();
const reason = "x".repeat(2000);
expect(control.requestHelp("b1", reason).helpReason?.length).toBe(280);
});

it("release settles an open help request in the same change", () => {
const { control } = tracked();
control.requestHelp("b1", "stuck on a captcha");
control.take("b1");
const after = control.release("b1");
expect(after).toEqual({ held: false, helpReason: null, heldSinceMs: null });
});

it("dismiss clears the plea without taking control", () => {
const { control } = tracked();
control.requestHelp("b1", "stuck");
const after = control.dismissHelp("b1");
expect(after.helpReason).toBeNull();
expect(after.held).toBe(false);
});

it("dismiss while driving keeps the hold", () => {
const { control } = tracked();
control.take("b1");
control.requestHelp("b1", "also this");
const after = control.dismissHelp("b1");
expect(after.held).toBe(true);
expect(after.helpReason).toBeNull();
});

it("dismissing nothing is silent — no phantom broadcast", () => {
const { control, changes } = tracked();
control.dismissHelp("b1");
expect(changes).toEqual([]);
});

it("bots are independent", () => {
const { control } = tracked();
control.take("b1");
expect(control.snapshot("b2").held).toBe(false);
});

it("forget clears a hold and tells the listeners", () => {
const { control, changes } = tracked();
control.take("b1");
control.forget("b1");
expect(control.snapshot("b1").held).toBe(false);
expect(changes.at(-1)?.snapshot).toEqual({ held: false, helpReason: null, heldSinceMs: null });
});

it("forgetting an unknown bot is silent", () => {
const { control, changes } = tracked();
control.forget("ghost");
expect(changes).toEqual([]);
});
});
135 changes: 135 additions & 0 deletions server/computer-control.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// Who is driving a bot's computer — the person or the bot. One record per
// bot, held in the harness because every consumer (the panel, the SSE
// stream, and the per-turn computer proxies) already talks to the harness.
//
// The rules this module exists to enforce:
// - A bot can only ASK for hands (`requestHelp`); it can never take
// control, and it cannot clear a hold. Only the person takes and
// releases, from the computer panel.
// - While the person holds control, the bot's computer actions are
// REFUSED by the proxies, not queued. A queued click would land after
// the person has moved on, on whatever happens to be under it.
// - Releasing control also settles any open help request, so a bot
// waiting on `requestHelp` wakes up from the same state change the
// person made — there is no separate "done helping" step to forget.
//
// State is per-boot and in-memory on purpose: a hold is a live fact about
// who is at the screen right now, and surviving a harness restart would
// mean a stale hold silently bricking a bot's computer.

export interface ControlSnapshot {
/** True while the person is driving; the bot's hands are refused. */
held: boolean;
/** The bot's open plea for help, verbatim, or null when none is open. */
helpReason: string | null;
heldSinceMs: number | null;
}

const NO_CONTROL: ControlSnapshot = { held: false, helpReason: null, heldSinceMs: null };
/** Keep a shouted help reason card-sized; the transcript has the rest. */
const MAX_REASON_CHARS = 280;

interface Entry {
heldSinceMs: number | null;
helpReason: string | null;
helpRequestId: string | null;
}

export class ComputerControl {
private entries = new Map<string, Entry>();
private onChange: (botId: string, snapshot: ControlSnapshot) => void;
private now: () => number;
private requestSequence = 0;

constructor(
onChange: (botId: string, snapshot: ControlSnapshot) => void = () => {},
now: () => number = Date.now,
) {
this.onChange = onChange;
this.now = now;
}

snapshot(botId: string): ControlSnapshot {
const entry = this.entries.get(botId);
if (!entry) return NO_CONTROL;
return {
held: entry.heldSinceMs !== null,
helpReason: entry.helpReason,
heldSinceMs: entry.heldSinceMs,
};
}

/** The person takes the wheel. Idempotent — a second click must not
* reset `heldSinceMs` and make the hold look newer than it is. */
take(botId: string): ControlSnapshot {
const entry = this.entries.get(botId);
if (entry?.heldSinceMs != null) return this.snapshot(botId);
this.entries.set(botId, {
heldSinceMs: this.now(),
helpReason: entry?.helpReason ?? null,
helpRequestId: entry?.helpRequestId ?? null,
});
return this.changed(botId);
}

/** The person hands the wheel back. Also settles any open help request —
* the waiting bot resumes from this one state change. */
release(botId: string): ControlSnapshot {
if (!this.entries.has(botId)) return NO_CONTROL;
this.entries.delete(botId);
return this.changed(botId);
}

/** The bot asks the person to take over. Never grants anything by
* itself — it only surfaces the plea. A reason shouted while the person
* is already driving is kept, but must not clobber an earlier one they
* may still be reading. */
requestHelp(botId: string, reason: unknown): ControlSnapshot {
return this.requestHelpLease(botId, reason).snapshot;
}

/** Open a help request and return the lease that owns it. A proxy uses
* this id to expire only its own unanswered plea when its wait ends. */
requestHelpLease(botId: string, reason: unknown): { snapshot: ControlSnapshot; requestId: string } {
const text = typeof reason === "string" ? reason.trim().slice(0, MAX_REASON_CHARS) : "";
const entry = this.entries.get(botId) ?? { heldSinceMs: null, helpReason: null, helpRequestId: null };
if (entry.helpReason === null) {
entry.helpReason = text || "the bot asked you to take over";
entry.helpRequestId = `${botId}-${++this.requestSequence}`;
}
this.entries.set(botId, entry);
return { snapshot: this.changed(botId), requestId: entry.helpRequestId! };
}

/** The person declines without taking over; the waiting bot is told. */
dismissHelp(botId: string): ControlSnapshot {
const entry = this.entries.get(botId);
if (!entry || entry.helpReason === null) return this.snapshot(botId);
entry.helpReason = null;
entry.helpRequestId = null;
if (entry.heldSinceMs === null) this.entries.delete(botId);
return this.changed(botId);
}

/** Expire an unanswered plea. The id comparison prevents an old proxy's
* timeout from dismissing a newer request for the same bot. */
expireHelp(botId: string, requestId: unknown): ControlSnapshot {
const entry = this.entries.get(botId);
if (!entry || entry.helpRequestId !== requestId) return this.snapshot(botId);
entry.helpReason = null;
entry.helpRequestId = null;
if (entry.heldSinceMs === null) this.entries.delete(botId);
return this.changed(botId);
}

/** The bot is gone; a hold on a deleted bot's computer means nothing. */
forget(botId: string): void {
if (this.entries.delete(botId)) this.onChange(botId, NO_CONTROL);
}

private changed(botId: string): ControlSnapshot {
const snapshot = this.snapshot(botId);
this.onChange(botId, snapshot);
return snapshot;
}
}
Loading
Loading