Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
3d63374
Run local work on the clicker's own repository-scoped connection
MaggieAppleton Oct 10, 2026
f5197ac
Remove workspace pickers: Run and Build use your own local agent
MaggieAppleton Oct 10, 2026
1387815
Harden repository-scoped connections after security review
MaggieAppleton Oct 10, 2026
8348a61
lane: sync run-on-clicker memory
MaggieAppleton Oct 10, 2026
fb88382
Serialize a run's lifecycle calls and explain refusals to the coding …
MaggieAppleton Oct 10, 2026
e7ccc5e
Propose investigations from the slash menu as inline cards
MaggieAppleton Oct 12, 2026
6b4e68f
Keep investigation cards and their records together
MaggieAppleton Oct 12, 2026
9a51fb9
Anchor the e2e investigate helper on a prose line
MaggieAppleton Oct 12, 2026
728d302
Publish a completed investigation as a child document
MaggieAppleton Oct 12, 2026
71c09a2
Expect the connector's published result as a ready child link
MaggieAppleton Oct 12, 2026
e2ead49
Keep investigation e2e stable when child titles are numbered
MaggieAppleton Oct 12, 2026
894adb8
lane: note investigation child publication constraints
MaggieAppleton Oct 12, 2026
36def55
Check the parent's build lock when a child records or places evidence
MaggieAppleton Oct 12, 2026
623998b
Refuse deleting an investigation's child and scope its lookup to the …
MaggieAppleton Oct 12, 2026
168baa4
Say plainly when a completed investigation's document is gone
MaggieAppleton Oct 12, 2026
dd22f7c
Quiet the investigation view embed and recorded decisions
MaggieAppleton Oct 12, 2026
0b15900
Offer Investigate in the phone Insert panel only where the host does
MaggieAppleton Oct 12, 2026
310cf74
Answer a repeated complete_task at once and document block_task on a …
MaggieAppleton Oct 12, 2026
de704c7
Keep investigations out of child documents and stale cancels off runn…
MaggieAppleton Oct 12, 2026
653cdee
Renew design-contract review for the rebased investigation work
MaggieAppleton Oct 12, 2026
0ab9a71
Open the phone investigation e2e for editing before typing the slash …
MaggieAppleton Oct 12, 2026
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 01M4JHF89WXY4D1JD3785H1TEJ
anchor: method assign
created: 2026-10-10T09:14:28Z
norm: '1'
sig: f8675142fa786e15
body_hash: 0b2572e2b4c95f54
raw_hash: 6cfbb54a446f4e21
lines: 124-126
---

A connection serves every document in its repository. Implementation pickup finds its build through assign()/assigned() (connection -> document of its last queued build), set by the Build POST; run tokens carry their own documentId. Both are in-memory, like connections themselves.
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
id: 01M4QMTZTXKP2R3JPG6TY4GFA1
anchor: function investigationDocument
created: 2026-10-12T08:49:30Z
norm: '1'
---

Emit list items joined by single newlines: blank-line-separated items make a loose list that fails the Lexical round trip (room.validate).
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
id: 01M4QMTZX6RSHR0KHHBEVS6F0P
anchor: function owned
created: 2026-10-12T08:49:30Z
norm: '1'
---

A published investigation is reachable from its child document id too (childId); run/retry/cancel stay parent-only.
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
id: 01M4QJKMX1FHTVXFFGXY4P07EG
anchor: function newResearchProjections
created: 2026-10-12T08:10:32Z
norm: '1'
---

Investigation references share Research's browser authority path: room.apply asks the authorizer per type; plan/service.ts allows add only for an existing record in this document and removal only when not queued/running/publishing. Only Research changes are persisted as researchProjections.
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 01M4JHF8ABX8VD8B85DTNNDWFW
anchor: function person
created: 2026-10-10T09:14:28Z
norm: '1'
sig: f9b02acb18645e83
body_hash: edef4e47aa3087f9
raw_hash: 9ed184827227bfb3
lines: 6-8
---

Connections are per person per repository and every e2e test shares octo-org/score, so any test that pairs a connector must sign in as a unique person; a shared 'ana' lets another test's connector pick up the work.
7 changes: 5 additions & 2 deletions apps/connector/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,8 @@ async function connect(args: string[]) {
try {
let { root: _root, ...source } = info;
let pairing = await post("/api/connector/pairings", { ...source, label: basename(info.root) });
console.error(`Approve this workspace in Chopin: ${pairing.url}`);
console.error(`Connect this checkout of ${info.repository} in Chopin: ${pairing.url}`);
console.error(`Confirm code ${pairing.code} in Chopin.`);
let token: string | undefined;
while (!abort.signal.aborted && !token) {
let claim = await post(`/api/connector/pairings/${pairing.id}/claim`, {
Expand All @@ -88,7 +89,9 @@ async function connect(args: string[]) {
}
if (!token) return;
api = await remote(url, token);
console.error("Connected. Waiting for owner-authorized local work. Ctrl-C disconnects.");
console.error(
`Connected. Work you start in any ${info.repository} document runs here. Ctrl-C disconnects.`,
);
while (!abort.signal.aborted) {
let offered = await api.call("wait_for_work", {}, abort.signal) as {
id?: string;
Expand Down
4 changes: 2 additions & 2 deletions apps/server/src/agent/experiment-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export const experimentTools = {
contextSchema,
inputSchema: z.object({}).strict(),
description:
"List this document's local investigations and available workspace labels. This does not start work.",
"List this document's local investigations and their states. This does not start work.",
execute: (_input, { context: { room } }) =>
answer(async () => {
if (!room.investigations) throw new Error("Investigations unavailable");
Expand Down Expand Up @@ -51,7 +51,7 @@ export const experimentTools = {
key: z.string().min(1).max(128),
}).strict(),
description:
"Propose a local investigation from the current member request. Explain the objective and required evidence. A workspace owner must explicitly authorize it in Investigations; this tool never executes code. Reuse key for retries.",
"Propose a local investigation from the current member request. Explain the objective and required evidence. A person must select Run on its card, which runs it on their own local agent; this tool never executes code. Reuse key for retries.",
execute: (input, { context: { room } }) =>
answer(async () => {
let request = room.currentMemberRequest?.();
Expand Down
87 changes: 65 additions & 22 deletions apps/server/src/experiments/connections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ import type { Source } from "@chopin/experiment";
import { fail, fingerprint } from "./service";

export type PairingInput = Omit<Source, "repositoryId"> & { label: string };
/** One owner's checkout of one repository; it serves every document in that repository. */
export type Connection = {
id: string;
documentId: string;
owner: string;
login: string;
sessionId: string;
Expand All @@ -17,35 +17,48 @@ type Pending = {
id: string;
input: PairingInput;
proof: string;
/** Shown by both the connector and the pairing page so a person can cross-check them. */
code: string;
expiresAt: number;
connection?: Connection;
token?: string;
};
export type Grant = {
connectionId: string;
run?: { id: string; generation: number; kind?: "implementation" };
run?: { id: string; documentId: string; generation: number; kind?: "implementation" };
};

const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
function confirmation() {
let bytes = randomBytes(8);
let code = [...bytes].map(byte => CODE_ALPHABET[byte % CODE_ALPHABET.length]).join("");
return `${code.slice(0, 4)}-${code.slice(4)}`;
}

export class Connections {
#pending = new Map<string, Pending>();
#connections = new Map<string, Connection>();
#tokens = new Map<string, Grant>();
#runTokens = new Map<string, string>();
#waiters = new Map<string, Set<() => void>>();
#builds = new Map<string, string>();
#used = new Map<string, string>();
#locks = new Map<string, Promise<unknown>>();
constructor(private now = () => Date.now()) {}
create(input: PairingInput) {
this.sweep();
if (this.#pending.size >= 100) fail("pairing-capacity");
let secret = randomBytes(32).toString("base64url");
let id = crypto.randomUUID();
let code = confirmation();
this.#pending.set(id, {
id,
input,
proof: fingerprint(secret),
code,
expiresAt: this.now() + 300_000,
});
return { id, secret };
return { id, secret, code };
}
pending(id: string) {
let value = this.#pending.get(id);
Expand All @@ -55,23 +68,20 @@ export class Connections {
approve(
id: string,
owner: { id: string; login: string; sessionId: string },
documentId: string,
repositoryId: string,
) {
let value = this.pending(id);
if (value.connection) {
if (value.connection.owner !== owner.id || value.connection.documentId !== documentId) {
fail("already-paired");
}
if (
value.connection.owner !== owner.id
|| value.connection.source.repositoryId !== repositoryId
) fail("already-paired");
return value.connection;
}
if (this.list(documentId).filter(item => item.owner === owner.id).length >= 8) {
fail("connection-limit");
}
if (this.list(repositoryId, owner.id).length >= 8) fail("connection-limit");
let { label, ...source } = value.input;
let connection: Connection = {
id: crypto.randomUUID(),
documentId,
owner: owner.id,
login: owner.login,
sessionId: owner.sessionId,
Expand Down Expand Up @@ -103,31 +113,63 @@ export class Connections {
touch(connection: Connection) {
connection.expiresAt = this.now() + 90_000;
}
list(documentId: string) {
/** Live connections for a repository, most recently heard from first. */
list(repositoryId: string, owner?: string) {
return [...this.#connections.values()].filter(value =>
value.documentId === documentId && value.expiresAt > this.now()
value.source.repositoryId === repositoryId && value.expiresAt > this.now()
&& (owner === undefined || value.owner === owner)
).sort((a, b) => b.expiresAt - a.expiresAt);
}
/** Remember which document holds the build a connection was last given. */
assign(connectionId: string, documentId: string) {
if (this.#connections.has(connectionId)) this.#builds.set(connectionId, documentId);
}
assigned(connectionId: string) {
return this.#builds.get(connectionId);
}
/** Forget a finished, deleted, or unreachable build so it cannot strand the connection. */
release(connectionId: string, documentId: string) {
if (this.#builds.get(connectionId) === documentId) this.#builds.delete(connectionId);
}
/** Remember the connection that last ran work for a document, to prefer it next time. */
use(documentId: string, connectionId: string) {
this.#used.set(documentId, connectionId);
}
/** Live connections for a repository, the one last used for `documentId` first. */
candidates(repositoryId: string, owner: string, documentId: string) {
let last = this.#used.get(documentId);
return this.list(repositoryId, owner).sort((a, b) =>
Number(b.id === last) - Number(a.id === last)
);
}
runToken(connectionId: string, id: string, generation: number, kind?: "implementation") {
runToken(
connectionId: string,
documentId: string,
id: string,
generation: number,
kind?: "implementation",
) {
let key = `${connectionId}:${id}:${generation}:${kind ?? "experiment"}`;
let existing = this.#runTokens.get(key);
if (existing) return existing;
let token = randomBytes(32).toString("base64url");
this.#runTokens.set(key, token);
this.#tokens.set(fingerprint(token), {
connectionId,
run: { id, generation, ...(kind ? { kind } : {}) },
run: { id, documentId, generation, ...(kind ? { kind } : {}) },
});
return token;
}
revoke(id: string) {
let connection = this.#connections.get(id);
this.#connections.delete(id);
this.#builds.delete(id);
for (let [document, used] of this.#used) if (used === id) this.#used.delete(document);
for (let key of this.#runTokens.keys()) {
if (key.startsWith(`${id}:`)) this.#runTokens.delete(key);
}
for (let [key, grant] of this.#tokens) if (grant.connectionId === id) this.#tokens.delete(key);
if (connection) this.wake(connection.documentId);
if (connection) this.wake(connection.source.repositoryId);
}
revokeSession(sessionId: string) {
for (let connection of this.#connections.values()) {
Expand All @@ -142,17 +184,18 @@ export class Connections {
if (connection.expiresAt <= this.now()) this.revoke(id);
}
}
wake(documentId: string) {
for (let resolve of this.#waiters.get(documentId) ?? []) resolve();
/** Wake every connector waiting for work in a repository. */
wake(repositoryId: string) {
for (let resolve of this.#waiters.get(repositoryId) ?? []) resolve();
}
wait(documentId: string, signal: AbortSignal) {
wait(repositoryId: string, signal: AbortSignal) {
return new Promise<void>(resolve => {
let set = this.#waiters.get(documentId) ?? new Set();
this.#waiters.set(documentId, set);
let set = this.#waiters.get(repositoryId) ?? new Set();
this.#waiters.set(repositoryId, set);
let finish = () => {
clearTimeout(timer);
set.delete(finish);
if (!set.size) this.#waiters.delete(documentId);
if (!set.size) this.#waiters.delete(repositoryId);
signal.removeEventListener("abort", finish);
resolve();
};
Expand Down
76 changes: 76 additions & 0 deletions apps/server/src/experiments/document.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { expect, test } from "bun:test";
import { limits, parse } from "@chopin/dialect";
import { parseResult } from "@chopin/experiment";
import { performance } from "@chopin/experiment/fixtures";
import { canonical } from "../mcp/create";
import { investigationDocument, investigationSummary } from "./document";

const ID = "6f1c3c2e-8b0a-4c43-9d55-2d6f3c1b9a10";

test("a completed result becomes a research-shaped child that round-trips the dialect", () => {
let document = investigationDocument(ID, "Does caching help startup?", performance);
expect(document.title).toBe("Startup comparison");
expect(canonical(document.source)).toEqual({ source: document.source });
let headings = document.source.split("\n").filter(line => line.startsWith("#"));
expect(headings).toEqual(["# Startup comparison", "## Views", "## Method", "## Limitations"]);
expect(document.source).toContain("Caching reduced median startup time");
let embeds = JSON.stringify(parse(document.source)).match(/"name":"Experiment"/g);
expect(embeds).toHaveLength(1);
expect(document.source).toMatch(
new RegExp(`<Experiment id="[0-9A-Z]{26}" experiment="${ID}" view="comparison" />`),
);
expect(investigationSummary("Does caching help startup?", performance)).toBe(
"Caching reduced median startup time in the captured workload.",
);
});

test("the brief names a report without its own heading, and markup stays inert", () => {
let result = parseResult({
...performance,
report: "Plain finding with `code` and {braces}.\n",
views: [],
evidence: [{ key: "log", title: "Run <log>", format: "plain", text: "```\n{x}\n```" }],
provenance: {
environment: "Laptop {env}",
checks: ["<b>ran</b>", "Compared medians"],
limitations: ["One", "Two"],
},
});
let document = investigationDocument(ID, "Measure *startup*\nwith detail", result);
expect(document.title).toBe("Measure startup");
expect(document.source.startsWith("# Measure startup\n")).toBe(true);
expect(canonical(document.source)).toEqual({ source: document.source });
expect(document.source).not.toContain("<Experiment");
expect(document.source).toContain("````\n```\n{x}\n```\n````");
});

test("evidence is clipped to keep the child inside the source limit", () => {
let result = parseResult({
...performance,
evidence: [..."abcdefgh"].map(key => ({
key,
title: key,
format: "plain",
text: "x".repeat(64 * 1024),
})),
report: `# Big\n\n${"word ".repeat(20_000)}\n`,
});
let document = investigationDocument(ID, "Big", result);
expect(Buffer.byteLength(document.source)).toBeLessThanOrEqual(limits.MAX_SOURCE_BYTES);
expect(document.source).toContain("## Evidence");
});

test("a report cannot embed record-backed components into its child", () => {
for (
let component of [
`<Investigation id="${ID}" />`,
`<Experiment id="01K0N4W3B7P27CBAEC7A8C8WEH" experiment="${ID}" view="comparison" />`,
`<Research id="${ID}" />`,
]
) {
let result = parseResult({ ...performance, report: `# Report\n\n${component}\n` });
expect(() => investigationDocument(ID, "Brief", result)).toThrow(
"A report cannot embed record-backed components.",
);
}
});
Loading
Loading