Skip to content
Open
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.

## Unreleased

### A browser clock that runs ahead no longer hides what a routine said

The roster line and unread dot for a channel are moved by the last report that arrived, and only
ever forwards. A browser whose clock was ahead stamped its report into the future, and then every
report from a correct clock — a routine's reply, a relayed handoff answer, another member's browser
— was dropped without a word until the real time caught up: the reply was in the thread, and the
roster never said so. A reported time is now capped at the server's own clock.

### Coworkers are made in a wizard and managed in a dialog

Creating a coworker is now a three-step wizard — who it is, who may see it, then where it runs,
Expand Down
23 changes: 18 additions & 5 deletions server/src/channels/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -908,10 +908,22 @@ type ActivityInputParseResult =
/**
* Parse a reported message.
*
* `at` comes from the client that saw the message, because only it knows when the message arrived, * but it is never trusted as a clock: the store compares it against what is stored and only ever
* moves forwards, so a wrong one can lose a report, not corrupt the row.
* `at` comes from the client that saw the message, because only it knows when the message arrived,
* and it may say when, but not later than now. The store compares it against what is stored and
* only ever moves forwards, and that guard is shared with every other clock in the deployment:
* the routine runner's, a relayed handoff answer's, every other member's browser. A browser whose
* clock ran seven minutes ahead used to stamp the row seven minutes into the future, and it was
* not that report that got lost — every correct one for the next seven minutes was, silently: a
* routine's reply landed in the thread and never on the roster. Clamped rather than refused,
* because clocks are a little ahead all the time and a report a second early is still the report.
* A stamp in the past is kept as it is, so a person's message and the reply, reported separately
* by the same clock, still land in the order that clock saw them.
*/
export function parseActivityInput(input: unknown): ActivityInputParseResult {
export function parseActivityInput(
input: unknown,
/** The server's own clock, injectable so a test can be about a specific gap. */
now: Date = new Date(),
): ActivityInputParseResult {
if (!isChannelInputObject(input)) {
return { ok: false, error: "Activity must be a JSON object." };
}
Expand All @@ -926,10 +938,11 @@ export function parseActivityInput(input: unknown): ActivityInputParseResult {
if (typeof object.at !== "string") {
return { ok: false, error: "Timestamp is required." };
}
const at = new Date(object.at);
if (Number.isNaN(at.getTime())) {
const reported = new Date(object.at);
if (Number.isNaN(reported.getTime())) {
return { ok: false, error: "Timestamp must be an ISO-8601 date." };
}
const at = reported.getTime() > now.getTime() ? now : reported;

return {
ok: true,
Expand Down
55 changes: 55 additions & 0 deletions server/tests/channel-activity-input.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, expect, test } from "bun:test";
import { parseActivityInput } from "../src/channels/routes";

/**
* The moment a browser says a message arrived is the browser's clock, and the row it lands on is
* compared against by every other clock in the deployment: the routine runner's, a relayed
* handoff answer's, and every other member's browser. `recordActivity` only moves forwards, so a
* report stamped in the future is not the report that gets lost — every correct one after it is.
*/
describe("parsing a reported message", () => {
const now = new Date("2026-09-03T10:00:00.000Z");

test("keeps a timestamp that is not ahead of the server", () => {
const at = "2026-09-03T09:59:30.000Z";
const parsed = parseActivityInput(
{ text: "hello", agentId: null, at },
now,
);
expect(parsed.ok).toBe(true);
if (!parsed.ok) return;
expect(parsed.value.at.toISOString()).toBe(at);
});

test("clamps a timestamp from a clock that runs ahead to now", () => {
const parsed = parseActivityInput(
{ text: "hello", agentId: null, at: "2026-09-03T10:07:00.000Z" },
now,
);
expect(parsed.ok).toBe(true);
if (!parsed.ok) return;
expect(parsed.value.at.toISOString()).toBe(now.toISOString());
});

test("still refuses a timestamp that is not a date", () => {
const parsed = parseActivityInput(
{ text: "hello", agentId: null, at: "yesterday" },
now,
);
expect(parsed).toEqual({
ok: false,
error: "Timestamp must be an ISO-8601 date.",
});
});

test("keeps the rest of the report as it was", () => {
const parsed = parseActivityInput(
{ text: "hello", agentId: " agent-1 ", at: "2026-09-03T09:00:00.000Z" },
now,
);
expect(parsed.ok).toBe(true);
if (!parsed.ok) return;
expect(parsed.value.text).toBe("hello");
expect(parsed.value.agentId).toBe("agent-1");
});
});