Skip to content

Commit 7ed22d0

Browse files
authored
feat(workspace): connect evidence and practice (#62)
* feat(workspace): connect evidence and practice * test(workspace): cover evidence and practice journey * fix(workspace): enforce shared AI boundaries * fix(workspace): make team generation server-owned * fix(workspace): isolate practice executions * fix(workspace): make practice retries idempotent * fix(workspace): replay durable practice safely * test(workspace): cover router and Deno assertions * test(workspace): cover sparring score boundaries * test(workspace): cover session restore lifecycle
1 parent ae57f46 commit 7ed22d0

37 files changed

Lines changed: 2428 additions & 111 deletions

‎e2e/workspace-collaboration.spec.ts‎

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { expect, test, type Browser, type BrowserContext, type Page } from "@pla
33
import { spawn, type ChildProcess } from "node:child_process";
44
import { createConnection } from "node:net";
55
import { kill as killProcess } from "node:process";
6+
import type { WorkspaceToolContext } from "../src/lib/workspace/tool-context";
67

78
const supabaseUrl = process.env.LOCAL_SUPABASE_URL;
89
const anonKey = process.env.LOCAL_SUPABASE_ANON_KEY;
@@ -348,3 +349,142 @@ test("real local users collaborate, recover, review, restore, and revoke access"
348349
await Promise.allSettled(ids.map((id) => admin.auth.admin.deleteUser(id)));
349350
}
350351
});
352+
353+
test("workspace evidence, team AI review, and practice survive a teammate reload", async ({ browser }, testInfo) => {
354+
test.skip(testInfo.project.name === "mobile-chromium", "The core mobile collaboration journey runs above");
355+
test.setTimeout(120_000);
356+
const admin = createClient(supabaseUrl!, serviceKey!, { auth: { persistSession: false } });
357+
const password = `Local-${crypto.randomUUID()}!`;
358+
const ids: string[] = [];
359+
const contexts: BrowserContext[] = [];
360+
let collaboration: ChildProcess | undefined;
361+
362+
try {
363+
const sessions: Session[] = [];
364+
for (const prefix of ["practice-owner", "practice-member"]) {
365+
const email = `${prefix}-${Date.now()}-${crypto.randomUUID()}@workspace.test`;
366+
const created = await admin.auth.admin.createUser({ email, email_confirm: true, password });
367+
if (created.error || !created.data.user) throw created.error ?? new Error("User creation failed");
368+
ids.push(created.data.user.id);
369+
const client = createClient(supabaseUrl!, anonKey!, { auth: { persistSession: false } });
370+
const signedIn = await client.auth.signInWithPassword({ email, password });
371+
if (signedIn.error || !signedIn.data.session) throw signedIn.error ?? new Error("Sign in failed");
372+
sessions.push(signedIn.data.session);
373+
}
374+
375+
collaboration = await startCollaboration();
376+
const owner = await authenticatedPage(browser, sessions[0]);
377+
const member = await authenticatedPage(browser, sessions[1]);
378+
contexts.push(owner.context, member.context);
379+
await owner.page.route("**/functions/v1/ai", async (route) => {
380+
const body = route.request().postDataJSON() as {
381+
input?: { message?: string };
382+
workspaceContext?: WorkspaceToolContext;
383+
};
384+
const workspaceContext = body.workspaceContext;
385+
if (workspaceContext?.threadId && workspaceContext.threadRequest && body.input?.message) {
386+
const started = await admin.rpc("start_workspace_thread_generation", {
387+
p_assistant_message_id: workspaceContext.threadRequest.assistantMessageId,
388+
p_content: body.input.message,
389+
p_request_id: workspaceContext.requestId,
390+
p_thread_id: workspaceContext.threadId,
391+
p_user_id: ids[0],
392+
p_user_message_id: workspaceContext.threadRequest.userMessageId,
393+
p_workspace_id: workspaceContext.workspaceId,
394+
});
395+
if (started.error) throw started.error;
396+
}
397+
await route.fulfill({
398+
body: JSON.stringify({ output: { response: "Phones should be limited with a clear enforcement mechanism.", followUpQuestions: [], uncertainties: [] } }),
399+
contentType: "application/json",
400+
status: 200,
401+
});
402+
});
403+
404+
await owner.page.goto("/app/workspaces");
405+
await owner.page.getByRole("textbox", { name: "Motion" }).fill("Schools should limit phones");
406+
await owner.page.getByRole("button", { name: "Create workspace" }).click();
407+
await expect(owner.page).toHaveURL(/\/app\/workspaces\/[0-9a-f-]+$/, { timeout: 20_000 });
408+
const workspaceUrl = owner.page.url();
409+
const workspaceId = workspaceUrl.split("/").at(-1)!;
410+
const membership = await admin.from("workspace_members").insert({ workspace_id: workspaceId, user_id: ids[1] });
411+
if (membership.error) throw membership.error;
412+
413+
await owner.page.getByRole("button", { name: "R1" }).click();
414+
await owner.page.locator(".ProseMirror").fill("Phones interrupt learning");
415+
await expect(owner.page.getByText("Saved", { exact: true })).toBeVisible();
416+
417+
await openPanel(owner.page, "Evidence");
418+
await owner.page.getByLabel("Source title").fill("Attention study");
419+
await owner.page.getByLabel("Quoted evidence").fill("Notifications reduce sustained attention.");
420+
await owner.page.getByRole("button", { name: "Add evidence" }).click();
421+
await expect(owner.page.getByText("Attention study", { exact: true })).toBeVisible();
422+
await owner.page.getByRole("button", { name: "Insert citation" }).click();
423+
await expect(owner.page.locator(".ProseMirror")).toContainText("Attention study");
424+
425+
await openPanel(owner.page, "Team threads");
426+
await owner.page.getByLabel("Thread name").fill("Both sides");
427+
await owner.page.getByRole("button", { name: "Create", exact: true }).click();
428+
await owner.page.getByLabel("Team message").fill("Compare both sides");
429+
await owner.page.getByRole("button", { name: "Send", exact: true }).click();
430+
let pendingMessage: { request_id: string; thread_id: string } | null = null;
431+
await expect.poll(async () => {
432+
const result = await admin.from("workspace_thread_messages")
433+
.select("request_id,thread_id").eq("workspace_id", workspaceId).eq("role", "assistant").eq("status", "progress").maybeSingle();
434+
if (result.error) throw result.error;
435+
pendingMessage = result.data;
436+
return Boolean(pendingMessage);
437+
}).toBe(true);
438+
if (!pendingMessage) throw new Error("Thread progress was not stored");
439+
const completed = await admin.rpc("complete_workspace_thread_generation", {
440+
p_content: "Phones should be limited with a clear enforcement mechanism.",
441+
p_request_id: pendingMessage.request_id,
442+
p_status: "complete",
443+
p_thread_id: pendingMessage.thread_id,
444+
p_user_id: ids[0],
445+
p_workspace_id: workspaceId,
446+
});
447+
if (completed.error) throw completed.error;
448+
await expect(owner.page.getByText(/clear enforcement mechanism/)).toBeVisible();
449+
450+
await openPanel(owner.page, "Practice");
451+
await owner.page.getByRole("button", { name: "Suggest AI rewrite" }).click();
452+
await openPanel(owner.page, "Review");
453+
await expect(owner.page.getByRole("button", { name: "Reject" })).toHaveCount(1);
454+
await owner.page.getByRole("button", { name: "Reject" }).click();
455+
await expect(owner.page.locator(".ProseMirror")).toContainText("Phones interrupt learning");
456+
await openPanel(owner.page, "Practice");
457+
await owner.page.getByRole("button", { name: "Suggest AI rewrite" }).click();
458+
await openPanel(owner.page, "Review");
459+
await expect(owner.page.getByRole("button", { name: "Accept" })).toHaveCount(1);
460+
await owner.page.getByRole("button", { name: "Accept" }).click();
461+
await expect(owner.page.locator(".ProseMirror")).toContainText("clear enforcement mechanism");
462+
463+
await openPanel(owner.page, "Practice");
464+
await owner.page.getByRole("button", { name: "Crossfire", exact: true }).click();
465+
await expect(owner.page).toHaveURL(/\/app\/crossfire$/);
466+
await expect(owner.page.getByTestId("crossfire-resolution")).toHaveValue("Schools should limit phones");
467+
await expect(owner.page.getByText(/Shared workspace session/)).toBeVisible();
468+
await expect.poll(async () => {
469+
const result = await admin.from("workspace_artifacts").select("id").eq("workspace_id", workspaceId).eq("tool", "crossfire");
470+
if (result.error) throw result.error;
471+
return result.data.length;
472+
}).toBe(1);
473+
await owner.page.getByRole("link", { name: "Return to workspace" }).click();
474+
475+
await member.page.goto(workspaceUrl);
476+
await member.page.getByRole("button", { name: "R1" }).click();
477+
await openPanel(member.page, "Practice");
478+
await expect(member.page.locator("summary").filter({ hasText: /Crossfire.*progress/i })).toBeVisible();
479+
await member.page.reload();
480+
await openPanel(member.page, "Practice");
481+
await expect(member.page.locator("summary").filter({ hasText: /Crossfire.*progress/i })).toBeVisible();
482+
483+
await owner.page.goto("/app/ask");
484+
await expect(owner.page.getByText(/Shared workspace session/)).toHaveCount(0);
485+
} finally {
486+
await stop(collaboration);
487+
await Promise.allSettled(contexts.map((context) => context.close()));
488+
await Promise.allSettled(ids.map((id) => admin.auth.admin.deleteUser(id)));
489+
}
490+
});

‎services/collaboration/src/postgres-store.test.ts‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ describe("Postgres collaboration writer locks", () => {
8181
});
8282

8383
it("appends and snapshots only while author membership and epoch are current", async () => {
84+
const { snapshot } = proposalFixture();
8485
const client = new FakeClient();
8586
const pool = { connect: vi.fn().mockResolvedValue(client) };
8687
const store = new PostgresDurableUpdateStore(pool);
@@ -104,7 +105,7 @@ describe("Postgres collaboration writer locks", () => {
104105
documentId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
105106
epoch: 3,
106107
mutationId: "a".repeat(43),
107-
snapshot: new Uint8Array([3, 4]),
108+
snapshot,
108109
syncType: 2,
109110
});
110111

@@ -114,6 +115,8 @@ describe("Postgres collaboration writer locks", () => {
114115
.toBeLessThan(appendQueries.findIndex((text) => text.includes("SELECT state.workspace_id, state.durable_sequence")));
115116
expect(client.query.mock.calls.some(([text]) => String(text).startsWith("INSERT INTO private.workspace_document_updates"))).toBe(true);
116117
expect(client.query.mock.calls.some(([text]) => String(text).startsWith("UPDATE private.workspace_document_state"))).toBe(true);
118+
const projectionWrite = client.query.mock.calls.find(([text]) => String(text).startsWith("UPDATE private.workspace_document_state"));
119+
expect(projectionWrite?.[1]?.[3]).toBe("Hello world");
117120
expect(client.query).toHaveBeenCalledWith("COMMIT");
118121
await release();
119122
});
@@ -145,6 +148,7 @@ describe("Postgres collaboration writer locks", () => {
145148
});
146149

147150
it("loads the current snapshot and durable acknowledgement from the writer session", async () => {
151+
const { snapshot } = proposalFixture();
148152
const client = new FakeClient();
149153
const pool = { connect: vi.fn().mockResolvedValue(client) };
150154
const store = new PostgresDurableUpdateStore(pool);
@@ -156,7 +160,7 @@ describe("Postgres collaboration writer locks", () => {
156160
client.query.mockReset();
157161
client.query.mockImplementation(async (text: string) => {
158162
if (text.includes("SELECT snapshot")) {
159-
return { rows: [{ snapshot: new Uint8Array([9, 8]) }] };
163+
return { rows: [{ snapshot, text_projection: "" }] };
160164
}
161165
return {
162166
rows: [{
@@ -171,7 +175,8 @@ describe("Postgres collaboration writer locks", () => {
171175
await expect(store.loadSnapshot(
172176
"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
173177
3,
174-
)).resolves.toEqual(new Uint8Array([9, 8]));
178+
)).resolves.toEqual(snapshot);
179+
expect(client.query.mock.calls.some(([text]) => String(text).includes("SET text_projection"))).toBe(true);
175180
await expect(store.find(
176181
"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
177182
3,

‎services/collaboration/src/postgres-store.ts‎

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import { createHash } from "node:crypto";
22
import { z } from "zod";
33
import * as Y from "yjs";
4+
import { yXmlFragmentToProsemirrorJSON } from "@tiptap/y-tiptap";
45
import {
56
acceptProposalHunkInDocument,
67
deserializeProposal,
78
rejectProposalHunks,
89
serializeProposal,
910
} from "../../../src/lib/workspace/review";
10-
import { validateWorkspaceDocument } from "../../../src/lib/workspace/schema";
11+
import { validateWorkspaceDocument, WORKSPACE_CONTENT_FIELD, workspaceSchema } from "../../../src/lib/workspace/schema";
1112
import type { DurableAppend, DurableUpdate, DurableUpdateStore } from "./server";
1213

1314
export interface PgClient {
@@ -42,7 +43,7 @@ const durableRowSchema = z.object({
4243
sync_type: z.number(),
4344
});
4445
const storedUpdateRowSchema = durableRowSchema.extend({ mutation_id: z.string() });
45-
const snapshotRowSchema = z.object({ snapshot: z.instanceof(Uint8Array) });
46+
const snapshotRowSchema = z.object({ snapshot: z.instanceof(Uint8Array), text_projection: z.string().optional() });
4647
const authorizationRowSchema = z.object({ allowed: z.boolean() });
4748
const proposalRowSchema = z.object({
4849
durable_sequence: bigintSchema,
@@ -52,6 +53,18 @@ const proposalRowSchema = z.object({
5253
workspace_id: z.string().uuid(),
5354
});
5455

56+
function snapshotText(snapshot: Uint8Array) {
57+
const document = new Y.Doc();
58+
try {
59+
if (snapshot.byteLength) Y.applyUpdate(document, snapshot);
60+
return workspaceSchema.nodeFromJSON(
61+
yXmlFragmentToProsemirrorJSON(document.getXmlFragment(WORKSPACE_CONTENT_FIELD)),
62+
).textContent.slice(0, 100_000);
63+
} finally {
64+
document.destroy();
65+
}
66+
}
67+
5568
export class PostgresDurableUpdateStore implements DurableUpdateStore {
5669
private readonly writers = new Map<string, Writer>();
5770

@@ -84,14 +97,26 @@ export class PostgresDurableUpdateStore implements DurableUpdateStore {
8497
}
8598

8699
async loadSnapshot(documentId: string, epoch: number) {
87-
const result = await this.getWriter(documentId, epoch).query(
88-
`SELECT snapshot
100+
const client = this.getWriter(documentId, epoch);
101+
const result = await client.query(
102+
`SELECT snapshot, text_projection
89103
FROM private.workspace_document_state
90104
WHERE document_id = $1 AND epoch = $2`,
91105
[documentId, epoch],
92106
);
93107
const input = result.rows[0];
94-
return input ? new Uint8Array(snapshotRowSchema.parse(input).snapshot) : undefined;
108+
if (!input) return undefined;
109+
const row = snapshotRowSchema.parse(input);
110+
const snapshot = new Uint8Array(row.snapshot);
111+
if (!row.text_projection) {
112+
await client.query(
113+
`UPDATE private.workspace_document_state
114+
SET text_projection = $3
115+
WHERE document_id = $1 AND epoch = $2 AND text_projection = ''`,
116+
[documentId, epoch, snapshotText(snapshot)],
117+
);
118+
}
119+
return snapshot;
95120
}
96121

97122
async load(documentId: string, epoch: number): Promise<DurableUpdate[]> {
@@ -204,9 +229,9 @@ export class PostgresDurableUpdateStore implements DurableUpdateStore {
204229
);
205230
await client.query(
206231
`UPDATE private.workspace_document_state
207-
SET durable_sequence = $2, snapshot = $3, updated_at = now()
232+
SET durable_sequence = $2, snapshot = $3, text_projection = $4, updated_at = now()
208233
WHERE document_id = $1`,
209-
[update.documentId, sequence, Buffer.from(update.snapshot)],
234+
[update.documentId, sequence, Buffer.from(update.snapshot), snapshotText(update.snapshot)],
210235
);
211236
await client.query(
212237
`UPDATE public.workspace_documents
@@ -374,9 +399,9 @@ export class PostgresDurableUpdateStore implements DurableUpdateStore {
374399
);
375400
await client.query(
376401
`UPDATE private.workspace_document_state
377-
SET durable_sequence = $2, snapshot = $3, updated_at = now()
402+
SET durable_sequence = $2, snapshot = $3, text_projection = $4, updated_at = now()
378403
WHERE document_id = $1`,
379-
[input.documentId, sequence, Buffer.from(snapshot)],
404+
[input.documentId, sequence, Buffer.from(snapshot), snapshotText(snapshot)],
380405
);
381406
await client.query(
382407
`UPDATE public.workspace_documents

‎src/components/features/sparring/SparringSetup.tsx‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export interface SparringSetupValues {
2626
interface SparringSetupProps {
2727
onStart: (values: SparringSetupValues) => void;
2828
isStarting?: boolean;
29+
initialValues?: Partial<SparringSetupValues>;
2930
}
3031

3132
const DIFFICULTIES: Array<{ id: SparringDifficulty; name: string }> = [
@@ -34,10 +35,10 @@ const DIFFICULTIES: Array<{ id: SparringDifficulty; name: string }> = [
3435
{ id: 'hard', name: 'Ruthless' },
3536
];
3637

37-
export function SparringSetup({ onStart, isStarting = false }: SparringSetupProps) {
38+
export function SparringSetup({ onStart, isStarting = false, initialValues }: SparringSetupProps) {
3839
const { t } = useTranslation();
39-
const [motion, setMotion] = useState('');
40-
const [userSide, setUserSide] = useState<SparringSide>('pro');
40+
const [motion, setMotion] = useState(initialValues?.motion ?? '');
41+
const [userSide, setUserSide] = useState<SparringSide>(initialValues?.userSide ?? 'pro');
4142
const [format, setFormat] = useState<SparringFormat>('standard');
4243
const [difficulty, setDifficulty] = useState<SparringDifficulty>('medium');
4344
const [isOnline, setIsOnline] = useState(() => typeof navigator === 'undefined' || navigator.onLine);

0 commit comments

Comments
 (0)