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
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ AGENT=on
# HARNESS=atomic requires a provider/model ID from Atomic's catalog (for example
# vercel-ai-gateway/anthropic/claude-sonnet-4.6) and fails a turn on any other.
MODEL=gpt-6-luna
# A small, fast model for History's one-line session summaries. Defaults to MODEL.
# HISTORY_MODEL=
HARNESS=copilot-sdk
# Auth mode forwarded to the selected harness. The current copilot-sdk adapter
# uses each owner's token and does not consume it. Host-login fallback modes
Expand Down
64 changes: 64 additions & 0 deletions apps/server/src/channels/history.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { describe, expect, test } from "bun:test";

import { MemoryStorage } from "../storage/memory/adapter";
import { DocumentHistory, VERSION_IDLE_MS } from "./history";

async function storage() {
let storage = new MemoryStorage();
let now = new Date("2026-10-14T10:00:00.000Z");
await storage.users.put({ id: "U_mona", login: "mona", avatarUrl: "", now });
await storage.users.put({ id: "U_hubot", login: "hubot", avatarUrl: "", now });
await storage.channels.create({
id: "channel",
repositoryId: "R_score",
repositoryOwner: "octo-org",
repositoryName: "score",
title: "Release plan",
createdBy: "U_mona",
now,
});
return storage;
}

describe("document history", () => {
test("groups each author's edits into sessions and credits Chopin's own", async () => {
let store = await storage();
let now = new Date("2026-10-14T10:00:00.000Z");
let history = new DocumentHistory(store, () => now);
let edit = (revision: number, author?: string) =>
history.record({
channelId: "channel",
revision,
source: `# Plan ${revision}\n`,
sourceHash: `hash-${revision}`,
...(author ? { author } : {}),
});

await Promise.all([edit(1, "U_mona"), edit(2, "U_mona")]);
now = new Date(now.getTime() + 60_000);
await edit(3);
await edit(4, "U_hubot");
now = new Date(now.getTime() + VERSION_IDLE_MS + 1);
await edit(5, "U_hubot");

let versions = await store.versions.list("channel", 10);
expect(versions.map(version => [version.revision, version.authorLogin])).toEqual([
[5, "hubot"],
[4, "hubot"],
[3, undefined],
[2, "mona"],
]);
});

test("logs rather than throws when a version cannot be recorded", async () => {
let store = await storage();
let history = new DocumentHistory(store);
await history.record({
channelId: "missing",
revision: 1,
source: "# Plan\n",
sourceHash: "hash",
});
await history.flush();
});
});
52 changes: 52 additions & 0 deletions apps/server/src/channels/history.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { DocumentTarget } from "../plan/service";
import type { StorageAdapter } from "../storage/port";

/** Edits by one author closer together than this read as one session in Activity. */
export const VERSION_IDLE_MS = 10 * 60_000;

/**
* Records each persisted document change into its editing session.
*
* History is derived from commits that already happened, so a failure here is logged and
* never blocks or rolls back an edit. One document's records run in order, so a session is
* always extended from the change before it.
*/
export class DocumentHistory {
#queues = new Map<string, Promise<void>>();

constructor(
private storage: StorageAdapter,
private clock: () => Date = () => new Date(),
) {}

record(target: DocumentTarget): Promise<void> {
let now = this.clock();
let previous = this.#queues.get(target.channelId) ?? Promise.resolve();
let next = previous.then(async () => {
try {
await this.storage.versions.record({
channelId: target.channelId,
...(target.author ? { authorId: target.author } : {}),
revision: target.revision,
source: target.source,
sourceHash: target.sourceHash,
now,
idleMs: VERSION_IDLE_MS,
...(target.newSession ? { newSession: true } : {}),
});
} catch (err) {
console.warn(`[history] could not record a version of ${target.channelId}:`, err);
}
});
this.#queues.set(target.channelId, next);
void next.then(() => {
if (this.#queues.get(target.channelId) === next) this.#queues.delete(target.channelId);
});
return next;
}

/** Settles every pending record, for shutdown. */
async flush(): Promise<void> {
await Promise.all(this.#queues.values());
}
}
141 changes: 141 additions & 0 deletions apps/server/src/channels/routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,8 @@ async function setup(random?: () => number) {
let archived: Array<{ id: string; changed: boolean }> = [];
let restored: Array<{ id: string; changed: boolean }> = [];
let deleted: string[] = [];
let versionRestores: Array<{ id: string; source: string; author: string }> = [];
let summaryRequests: Array<{ id: string; versions: { id: string; sourceHash: string }[] }> = [];
registerChannelRoutes(router, auth, {
renameChannel: async (id, title) => {
let opened = await Service.open(id, {
Expand Down Expand Up @@ -248,6 +250,14 @@ async function setup(random?: () => number) {
restored.push({ id, changed: result.changed });
return result;
},
onVersionSummaries: async (id, versions) => {
summaryRequests.push({ id, versions });
return "pending";
},
onVersionRestore: async (id, source, author) => {
versionRestores.push({ id, source, author });
return { kind: "restored", revision: 7 };
},
random,
references: new GitHubReferences(referenceSource),
});
Expand All @@ -264,6 +274,8 @@ async function setup(random?: () => number) {
archived,
restored,
deleted,
versionRestores,
summaryRequests,
now,
};
}
Expand Down Expand Up @@ -1092,6 +1104,135 @@ describe("channel routes", () => {
expect(response!.status).toBe(404);
});

it("lists a readable document's editing sessions and serves each one's source", async () => {
let { router, storage, github, cookie, now } = await setup();
let channel = await createChannel(storage, now, "History");
let record = (revision: number, minutes: number, authorId?: string) =>
storage.versions.record({
channelId: channel.id,
...(authorId ? { authorId } : {}),
revision,
source: `# Plan ${revision}\n`,
sourceHash: `hash-${revision}`,
now: new Date(now.getTime() + minutes * 60_000),
idleMs: 10 * 60_000,
});
let first = await record(1, 1, "U_hubot");
await record(2, 2);

let listed = await router.handle(request(`/api/channels/${channel.id}/versions`, cookie));
expect(listed!.status).toBe(200);
let body = await listed!.json();
expect(body.created).toEqual({
at: now.toISOString(),
author: { login: "octocat", avatarUrl: "avatar" },
});
expect(body.versions.map((version: { revision: number }) => version.revision)).toEqual([2, 1]);
expect(body.versions[0].author).toBeUndefined();
expect(body.versions[1].author).toEqual({ login: "hubot", avatarUrl: "avatar" });

let version = await router.handle(
request(`/api/channels/${channel.id}/versions/${first!.id}`, cookie),
);
expect(await version!.json()).toMatchObject({ revision: 1, source: "# Plan 1\n" });
let unknown = await router.handle(
request(`/api/channels/${channel.id}/versions/${crypto.randomUUID()}`, cookie),
);
expect(unknown!.status).toBe(404);

expect((await router.handle(request(`/api/channels/${channel.id}/versions`)))!.status)
.toBe(401);
github.repo = { ...github.repo, permissions: { pull: false, push: false, admin: false } };
let denied = await router.handle(
request(`/api/channels/${channel.id}/versions/${first!.id}`, cookie),
);
expect(denied!.status).toBe(404);
});

it("asks for summaries of the newest sessions that have none", async () => {
let { router, storage, cookie, now, summaryRequests } = await setup();
let channel = await createChannel(storage, now, "Summaries");
let record = (revision: number, minutes: number, authorId?: string) =>
storage.versions.record({
channelId: channel.id,
...(authorId ? { authorId } : {}),
revision,
source: `# Plan ${revision}\n`,
sourceHash: `hash-${revision}`,
now: new Date(now.getTime() + minutes * 60_000),
idleMs: 10 * 60_000,
});
let first = await record(1, 1, "U_hubot");
let second = await record(2, 2);
await storage.versions.summarize({
channelId: channel.id,
id: first!.id,
sourceHash: "hash-1",
summary: "Started the plan",
});
let ask = () =>
router.handle(
request(`/api/channels/${channel.id}/version-summaries`, cookie, {
method: "POST",
headers: { origin: "https://chopin.test" },
}),
);

expect(await (await ask())!.json()).toEqual({ state: "pending" });
expect(summaryRequests).toEqual([{
id: channel.id,
versions: [{ id: second!.id, sourceHash: "hash-2" }],
}]);
let listed = await (await router.handle(
request(`/api/channels/${channel.id}/versions`, cookie),
))!.json();
expect(listed.versions[1].summary).toBe("Started the plan");

await storage.versions.summarize({
channelId: channel.id,
id: second!.id,
sourceHash: "hash-2",
summary: "Retitled it",
});
expect(await (await ask())!.json()).toEqual({ state: "complete" });
expect(summaryRequests).toHaveLength(1);
});

it("restores an earlier session for a writer and refuses everyone else", async () => {
let { router, storage, github, cookie, now, versionRestores } = await setup();
let channel = await createChannel(storage, now, "Restorable");
let version = await storage.versions.record({
channelId: channel.id,
revision: 1,
source: "# Earlier\n",
sourceHash: "hash-1",
now,
idleMs: 10 * 60_000,
});
let restore = (versionId: string, origin = "https://chopin.test") =>
router.handle(
request(`/api/channels/${channel.id}/versions/${versionId}/restore`, cookie, {
method: "POST",
headers: { origin },
}),
);

expect((await restore(version!.id, "https://elsewhere.test"))!.status).toBe(403);
expect((await restore(crypto.randomUUID()))!.status).toBe(404);
let restored = await restore(version!.id);
expect(restored!.status).toBe(200);
expect(await restored!.json()).toEqual({ revision: 7 });
expect(versionRestores).toEqual([{
id: channel.id,
source: "# Earlier\n",
author: "U_octocat",
}]);

github.repo = { ...github.repo, permissions: { pull: true, push: false, admin: false } };
expect((await restore(version!.id))!.status).toBe(403);
expect(versionRestores).toHaveLength(1);
});

it("lets an editor explicitly release the Copilot owner", async () => {
let { router, storage, cookie, sessionId, reset, now } = await setup();
let channel = await storage.channels.create({
Expand Down
Loading
Loading