diff --git a/AGENTS.md b/AGENTS.md index e4f79dc43..1ad5f654e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -138,6 +138,23 @@ Azure stack itself is `infra/azure/`. provider** and have nothing to do with the retired runtime. Do not remove those two, and do not add the rest back. +## Focused investigation and execution + +Operate with a strict token budget. Before opening a file, determine whether it +is necessary. Locate relevant code with repository search and inspect only the +required ranges. + +- Use `rg` for symbol/text discovery, limited-range readers for files, targeted + `git diff -- `, and targeted test files or cases. +- Form a debugging hypothesis before gathering the minimum evidence needed to + confirm or reject it. +- Do not read whole large files, recursively inspect directories without a + reason, reread unchanged files, print full diffs where a targeted diff is + enough, run verbose tests unless debugging, explain routine actions, research + alternatives after finding a satisfactory implementation, or modify adjacent + code merely because it could be improved. +- Once the requested change is implemented and verified, stop. + ## UI & Design (required skills) Every change that touches the interface — pages, components, layout, spacing, diff --git a/apps/web/app/api/gen2/workspaces/[workspaceId]/collaboration/route.ts b/apps/web/app/api/gen2/workspaces/[workspaceId]/collaboration/route.ts new file mode 100644 index 000000000..0ff07eec4 --- /dev/null +++ b/apps/web/app/api/gen2/workspaces/[workspaceId]/collaboration/route.ts @@ -0,0 +1,54 @@ +import { experimental_upgradeWebSocket } from "@vercel/functions"; +import { eq } from "drizzle-orm"; + +import { schema } from "@codev/db"; + +import { + handleGen2CollaborationSocket, + gen2CollaborationSocketMaxPayload, +} from "@/lib/gen2/collaboration-socket"; +import { requireGen2Member } from "@/lib/gen2/workspaces"; +import { apiError } from "@/lib/http/api"; +import { withUser } from "@/lib/http/api-route"; +import { getDatabase } from "@/lib/platform/database"; + +type Params = { workspaceId: string }; + +export const dynamic = "force-dynamic"; +export const maxDuration = 300; + +/** Authenticated browser-only transport for a Gen 2 shared document. */ +export const GET = withUser( + async ({ user: sessionUser, params: { workspaceId } }) => { + const [membership, user] = await Promise.all([ + requireGen2Member(workspaceId, sessionUser.id), + getDatabase() + .select({ + id: schema.users.id, + login: schema.users.login, + name: schema.users.name, + avatarUrl: schema.users.avatarUrl, + }) + .from(schema.users) + .where(eq(schema.users.id, sessionUser.id)) + .limit(1) + .then((rows) => rows[0]), + ]); + if (!user) return apiError(new Error("Workspace not found."), 404); + try { + return await experimental_upgradeWebSocket( + (socket) => + handleGen2CollaborationSocket(workspaceId, socket, user, { + canEdit: membership.role !== "viewer", + }), + { maxPayload: gen2CollaborationSocketMaxPayload }, + ); + } catch { + return apiError( + new Error("Realtime collaboration is temporarily unavailable."), + 503, + ); + } + }, + { errorStatus: 503 }, +); diff --git a/apps/web/app/api/gen2/workspaces/[workspaceId]/superset/entry/route.test.ts b/apps/web/app/api/gen2/workspaces/[workspaceId]/superset/entry/route.test.ts index 3e624602f..288e9f470 100644 --- a/apps/web/app/api/gen2/workspaces/[workspaceId]/superset/entry/route.test.ts +++ b/apps/web/app/api/gen2/workspaces/[workspaceId]/superset/entry/route.test.ts @@ -3,6 +3,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ getApiUser: vi.fn(), create: vi.fn(), + move: vi.fn(), + remove: vi.fn(), })); vi.mock("@/lib/http/api", () => ({ @@ -16,9 +18,11 @@ vi.mock("@/lib/http/api", () => ({ })); vi.mock("@/lib/gen2/superset", () => ({ createGen2SupersetEntry: mocks.create, + moveGen2SupersetEntry: mocks.move, + deleteGen2SupersetEntry: mocks.remove, })); -import { POST } from "./route"; +import { DELETE, PATCH, POST } from "./route"; const workspaceId = "e010bd2c-a3c1-438f-acef-166287a3b1cb"; const userId = "2f2387ed-4a63-4b05-88cc-266d65f7b82b"; @@ -33,17 +37,30 @@ function post(body: unknown) { }); } +function request(method: "PATCH" | "DELETE", body: unknown) { + return new Request(url, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + describe("Superset entry route", () => { beforeEach(() => mocks.getApiUser.mockResolvedValue({ id: userId })); afterEach(() => vi.resetAllMocks()); - it("creates a root-level file through the authenticated bridge", async () => { + it("creates a file in the selected folder through the authenticated bridge", async () => { mocks.create.mockResolvedValue({ path: "notes.md", kind: "file", size: 0, }); - const input = { worktreeId: "main", name: "notes.md", kind: "file" }; + const input = { + worktreeId: "main", + parentPath: "notes", + name: "notes.md", + kind: "file", + }; const response = await POST(post(input), { params }); expect(mocks.create).toHaveBeenCalledWith(workspaceId, userId, input); expect(await response.json()).toEqual({ @@ -51,6 +68,54 @@ describe("Superset entry route", () => { }); }); + it("keeps root-level creation compatible when no parent path is sent", async () => { + mocks.create.mockResolvedValue({ path: "notes.md", kind: "file", size: 0 }); + + const response = await POST( + post({ worktreeId: "main", name: "notes.md", kind: "file" }), + { params }, + ); + + expect(mocks.create).toHaveBeenCalledWith(workspaceId, userId, { + worktreeId: "main", + parentPath: "", + name: "notes.md", + kind: "file", + }); + expect(response.status).toBe(200); + }); + + it("renames an entry through the authenticated bridge", async () => { + const input = { + worktreeId: "main", + path: "notes/draft.md", + parentPath: "notes", + name: "published.md", + }; + mocks.move.mockResolvedValue({ + path: "notes/published.md", + kind: "file", + size: 12, + }); + + const response = await PATCH(request("PATCH", input), { params }); + + expect(mocks.move).toHaveBeenCalledWith(workspaceId, userId, input); + expect(await response.json()).toEqual({ + entry: { path: "notes/published.md", kind: "file", size: 12 }, + }); + }); + + it("deletes an entry through the authenticated bridge", async () => { + const input = { worktreeId: "main", path: "notes/archive" }; + mocks.remove.mockResolvedValue(input.path); + + const response = await DELETE(request("DELETE", input), { params }); + + expect(mocks.remove).toHaveBeenCalledWith(workspaceId, userId, input); + expect(await response.json()).toEqual({ path: input.path }); + }); + it("rejects a path instead of a single entry name", async () => { const response = await POST( post({ worktreeId: "main", name: "src/notes.md", kind: "file" }), diff --git a/apps/web/app/api/gen2/workspaces/[workspaceId]/superset/entry/route.ts b/apps/web/app/api/gen2/workspaces/[workspaceId]/superset/entry/route.ts index c2abaf6ba..9b21d4521 100644 --- a/apps/web/app/api/gen2/workspaces/[workspaceId]/superset/entry/route.ts +++ b/apps/web/app/api/gen2/workspaces/[workspaceId]/superset/entry/route.ts @@ -1,6 +1,14 @@ -import { gen2SupersetCreateEntryRequestSchema } from "@codev/contracts"; +import { + gen2SupersetCreateEntryRequestSchema, + gen2SupersetDeleteEntryRequestSchema, + gen2SupersetMoveEntryRequestSchema, +} from "@codev/contracts"; -import { createGen2SupersetEntry } from "@/lib/gen2/superset"; +import { + createGen2SupersetEntry, + deleteGen2SupersetEntry, + moveGen2SupersetEntry, +} from "@/lib/gen2/superset"; import { readJson, withUser } from "@/lib/http/api-route"; type Params = { workspaceId: string }; @@ -16,3 +24,23 @@ export const POST = withUser( }, { errorStatus: 502 }, ); + +export const PATCH = withUser( + async ({ request, user, params: { workspaceId } }) => { + const input = await readJson(request, gen2SupersetMoveEntryRequestSchema); + return Response.json({ + entry: await moveGen2SupersetEntry(workspaceId, user.id, input), + }); + }, + { errorStatus: 502 }, +); + +export const DELETE = withUser( + async ({ request, user, params: { workspaceId } }) => { + const input = await readJson(request, gen2SupersetDeleteEntryRequestSchema); + return Response.json({ + path: await deleteGen2SupersetEntry(workspaceId, user.id, input), + }); + }, + { errorStatus: 502 }, +); diff --git a/apps/web/app/gen2/workspace.css b/apps/web/app/gen2/workspace.css index 636594b8d..840013140 100644 --- a/apps/web/app/gen2/workspace.css +++ b/apps/web/app/gen2/workspace.css @@ -1019,6 +1019,18 @@ gap: 2px; } +/* A quiet, text-first live region keeps collaboration state visible without + competing with the file name or changing the editor's layout. */ +.gen2-superset-collaboration-state { + max-width: 12rem; + overflow: hidden; + color: var(--ss-muted-foreground); + font-size: 0.6875rem; + line-height: 1.2; + text-overflow: ellipsis; + white-space: nowrap; +} + .gen2-superset-create-entry { display: grid; gap: 8px; @@ -1115,6 +1127,83 @@ font-size: 0.75rem; } +.gen2-superset-tree-row { + display: flex; + align-items: stretch; + min-width: 0; +} + +.gen2-superset-tree-row .gen2-superset-folder, +.gen2-superset-tree-row .gen2-superset-file { + flex: 1; + min-width: 0; +} + +.gen2-superset-tree-action { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + width: 28px; + min-height: 28px; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--ss-muted-foreground); + cursor: pointer; +} + +.gen2-superset-tree-action:hover, +.gen2-superset-tree-action[aria-expanded="true"] { + background: color-mix(in srgb, var(--ss-foreground) 7%, transparent); + color: var(--ss-foreground); +} + +.gen2-superset-tree-action:focus-visible { + outline: 2px solid var(--ss-highlight); + outline-offset: -2px; +} + +.gen2-superset-tree-action:disabled { + cursor: not-allowed; + opacity: 0.38; +} + +.gen2-superset-entry-actions { + display: flex; + flex-wrap: wrap; + gap: 3px; + padding: 3px 8px 7px 30px; +} + +.gen2-superset-entry-actions button { + display: inline-flex; + align-items: center; + gap: 4px; + min-height: 26px; + border: 1px solid var(--ss-border); + border-radius: 4px; + background: var(--ss-secondary); + color: var(--ss-foreground); + cursor: pointer; + font: inherit; + font-size: 0.6875rem; + padding: 3px 6px; +} + +.gen2-superset-entry-actions button:hover { + border-color: var(--ss-muted-foreground); +} + +.gen2-superset-entry-actions button:focus-visible { + outline: 2px solid var(--ss-highlight); + outline-offset: 1px; +} + +.gen2-superset-entry-actions .gen2-superset-destructive { + color: #ffb4a9; +} + .gen2-superset-folder { background: transparent; color: var(--ss-muted-foreground); @@ -1241,6 +1330,78 @@ opacity: 0.38; } +.gen2-superset-dialog-backdrop { + position: fixed; + z-index: 20; + inset: 0; + display: grid; + place-items: center; + padding: 20px; + background: rgb(0 0 0 / 58%); +} + +.gen2-superset-dialog { + width: min(100%, 25rem); + padding: 18px; + border: 1px solid var(--ss-border); + border-radius: 9px; + background: var(--ss-card); + box-shadow: 0 16px 48px rgb(0 0 0 / 38%); + color: var(--ss-foreground); +} + +.gen2-superset-dialog h2 { + margin: 0; + font-size: 0.9375rem; +} + +.gen2-superset-dialog p { + margin: 9px 0 0; + color: var(--ss-muted-foreground); + font-size: 0.8125rem; + line-height: 1.45; + overflow-wrap: anywhere; +} + +.gen2-superset-dialog strong { + color: var(--ss-foreground); +} + +.gen2-superset-dialog-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 18px; +} + +.gen2-superset-dialog-actions button { + min-height: 32px; + border: 1px solid var(--ss-border); + border-radius: 5px; + background: var(--ss-secondary); + color: var(--ss-foreground); + cursor: pointer; + font: inherit; + font-size: 0.75rem; + padding: 5px 9px; +} + +.gen2-superset-dialog-actions .gen2-superset-dialog-delete { + border-color: #b64c43; + background: #a83d35; + color: #fff8f6; +} + +.gen2-superset-dialog-actions button:focus-visible { + outline: 2px solid var(--ss-highlight); + outline-offset: 2px; +} + +.gen2-superset-dialog-actions button:disabled { + cursor: not-allowed; + opacity: 0.55; +} + .gen2-superset-notice { margin: 0; display: flex; diff --git a/apps/web/components/gen2/superset-code-editor.tsx b/apps/web/components/gen2/superset-code-editor.tsx index 70a2b2136..3b3ac50b4 100644 --- a/apps/web/components/gen2/superset-code-editor.tsx +++ b/apps/web/components/gen2/superset-code-editor.tsx @@ -27,13 +27,14 @@ import { lineNumbers, rectangularSelection, } from "@codemirror/view"; +import type * as Y from "yjs"; import { gen2LanguageExtension } from "./editor-languages"; import { - buildSupersetFoldChevron, supersetEditorTheme, supersetHighlighting, } from "./superset-editor-theme"; +import { buildFoldChevron } from "../../../../vendor/superset/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/extensions/foldChevron/foldChevron"; /** * Browser-safe adaptation of Superset's CodeEditor. Desktop font settings, @@ -46,23 +47,30 @@ export function SupersetCodeEditor({ readOnly = false, onChange, onSave, + sharedText, + onSelectionChange, }: { path: string; value: string; readOnly?: boolean; onChange: (value: string) => void; onSave: () => void; + /** A CoDev Y.Text makes this CodeMirror view a collaborative editor. */ + sharedText?: Y.Text | null; + onSelectionChange?: (selection: { anchor: number; head: number }) => void; }) { const hostRef = useRef(null); const viewRef = useRef(null); const languageRef = useRef(new Compartment()); const onChangeRef = useRef(onChange); const onSaveRef = useRef(onSave); + const onSelectionChangeRef = useRef(onSelectionChange); const syncingValueRef = useRef(false); useEffect(() => { onChangeRef.current = onChange; onSaveRef.current = onSave; + onSelectionChangeRef.current = onSelectionChange; }); useEffect(() => { @@ -72,13 +80,13 @@ export function SupersetCodeEditor({ const view = new EditorView({ parent: host, state: EditorState.create({ - doc: value, + doc: sharedText?.toString() ?? value, extensions: [ lineNumbers(), highlightSpecialChars(), highlightActiveLineGutter(), highlightSelectionMatches(), - foldGutter({ markerDOM: buildSupersetFoldChevron }), + foldGutter({ markerDOM: buildFoldChevron }), codeFolding(), history(), drawSelection(), @@ -111,14 +119,63 @@ export function SupersetCodeEditor({ indentWithTab, ]), EditorView.updateListener.of((update) => { - if (update.docChanged && !syncingValueRef.current) + if (update.selectionSet) { + const selection = update.state.selection.main; + onSelectionChangeRef.current?.({ + anchor: selection.anchor, + head: selection.head, + }); + } + if (!update.docChanged || syncingValueRef.current) return; + if (!sharedText) { onChangeRef.current(update.state.doc.toString()); + return; + } + sharedText.doc?.transact(() => { + let offset = 0; + update.changes.iterChanges( + (fromA, toA, _fromB, _toB, inserted) => { + const from = fromA + offset; + const deleted = toA - fromA; + const insertedText = inserted.toString(); + if (deleted > 0) sharedText.delete(from, deleted); + if (insertedText) sharedText.insert(from, insertedText); + offset += insertedText.length - deleted; + }, + ); + }, "codev-editor"); }), ], }), }); viewRef.current = view; + const applySharedChanges = (event: Y.YTextEvent) => { + onChangeRef.current(sharedText?.toString() ?? view.state.doc.toString()); + if (event.transaction.origin === "codev-editor") return; + syncingValueRef.current = true; + try { + let position = 0; + for (const delta of event.delta) { + if (typeof delta.retain === "number") { + position += delta.retain; + } else if (typeof delta.delete === "number") { + view.dispatch({ + changes: { from: position, to: position + delta.delete }, + }); + } else if (typeof delta.insert === "string") { + view.dispatch({ + changes: { from: position, insert: delta.insert }, + }); + position += delta.insert.length; + } + } + } finally { + syncingValueRef.current = false; + } + }; + if (sharedText) sharedText.observe(applySharedChanges); + let cancelled = false; void gen2LanguageExtension(path).then((extension) => { if (!cancelled && extension) { @@ -128,17 +185,18 @@ export function SupersetCodeEditor({ return () => { cancelled = true; + if (sharedText) sharedText.unobserve(applySharedChanges); viewRef.current = null; view.destroy(); }; // A changed path intentionally constructs a new editor, separating undo // history between files just as the Superset pane does. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [path]); + }, [path, sharedText]); useEffect(() => { const view = viewRef.current; - if (!view || view.state.doc.toString() === value) return; + if (sharedText || !view || view.state.doc.toString() === value) return; syncingValueRef.current = true; try { view.dispatch({ @@ -147,7 +205,7 @@ export function SupersetCodeEditor({ } finally { syncingValueRef.current = false; } - }, [value]); + }, [sharedText, value]); return
; } diff --git a/apps/web/components/gen2/superset-editor-theme.ts b/apps/web/components/gen2/superset-editor-theme.ts index f2710da47..247fd9f7f 100644 --- a/apps/web/components/gen2/superset-editor-theme.ts +++ b/apps/web/components/gen2/superset-editor-theme.ts @@ -130,19 +130,3 @@ export const supersetHighlighting = syntaxHighlighting( { tag: [tags.invalid], color: "#ffcccc" }, ]), ); - -/** Superset renders these Lucide-compatible markers rather than text glyphs. */ -export function buildSupersetFoldChevron(open: boolean): HTMLElement { - const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); - svg.setAttribute("viewBox", "0 0 24 24"); - svg.setAttribute("fill", "none"); - svg.setAttribute("stroke", "currentColor"); - svg.setAttribute("stroke-width", "2"); - svg.setAttribute("stroke-linecap", "round"); - svg.setAttribute("stroke-linejoin", "round"); - svg.setAttribute("class", "cm-foldChevron"); - const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); - path.setAttribute("d", open ? "m6 9 6 6 6-6" : "m9 18 6-6-6-6"); - svg.appendChild(path); - return svg as unknown as HTMLElement; -} diff --git a/apps/web/components/gen2/superset-file-client.ts b/apps/web/components/gen2/superset-file-client.ts index f16577cb8..d6224ee8e 100644 --- a/apps/web/components/gen2/superset-file-client.ts +++ b/apps/web/components/gen2/superset-file-client.ts @@ -1,7 +1,9 @@ import { gen2SupersetCreateEntryResponseSchema, + gen2SupersetDeleteEntryResponseSchema, gen2SupersetExternalFileChangesResponseSchema, gen2SupersetListFilesResponseSchema, + gen2SupersetMoveEntryResponseSchema, gen2SupersetReadFileResponseSchema, gen2SupersetSaveFileResponseSchema, type Gen2SupersetFile, @@ -63,7 +65,7 @@ export async function listSupersetFiles( export async function createSupersetEntry( workspaceId: string, - input: { name: string; kind: "file" | "directory" }, + input: { parentPath: string; name: string; kind: "file" | "directory" }, ): Promise { const response = await request<{ entry: Gen2SupersetEntry }>( `${fileApiBase(workspaceId)}/entry`, @@ -77,6 +79,38 @@ export async function createSupersetEntry( return response.entry; } +export async function moveSupersetEntry( + workspaceId: string, + input: { path: string; parentPath: string; name: string }, +): Promise { + const response = await request<{ entry: Gen2SupersetEntry }>( + `${fileApiBase(workspaceId)}/entry`, + (payload) => gen2SupersetMoveEntryResponseSchema.parse(payload), + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ worktreeId: SUPERSET_WORKTREE_ID, ...input }), + }, + ); + return response.entry; +} + +export async function deleteSupersetEntry( + workspaceId: string, + path: string, +): Promise { + const response = await request<{ path: string }>( + `${fileApiBase(workspaceId)}/entry`, + (payload) => gen2SupersetDeleteEntryResponseSchema.parse(payload), + { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ worktreeId: SUPERSET_WORKTREE_ID, path }), + }, + ); + return response.path; +} + export async function readSupersetFile( workspaceId: string, path: string, diff --git a/apps/web/components/gen2/superset-file-pane.test.tsx b/apps/web/components/gen2/superset-file-pane.test.tsx index 7468601f1..8f9983672 100644 --- a/apps/web/components/gen2/superset-file-pane.test.tsx +++ b/apps/web/components/gen2/superset-file-pane.test.tsx @@ -1,17 +1,10 @@ -import { - act, - fireEvent, - render, - screen, - waitFor, -} from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ list: vi.fn(), read: vi.fn(), save: vi.fn(), - changes: vi.fn(), create: vi.fn(), })); @@ -21,7 +14,18 @@ vi.mock("./superset-file-client", async (importOriginal) => ({ readSupersetFile: mocks.read, saveSupersetFile: mocks.save, createSupersetEntry: mocks.create, - listSupersetFileChanges: mocks.changes, +})); + +vi.mock("./use-gen2-shared-file-document", () => ({ + useGen2SharedFileDocument: () => ({ + text: null, + awareness: null, + state: "connected", + notice: null, + members: [], + updateCursor: vi.fn(), + readOnly: false, + }), })); vi.mock("./superset-code-editor", () => ({ @@ -75,11 +79,14 @@ describe("SupersetFilePane", () => { revision: "rev-2", }), ); - mocks.changes.mockResolvedValue([]); mocks.create.mockImplementation( async ( _id: string, - input: { name: string; kind: "file" | "directory" }, + input: { + parentPath: string; + name: string; + kind: "file" | "directory"; + }, ) => input.kind === "file" ? { path: input.name, kind: "file", size: 0 } @@ -123,9 +130,7 @@ describe("SupersetFilePane", () => { "export const live = true;", ), ); - expect(await screen.findByRole("status", { name: "" })).toHaveTextContent( - "File saved.", - ); + expect(await screen.findByText("File saved.")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Save" })).toBeDisabled(); }); @@ -158,38 +163,6 @@ describe("SupersetFilePane", () => { expect(screen.queryByRole("alert")).not.toBeInTheDocument(); }); - it("flags an external change while the current file has unsaved edits", async () => { - let poll: (() => void) | undefined; - vi.spyOn(window, "setInterval").mockImplementation((callback, delay) => { - if (delay === 10_000) poll = callback as () => void; - return 1 as unknown as ReturnType; - }); - vi.spyOn(window, "clearInterval").mockImplementation(() => {}); - render(); - fireEvent.change(await screen.findByLabelText("Code"), { - target: { value: "keep this draft" }, - }); - mocks.changes.mockResolvedValueOnce([ - { - type: "file.changed", - worktreeId: "main", - path: firstFile.path, - revision: "rev-2", - origin: "external", - }, - ]); - - act(() => poll?.()); - expect(await screen.findByRole("alert")).toHaveTextContent( - "changed elsewhere", - ); - expect(screen.getByLabelText("Code")).toHaveValue("keep this draft"); - expect( - screen.getByRole("button", { name: "Copy changes" }), - ).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Save" })).toBeDisabled(); - }); - it("shows a read-only editor to workspace viewers", async () => { render(); expect(await screen.findByLabelText("Code")).toHaveAttribute("readonly"); @@ -225,6 +198,7 @@ describe("SupersetFilePane", () => { await waitFor(() => expect(mocks.create).toHaveBeenCalledWith(workspaceId, { + parentPath: "", name: newFile.path, kind: "file", }), @@ -236,9 +210,9 @@ describe("SupersetFilePane", () => { undefined, ), ); - expect(await screen.findByRole("status")).toHaveTextContent( - "File notes.md created.", - ); + expect( + await screen.findByText("File notes.md created."), + ).toBeInTheDocument(); }); it("creates an empty folder without replacing an unsaved draft", async () => { @@ -255,13 +229,14 @@ describe("SupersetFilePane", () => { await waitFor(() => expect(mocks.create).toHaveBeenCalledWith(workspaceId, { + parentPath: "", name: "notes", kind: "directory", }), ); - expect(await screen.findByRole("status")).toHaveTextContent( - "Folder notes created.", - ); + expect( + await screen.findByText("Folder notes created."), + ).toBeInTheDocument(); expect(screen.getByLabelText("Code")).toHaveValue("keep this draft"); }); }); diff --git a/apps/web/components/gen2/superset-file-pane.tsx b/apps/web/components/gen2/superset-file-pane.tsx index 218fb5999..7b904c7ba 100644 --- a/apps/web/components/gen2/superset-file-pane.tsx +++ b/apps/web/components/gen2/superset-file-pane.tsx @@ -21,18 +21,23 @@ import { FilePlus, Folder, FolderPlus, + MoreHorizontal, + Pencil, RefreshCw, Search, + Trash2, } from "lucide-react"; import { - listSupersetFileChanges, listSupersetFiles, readSupersetFile, saveSupersetFile, createSupersetEntry, + deleteSupersetEntry, + moveSupersetEntry, SupersetFileApiError, } from "./superset-file-client"; +import { useGen2SharedFileDocument } from "./use-gen2-shared-file-document"; const SupersetCodeEditor = dynamic( () => @@ -82,6 +87,16 @@ function fileName(path: string) { return path.split("/").at(-1) ?? path; } +function parentPath(path: string) { + return path.includes("/") + ? (path.split("/").slice(0, -1).join("/") ?? "") + : ""; +} + +function isPathOrDescendant(path: string, ancestor: string) { + return path === ancestor || path.startsWith(`${ancestor}/`); +} + function errorMessage(error: unknown, fallback: string) { return error instanceof SupersetFileApiError ? error.message : fallback; } @@ -107,8 +122,21 @@ export function SupersetFilePane({ const [createKind, setCreateKind] = useState<"file" | "directory" | null>( null, ); + const [createParentPath, setCreateParentPath] = useState(""); const [createName, setCreateName] = useState(""); const [creating, setCreating] = useState(false); + const [actionEntry, setActionEntry] = useState( + null, + ); + const [renameEntry, setRenameEntry] = useState( + null, + ); + const [renameName, setRenameName] = useState(""); + const [renaming, setRenaming] = useState(false); + const [deleteEntry, setDeleteEntry] = useState( + null, + ); + const [deleting, setDeleting] = useState(false); const [expandedFolders, setExpandedFolders] = useState>( new Set(), ); @@ -118,6 +146,15 @@ export function SupersetFilePane({ const openRequestId = useRef(0); const listRequestId = useRef(0); const dirty = openFile !== null && contents !== openFile.contents; + const sharedDocument = useGen2SharedFileDocument({ + workspaceId, + path: openFile?.path ?? null, + canEdit, + onContentsChange: (next) => { + contentsRef.current = next; + setContents(next); + }, + }); useEffect(() => { openFileRef.current = openFile; @@ -131,6 +168,15 @@ export function SupersetFilePane({ return () => window.removeEventListener("beforeunload", warn); }, [dirty]); + useEffect(() => { + if (!deleteEntry) return; + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === "Escape" && !deleting) setDeleteEntry(null); + }; + window.addEventListener("keydown", closeOnEscape); + return () => window.removeEventListener("keydown", closeOnEscape); + }, [deleteEntry, deleting]); + const openPath = useCallback( async (path: string, signal?: AbortSignal) => { const requestId = ++openRequestId.current; @@ -223,92 +269,12 @@ export function SupersetFilePane({ return () => controller.abort(); }, [refreshFiles]); - const reconcileRemote = useCallback((remote: Gen2SupersetFile) => { - const current = openFileRef.current; - if ( - !current || - current.path !== remote.path || - current.revision === remote.revision - ) - return; - if (contentsRef.current !== current.contents || savingRef.current) { - setStale(true); - setNotice({ - kind: "conflict", - text: "This file changed elsewhere while you were editing. Your changes are still here.", - }); - } else { - openFileRef.current = remote; - contentsRef.current = remote.contents; - setOpenFile(remote); - setContents(remote.contents); - setStale(false); - setNotice({ kind: "info", text: "File updated from the workspace." }); - } - }, []); - - useEffect(() => { - const controller = new AbortController(); - let polling = false; - const poll = async () => { - if (polling) return; - polling = true; - try { - const changes = await listSupersetFileChanges( - workspaceId, - controller.signal, - ); - if (controller.signal.aborted || changes.length === 0) return; - void refreshFiles(false, true, controller.signal); - const current = openFileRef.current; - const changed = changes.find((change) => change.path === current?.path); - if (!current || !changed || changed.revision === current.revision) - return; - if (contentsRef.current !== current.contents || savingRef.current) { - setStale(true); - setNotice({ - kind: "conflict", - text: "This file changed elsewhere while you were editing. Your changes are still here.", - }); - } else { - const remote = await readSupersetFile( - workspaceId, - current.path, - controller.signal, - ); - if (!controller.signal.aborted) reconcileRemote(remote); - } - } catch { - // Revision checking still protects saves; the next poll retries. - } finally { - polling = false; - } - }; - const timer = window.setInterval(() => void poll(), 10_000); - // The host event journal is shared by callers. Rechecking the open file - // also catches a change consumed by another member's browser. - const verifyTimer = window.setInterval(() => { - const current = openFileRef.current; - if (!current || polling) return; - void readSupersetFile(workspaceId, current.path, controller.signal) - .then((remote) => { - if (!controller.signal.aborted) reconcileRemote(remote); - }) - .catch(() => {}); - }, 30_000); - return () => { - controller.abort(); - window.clearInterval(timer); - window.clearInterval(verifyTimer); - }; - }, [workspaceId, refreshFiles, reconcileRemote]); - async function save() { const file = openFileRef.current; const draft = contentsRef.current; if (!canEdit || !file || draft === file.contents || savingRef.current) return; - if (stale) { + if (stale || sharedDocument.state === "conflict") { setNotice({ kind: "conflict", text: "Reload the latest file before saving. Copy your changes first if you want to keep them.", @@ -353,9 +319,13 @@ export function SupersetFilePane({ } } - function startCreate(kind: "file" | "directory") { + function startCreate(kind: "file" | "directory", parent = "") { setCreateKind(kind); + setCreateParentPath(parent); setCreateName(kind === "file" ? "untitled.txt" : "untitled-folder"); + setActionEntry(null); + setRenameEntry(null); + setRenameName(""); setNotice(null); } @@ -367,8 +337,13 @@ export function SupersetFilePane({ setCreating(true); setNotice(null); try { - const entry = await createSupersetEntry(workspaceId, { name, kind }); + const entry = await createSupersetEntry(workspaceId, { + parentPath: createParentPath, + name, + kind, + }); setCreateKind(null); + setCreateParentPath(""); setCreateName(""); await refreshFiles(false, true); const current = openFileRef.current; @@ -397,6 +372,112 @@ export function SupersetFilePane({ } } + function startRename(entry: Gen2SupersetEntry) { + setActionEntry(null); + setCreateKind(null); + setCreateParentPath(""); + setRenameEntry(entry); + setRenameName(fileName(entry.path)); + setNotice(null); + } + + async function renameEntrySubmit(event: FormEvent) { + event.preventDefault(); + const entry = renameEntry; + const name = renameName.trim(); + if (!entry || !name || renaming) return; + const current = openFileRef.current; + if ( + current && + isPathOrDescendant(current.path, entry.path) && + contentsRef.current !== current.contents + ) { + setNotice({ + kind: "error", + text: "Save or discard unsaved changes before renaming this open file or folder.", + }); + return; + } + setRenaming(true); + setNotice(null); + try { + const moved = await moveSupersetEntry(workspaceId, { + path: entry.path, + parentPath: parentPath(entry.path), + name, + }); + setRenameEntry(null); + setRenameName(""); + await refreshFiles(false, true); + if (current && isPathOrDescendant(current.path, entry.path)) { + const suffix = current.path.slice(entry.path.length); + await openPath(`${moved.path}${suffix}`); + } + setNotice({ + kind: "success", + text: `${entry.kind === "file" ? "File" : "Folder"} renamed to ${moved.path}.`, + }); + } catch (error) { + setNotice({ + kind: "error", + text: errorMessage( + error, + "Couldn’t rename this entry. Try another name.", + ), + }); + } finally { + setRenaming(false); + } + } + + function requestDelete(entry: Gen2SupersetEntry) { + setActionEntry(null); + const current = openFileRef.current; + if ( + current && + isPathOrDescendant(current.path, entry.path) && + contentsRef.current !== current.contents + ) { + setNotice({ + kind: "error", + text: "Save or discard unsaved changes before deleting this open file or folder.", + }); + return; + } + setDeleteEntry(entry); + } + + async function confirmDelete() { + const entry = deleteEntry; + if (!entry || deleting) return; + setDeleting(true); + setNotice(null); + try { + await deleteSupersetEntry(workspaceId, entry.path); + const current = openFileRef.current; + if (current && isPathOrDescendant(current.path, entry.path)) { + openFileRef.current = null; + contentsRef.current = ""; + setOpenFile(null); + setContents(""); + setStale(false); + } + setDeleteEntry(null); + await refreshFiles(false, true); + setNotice({ + kind: "success", + text: `${entry.kind === "file" ? "File" : "Folder"} ${entry.path} deleted.`, + }); + } catch (error) { + setNotice({ + kind: "error", + text: errorMessage(error, "Couldn’t delete this entry. Try again."), + }); + } finally { + setDeleting(false); + } + } + function selectFile(path: string) { if (savingRef.current || openingPath || path === openFileRef.current?.path) return; @@ -461,33 +542,90 @@ export function SupersetFilePane({ Boolean(query.trim()) || expandedFolders.has(child.path); return (
  • - + > +
  • + {actionEntry?.path === child.path ? ( +
    + + + + +
    + ) : null} {expanded ? (
      {renderTree(child, level + 1)}
    ) : null} @@ -498,28 +636,64 @@ export function SupersetFilePane({ const selected = file.path === openFile?.path; return (
  • - + {canEdit ? ( + ) : null} - + + {actionEntry?.path === file.path ? ( +
    + + +
    + ) : null}
  • ); })} @@ -608,6 +782,7 @@ export function SupersetFilePane({ if (event.key === "Escape" && !creating) { event.preventDefault(); setCreateKind(null); + setCreateParentPath(""); } }} disabled={creating} @@ -615,13 +790,18 @@ export function SupersetFilePane({ />

    - Names are created at the workspace root. + {createParentPath + ? `Created in ${createParentPath}.` + : "Created at the workspace root."}

    @@ -631,6 +811,46 @@ export function SupersetFilePane({
    ) : null} + {renameEntry ? ( +
    + +

    {renameEntry.path}

    +
    + + +
    +
    + ) : null} {loadingFiles ? (

    Loading files… @@ -661,11 +881,32 @@ export function SupersetFilePane({ {openFile && !canEdit ? ( Read only ) : null} + {openFile ? ( + + {sharedDocument.state === "connected" + ? sharedDocument.members.length > 1 + ? `${sharedDocument.members.length - 1} collaborator${sharedDocument.members.length === 2 ? "" : "s"} editing` + : "Shared editing" + : sharedDocument.state === "conflict" + ? "Resolve conflict" + : "Syncing collaboration…"} + + ) : null} + + + + + ) : null} ); } diff --git a/apps/web/components/gen2/use-gen2-shared-file-document.ts b/apps/web/components/gen2/use-gen2-shared-file-document.ts new file mode 100644 index 000000000..1eadc6a8c --- /dev/null +++ b/apps/web/components/gen2/use-gen2-shared-file-document.ts @@ -0,0 +1,222 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { + collaborationServerMessageSchema, + type CollaborationPresenceEntry, +} from "@codev/contracts"; +import { + applyAwarenessUpdate, + Awareness, + encodeAwarenessUpdate, +} from "y-protocols/awareness"; +import * as Y from "yjs"; + +const REMOTE_ORIGIN = "codev-remote"; + +function encodeBase64(bytes: Uint8Array) { + let value = ""; + for (let offset = 0; offset < bytes.length; offset += 0x8000) { + value += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000)); + } + return btoa(value); +} + +function decodeBase64(value: string) { + const decoded = atob(value); + return Uint8Array.from(decoded, (character) => character.charCodeAt(0)); +} + +function socketUrl(workspaceId: string) { + const url = new URL( + `/api/gen2/workspaces/${encodeURIComponent(workspaceId)}/collaboration`, + window.location.href, + ); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + return url.toString(); +} + +export type Gen2DocumentConnectionState = + | "idle" + | "connecting" + | "syncing" + | "connected" + | "disconnected" + | "conflict"; + +/** + * Browser-safe CoDev adapter for one open Gen 2 file. Superset's shared-file + * interaction model stays above this hook; all desktop host and store + * dependencies stop here. + */ +export function useGen2SharedFileDocument(input: { + workspaceId: string; + path: string | null; + canEdit: boolean; + onContentsChange: (contents: string) => void; +}) { + const [text, setText] = useState(null); + const [awareness, setAwareness] = useState(null); + const [state, setState] = useState("idle"); + const [notice, setNotice] = useState(null); + const [members, setMembers] = useState([]); + const onContentsChangeRef = useRef(input.onContentsChange); + const socketRef = useRef(null); + const syncedRef = useRef(false); + + useEffect(() => { + onContentsChangeRef.current = input.onContentsChange; + }, [input.onContentsChange]); + + // This effect creates the Yjs resource keyed by the selected file, so its + // initial state must publish the newly created external resource handle. + // Subsequent state changes come from socket/document callbacks. + useEffect(() => { + /* eslint-disable react-hooks/set-state-in-effect */ + if (!input.path) { + setText(null); + setAwareness(null); + setState("idle"); + setNotice(null); + setMembers([]); + return; + } + + let disposed = false; + let reconnectTimer: number | null = null; + const doc = new Y.Doc(); + const nextText = doc.getText("content"); + const nextAwareness = new Awareness(doc); + const path = input.path; + syncedRef.current = false; + setText(nextText); + setAwareness(nextAwareness); + setState("connecting"); + setNotice(null); + setMembers([]); + + const send = (message: unknown) => { + const socket = socketRef.current; + if (socket?.readyState === WebSocket.OPEN) + socket.send(JSON.stringify(message)); + }; + const onDocumentUpdate = (update: Uint8Array, origin: unknown) => { + if (origin === REMOTE_ORIGIN || !syncedRef.current) return; + send({ type: "update", path, update: encodeBase64(update) }); + }; + const onTextChange = () => onContentsChangeRef.current(nextText.toString()); + const onAwarenessUpdate = ( + changes: { added: number[]; updated: number[]; removed: number[] }, + origin: unknown, + ) => { + if (origin === REMOTE_ORIGIN || !syncedRef.current) return; + const clients = [ + ...changes.added, + ...changes.updated, + ...changes.removed, + ]; + if (clients.length > 0) { + send({ + type: "awareness", + path, + update: encodeBase64(encodeAwarenessUpdate(nextAwareness, clients)), + }); + } + }; + doc.on("update", onDocumentUpdate); + nextText.observe(onTextChange); + nextAwareness.on("update", onAwarenessUpdate); + + const connect = () => { + if (disposed) return; + setState(syncedRef.current ? "syncing" : "connecting"); + const socket = new WebSocket(socketUrl(input.workspaceId)); + socketRef.current = socket; + socket.onopen = () => send({ type: "join" }); + socket.onmessage = (event) => { + if (typeof event.data !== "string") return; + let payload: unknown; + try { + payload = JSON.parse(event.data); + } catch { + return; + } + const parsed = collaborationServerMessageSchema.safeParse(payload); + if (!parsed.success) return; + const message = parsed.data; + if (message.type === "welcome") { + setState("syncing"); + send({ type: "subscribe", path }); + } else if (message.type === "sync" && message.path === path) { + Y.applyUpdate(doc, decodeBase64(message.update), REMOTE_ORIGIN); + syncedRef.current = true; + setState("connected"); + setNotice(null); + } else if (message.type === "update" && message.path === path) { + Y.applyUpdate(doc, decodeBase64(message.update), REMOTE_ORIGIN); + } else if (message.type === "awareness" && message.path === path) { + applyAwarenessUpdate( + nextAwareness, + decodeBase64(message.update), + REMOTE_ORIGIN, + ); + } else if (message.type === "presence") { + setMembers(message.members.filter((member) => member.path === path)); + } else if (message.type === "reconciled" && message.path === path) { + if (message.update) { + Y.applyUpdate(doc, decodeBase64(message.update), REMOTE_ORIGIN); + } + setNotice("An agent updated this file from the workspace."); + } else if (message.type === "conflict" && message.path === path) { + setState("conflict"); + setNotice(message.message); + } else if ( + message.type === "error" && + (!message.path || message.path === path) + ) { + setNotice(message.message); + if (message.code === "conflict") setState("conflict"); + } + }; + socket.onclose = () => { + if (socketRef.current === socket) socketRef.current = null; + if (disposed) return; + setState("disconnected"); + setNotice("Collaboration disconnected. Reconnecting…"); + reconnectTimer = window.setTimeout(connect, 1_000); + }; + }; + connect(); + + return () => { + disposed = true; + if (reconnectTimer) window.clearTimeout(reconnectTimer); + socketRef.current?.close(); + socketRef.current = null; + doc.off("update", onDocumentUpdate); + nextText.unobserve(onTextChange); + nextAwareness.off("update", onAwarenessUpdate); + nextAwareness.destroy(); + doc.destroy(); + }; + /* eslint-enable react-hooks/set-state-in-effect */ + }, [input.workspaceId, input.path]); + + const updateCursor = useCallback( + (cursor: { anchor: number; head: number } | null) => { + if (!awareness) return; + awareness.setLocalStateField("cursor", cursor); + }, + [awareness], + ); + + return { + text, + awareness, + state, + notice, + members, + updateCursor, + readOnly: !input.canEdit || state !== "connected", + }; +} diff --git a/apps/web/lib/collaboration/yjs-document.test.ts b/apps/web/lib/collaboration/yjs-document.test.ts new file mode 100644 index 000000000..45019ced5 --- /dev/null +++ b/apps/web/lib/collaboration/yjs-document.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import * as Y from "yjs"; + +import { + classifyFilesystemReconciliation, + docFromUpdate, + encodedDocument, + replaceDocumentContents, +} from "./yjs-document"; + +describe("shared Yjs document primitives", () => { + it("preserves a document through a serializable snapshot", () => { + const doc = new Y.Doc(); + doc.getText("content").insert(0, "shared text"); + + const restored = docFromUpdate(encodedDocument(doc).update); + + expect(restored.getText("content").toString()).toBe("shared text"); + }); + + it("only ingests a filesystem change when no collaborative edit is pending", () => { + expect( + classifyFilesystemReconciliation({ + snapshotContents: "on disk", + collaborativeContents: "on disk", + snapshotRevision: "rev-1", + filesystemRevision: "rev-2", + }), + ).toBe("ingest"); + expect( + classifyFilesystemReconciliation({ + snapshotContents: "on disk", + collaborativeContents: "member edit", + snapshotRevision: "rev-1", + filesystemRevision: "rev-2", + }), + ).toBe("conflict"); + }); + + it("replaces the shared text as one filesystem-origin transaction", () => { + const doc = new Y.Doc(); + doc.getText("content").insert(0, "before"); + + replaceDocumentContents(doc, "after"); + + expect(doc.getText("content").toString()).toBe("after"); + }); +}); diff --git a/apps/web/lib/collaboration/yjs-document.ts b/apps/web/lib/collaboration/yjs-document.ts new file mode 100644 index 000000000..b02656a1b --- /dev/null +++ b/apps/web/lib/collaboration/yjs-document.ts @@ -0,0 +1,51 @@ +import { createHash } from "node:crypto"; + +import * as Y from "yjs"; + +/** Browser- and runtime-agnostic Yjs document primitives shared by workspace adapters. */ +export function classifyFilesystemReconciliation(input: { + snapshotContents: string; + collaborativeContents: string; + snapshotRevision: string; + filesystemRevision: string; +}) { + if (input.snapshotRevision === input.filesystemRevision) { + return "unchanged" as const; + } + return input.collaborativeContents === input.snapshotContents + ? ("ingest" as const) + : ("conflict" as const); +} + +export function collaborativeConflictRevision(contents: string): string { + return `editor-${createHash("sha256").update(contents).digest("hex").slice(0, 24)}`; +} + +export function decodeBase64(value: string) { + return new Uint8Array(Buffer.from(value, "base64")); +} + +export function encodeBase64(value: Uint8Array) { + return Buffer.from(value).toString("base64"); +} + +export function docFromUpdate(update: string) { + const doc = new Y.Doc(); + Y.applyUpdate(doc, decodeBase64(update), "snapshot"); + return doc; +} + +export function replaceDocumentContents(doc: Y.Doc, contents: string) { + const text = doc.getText("content"); + doc.transact(() => { + text.delete(0, text.length); + text.insert(0, contents); + }, "filesystem"); +} + +export function encodedDocument(doc: Y.Doc) { + return { + update: encodeBase64(Y.encodeStateAsUpdate(doc)), + stateVector: encodeBase64(Y.encodeStateVector(doc)), + }; +} diff --git a/apps/web/lib/gen2/collaboration-documents.ts b/apps/web/lib/gen2/collaboration-documents.ts new file mode 100644 index 000000000..2dbad4c43 --- /dev/null +++ b/apps/web/lib/gen2/collaboration-documents.ts @@ -0,0 +1,203 @@ +import "server-only"; + +import { schema } from "@codev/db"; +import { and, eq } from "drizzle-orm"; +import * as Y from "yjs"; + +import { + classifyFilesystemReconciliation, + docFromUpdate, + encodedDocument, + replaceDocumentContents, +} from "../collaboration/yjs-document"; +import { getDatabase } from "../platform/database"; +import { readGen2SupersetFile } from "./superset"; + +export interface Gen2DocumentSnapshot { + workspaceId: string; + path: string; + revision: string; + update: string; + stateVector: string; + filesystemContents: string; + filesystemRevision: string | null; + hasConflict: boolean; + conflictFilesystemRevision: string | null; +} + +export async function loadGen2Document( + workspaceId: string, + path: string, +): Promise { + const [snapshot] = await getDatabase() + .select({ + workspaceId: schema.gen2YjsDocuments.workspaceId, + path: schema.gen2YjsDocuments.path, + revision: schema.gen2YjsDocuments.revision, + update: schema.gen2YjsDocuments.update, + stateVector: schema.gen2YjsDocuments.stateVector, + filesystemContents: schema.gen2YjsDocuments.filesystemContents, + filesystemRevision: schema.gen2YjsDocuments.filesystemRevision, + hasConflict: schema.gen2YjsDocuments.hasConflict, + conflictFilesystemRevision: + schema.gen2YjsDocuments.conflictFilesystemRevision, + }) + .from(schema.gen2YjsDocuments) + .where( + and( + eq(schema.gen2YjsDocuments.workspaceId, workspaceId), + eq(schema.gen2YjsDocuments.path, path), + ), + ) + .limit(1); + return snapshot ?? null; +} + +export async function saveGen2Document( + snapshot: Gen2DocumentSnapshot, + conflictFilesystemRevision: string | null = null, +) { + const now = new Date(); + await getDatabase() + .insert(schema.gen2YjsDocuments) + .values({ + ...snapshot, + lastSyncedAt: conflictFilesystemRevision ? null : now, + hasConflict: Boolean(conflictFilesystemRevision), + conflictFilesystemRevision, + conflictDetectedAt: conflictFilesystemRevision ? now : null, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [ + schema.gen2YjsDocuments.workspaceId, + schema.gen2YjsDocuments.path, + ], + set: { + revision: snapshot.revision, + update: snapshot.update, + stateVector: snapshot.stateVector, + filesystemContents: snapshot.filesystemContents, + filesystemRevision: snapshot.filesystemRevision, + lastSyncedAt: conflictFilesystemRevision ? null : now, + hasConflict: Boolean(conflictFilesystemRevision), + conflictFilesystemRevision, + conflictDetectedAt: conflictFilesystemRevision ? now : null, + updatedAt: now, + }, + }); +} + +export async function initializeGen2Document( + workspaceId: string, + userId: string, + path: string, +) { + const file = await readGen2SupersetFile(workspaceId, userId, "main", path); + const doc = new Y.Doc(); + doc.getText("content").insert(0, file.contents); + const snapshot: Gen2DocumentSnapshot = { + workspaceId, + path, + revision: file.revision, + ...encodedDocument(doc), + filesystemContents: file.contents, + filesystemRevision: file.revision, + hasConflict: false, + conflictFilesystemRevision: null, + }; + await saveGen2Document(snapshot); + return snapshot; +} + +/** Records a completed revision-checked filesystem save without replacing a newer Yjs edit. */ +export async function recordGen2DocumentSave(input: { + workspaceId: string; + path: string; + contents: string; + revision: string; +}) { + const snapshot = await loadGen2Document(input.workspaceId, input.path); + if (!snapshot) return; + await saveGen2Document({ + ...snapshot, + revision: input.revision, + filesystemContents: input.contents, + filesystemRevision: input.revision, + hasConflict: false, + conflictFilesystemRevision: null, + }); +} + +/** + * Checks the shared document against the durable file without changing it. + * Callers decide whether an agent-originated change should be broadcast. + */ +export async function reconcileGen2Document( + workspaceId: string, + userId: string, + snapshot: Gen2DocumentSnapshot, +) { + const file = await readGen2SupersetFile( + workspaceId, + userId, + "main", + snapshot.path, + ); + const previousRevision = snapshot.filesystemRevision ?? snapshot.revision; + if (snapshot.hasConflict) { + return { + snapshot, + event: { + type: "conflict" as const, + path: snapshot.path, + snapshotRevision: snapshot.revision, + filesystemRevision: + snapshot.conflictFilesystemRevision ?? file.revision, + }, + }; + } + + const doc = docFromUpdate(snapshot.update); + const reconciliation = classifyFilesystemReconciliation({ + snapshotContents: snapshot.filesystemContents, + collaborativeContents: doc.getText("content").toString(), + snapshotRevision: previousRevision, + filesystemRevision: file.revision, + }); + if (reconciliation === "unchanged") return { snapshot, event: null }; + + if (reconciliation === "conflict") { + await saveGen2Document(snapshot, file.revision); + return { + snapshot: { ...snapshot, hasConflict: true }, + event: { + type: "conflict" as const, + path: snapshot.path, + snapshotRevision: previousRevision, + filesystemRevision: file.revision, + }, + }; + } + + replaceDocumentContents(doc, file.contents); + const reconciled: Gen2DocumentSnapshot = { + ...snapshot, + ...encodedDocument(doc), + revision: file.revision, + filesystemContents: file.contents, + filesystemRevision: file.revision, + hasConflict: false, + conflictFilesystemRevision: null, + }; + await saveGen2Document(reconciled); + return { + snapshot: reconciled, + event: { + type: "reconciled" as const, + path: snapshot.path, + revision: file.revision, + update: reconciled.update, + }, + }; +} diff --git a/apps/web/lib/gen2/collaboration-events.ts b/apps/web/lib/gen2/collaboration-events.ts new file mode 100644 index 000000000..0e691f87f --- /dev/null +++ b/apps/web/lib/gen2/collaboration-events.ts @@ -0,0 +1,57 @@ +import "server-only"; + +import { publish } from "../workspaces/collaboration-rooms"; +import { + loadGen2Document, + reconcileGen2Document, +} from "./collaboration-documents"; + +/** Redis room namespace; unlike Gen 1 it never identifies a worktree. */ +export function gen2CollaborationRoom(workspaceId: string) { + return `gen2:${workspaceId}`; +} + +/** + * Reconciles only documents that are actually open in a shared editor. Codex + * file-change events call this instead of making every browser poll the guest + * filesystem. + */ +export async function reconcileGen2CollaborationPaths(input: { + workspaceId: string; + userId: string; + paths: string[]; +}) { + for (const path of [...new Set(input.paths)]) { + const snapshot = await loadGen2Document(input.workspaceId, path); + if (!snapshot) continue; + const result = await reconcileGen2Document( + input.workspaceId, + input.userId, + snapshot, + ); + if (!result.event) continue; + if (result.event.type === "reconciled") { + await publish(gen2CollaborationRoom(input.workspaceId), { + type: "reconciled", + // The shared collaboration wire format predates Gen 2. This carries + // the workspace ID solely for per-document event filtering; it is not + // looked up as a Gen 1 worktree. + worktreeId: input.workspaceId, + path: result.event.path, + revision: result.event.revision, + source: "filesystem", + update: result.event.update, + }); + } else { + await publish(gen2CollaborationRoom(input.workspaceId), { + type: "conflict", + worktreeId: input.workspaceId, + path: result.event.path, + snapshotRevision: result.event.snapshotRevision, + filesystemRevision: result.event.filesystemRevision, + message: + "An agent changed this file while collaborative edits were pending. Neither version was overwritten.", + }); + } + } +} diff --git a/apps/web/lib/gen2/collaboration-socket.ts b/apps/web/lib/gen2/collaboration-socket.ts new file mode 100644 index 000000000..18e458c1f --- /dev/null +++ b/apps/web/lib/gen2/collaboration-socket.ts @@ -0,0 +1,469 @@ +import "server-only"; + +import { randomUUID } from "node:crypto"; + +import { + collaborationClientMessageSchema, + presenceCursorSchema, + type CollaborationUser, +} from "@codev/contracts"; +import type { RawData, WebSocket } from "ws"; +import { + applyAwarenessUpdate, + Awareness, + encodeAwarenessUpdate, +} from "y-protocols/awareness"; +import * as Y from "yjs"; + +import { + decodeBase64, + docFromUpdate, + encodeBase64, + encodedDocument, +} from "../collaboration/yjs-document"; +import { + send, + sendError, + type Connection, +} from "../workspaces/collaboration-connection"; +import { + HEARTBEAT_INTERVAL_MS, + INSTANCE_ID, + MAX_SOCKET_PAYLOAD_BYTES, + STREAM_MAX_LENGTH, + redisClient, + streamKey, + withDocumentLock, +} from "../workspaces/collaboration-redis"; +import { + broadcastLocal, + closeRoomIfEmpty, + publish, + replay, + startRoom, +} from "../workspaces/collaboration-rooms"; +import { + refreshPresence, + removePresence, +} from "../workspaces/collaboration-presence"; +import { + initializeGen2Document, + loadGen2Document, + reconcileGen2Document, + saveGen2Document, +} from "./collaboration-documents"; +import { gen2CollaborationRoom } from "./collaboration-events"; + +export const gen2CollaborationSocketMaxPayload = MAX_SOCKET_PAYLOAD_BYTES; + +function roomConnection( + workspaceId: string, + socket: WebSocket, + user: CollaborationUser, + canEdit: boolean, +): Connection { + return { + id: randomUUID(), + socket, + user, + joined: false, + // Compatibility envelope only: this is never read from the Gen 1 + // worktrees table. It lets the existing room fan-out filter a Gen 2 + // workspace's document updates without duplicating transport machinery. + worktreeId: workspaceId, + subscriptions: new Set(), + activePath: null, + cursor: null, + resumeFrom: null, + replayedPaths: new Set(), + alive: true, + canEdit, + }; +} + +async function subscribe( + workspaceId: string, + connection: Connection, + path: string, + stateVector?: string, +) { + const roomKey = gen2CollaborationRoom(workspaceId); + const result = await withDocumentLock( + roomKey, + workspaceId, + path, + async () => { + const snapshot = + (await loadGen2Document(workspaceId, path)) ?? + (await initializeGen2Document(workspaceId, connection.user.id, path)); + return reconcileGen2Document(workspaceId, connection.user.id, snapshot); + }, + ); + connection.subscriptions.add(path); + connection.activePath = path; + if (result.event?.type === "reconciled") { + await publish(roomKey, { + type: "reconciled", + worktreeId: workspaceId, + path, + revision: result.event.revision, + source: "filesystem", + update: result.event.update, + }); + } else if (result.event?.type === "conflict") { + await publish(roomKey, { + type: "conflict", + worktreeId: workspaceId, + path, + snapshotRevision: result.event.snapshotRevision, + filesystemRevision: result.event.filesystemRevision, + message: + "The shared document and workspace file both changed. Neither version was overwritten.", + }); + } + if (connection.resumeFrom && !connection.replayedPaths.has(path)) { + connection.replayedPaths.add(path); + await replay(roomKey, connection, connection.resumeFrom, path); + } + const doc = docFromUpdate(result.snapshot.update); + const update = stateVector + ? Y.encodeStateAsUpdate(doc, decodeBase64(stateVector)) + : Y.encodeStateAsUpdate(doc); + send(connection, { + type: "sync", + path, + update: encodeBase64(update), + stateVector: encodeBase64(Y.encodeStateVector(doc)), + revision: result.snapshot.revision, + }); + await refreshPresence(roomKey, connection); +} + +async function applyUpdate( + workspaceId: string, + connection: Connection, + path: string, + update: string, +) { + const roomKey = gen2CollaborationRoom(workspaceId); + const outcome = await withDocumentLock( + roomKey, + workspaceId, + path, + async () => { + const loaded = await loadGen2Document(workspaceId, path); + if (!loaded) throw new Error("The collaborative document was not found."); + const reconciled = await reconcileGen2Document( + workspaceId, + connection.user.id, + loaded, + ); + if (reconciled.event?.type === "conflict") { + return { conflict: reconciled.event, reconciled: null }; + } + const doc = docFromUpdate(reconciled.snapshot.update); + Y.applyUpdate(doc, decodeBase64(update), "client"); + await saveGen2Document({ + ...reconciled.snapshot, + ...encodedDocument(doc), + }); + return { + conflict: null, + reconciled: + reconciled.event?.type === "reconciled" ? reconciled.event : null, + }; + }, + ); + if (outcome.conflict) { + await publish(roomKey, { + type: "conflict", + worktreeId: workspaceId, + path, + snapshotRevision: outcome.conflict.snapshotRevision, + filesystemRevision: outcome.conflict.filesystemRevision, + message: + "A collaborative edit arrived after the file changed on the workspace. Neither version was overwritten.", + }); + return; + } + if (outcome.reconciled) { + await publish(roomKey, { + type: "reconciled", + worktreeId: workspaceId, + path, + revision: outcome.reconciled.revision, + source: "filesystem", + update: outcome.reconciled.update, + }); + } + const streamId = await redisClient().xadd( + streamKey(roomKey), + "MAXLEN", + "~", + STREAM_MAX_LENGTH, + "*", + "instance", + INSTANCE_ID, + "payload", + JSON.stringify({ + type: "update", + worktreeId: workspaceId, + path, + update, + revision: + (await loadGen2Document(workspaceId, path))?.revision ?? "pending", + actorId: connection.user.id, + streamId: "pending", + }), + ); + broadcastLocal( + roomKey, + { + type: "update", + worktreeId: workspaceId, + path, + update, + revision: + (await loadGen2Document(workspaceId, path))?.revision ?? "pending", + actorId: connection.user.id, + streamId: streamId ?? "0-0", + }, + connection, + ); +} + +function sanitizeAwareness(update: string, user: CollaborationUser) { + const awareness = new Awareness(new Y.Doc()); + let clientIds: number[] = []; + let cursor: { anchor: number; head: number } | null = null; + awareness.on( + "update", + (changes: { added: number[]; updated: number[]; removed: number[] }) => { + clientIds = [ + ...changes.added, + ...changes.updated, + ...changes.removed, + ].filter((clientId) => clientId !== awareness.clientID); + }, + ); + applyAwarenessUpdate(awareness, decodeBase64(update), "client"); + for (const clientId of clientIds) { + const state = awareness.states.get(clientId); + if (!state) continue; + const parsedCursor = presenceCursorSchema.safeParse(state.cursor); + if (parsedCursor.success) cursor = parsedCursor.data; + awareness.states.set(clientId, { + ...state, + user: { + id: user.id, + login: user.login, + name: user.name, + image: user.avatarUrl, + }, + }); + } + return { + update: encodeBase64(encodeAwarenessUpdate(awareness, clientIds)), + cursor, + }; +} + +async function publishAwareness( + workspaceId: string, + connection: Connection, + path: string, + update: string, +) { + if (!connection.subscriptions.has(path)) { + sendError( + connection, + "not_subscribed", + "Subscribe before sharing your cursor.", + false, + path, + ); + return; + } + const roomKey = gen2CollaborationRoom(workspaceId); + connection.activePath = path; + const sanitized = sanitizeAwareness(update, connection.user); + const streamId = await redisClient().xadd( + streamKey(roomKey), + "MAXLEN", + "~", + STREAM_MAX_LENGTH, + "*", + "instance", + INSTANCE_ID, + "payload", + JSON.stringify({ + type: "awareness", + worktreeId: workspaceId, + path, + update: sanitized.update, + actorId: connection.user.id, + connectionId: connection.id, + streamId: "pending", + }), + ); + broadcastLocal( + roomKey, + { + type: "awareness", + worktreeId: workspaceId, + path, + update: sanitized.update, + actorId: connection.user.id, + connectionId: connection.id, + streamId: streamId ?? "0-0", + }, + connection, + ); + if (sanitized.cursor) connection.cursor = sanitized.cursor; + await refreshPresence(roomKey, connection); +} + +async function handleMessage( + workspaceId: string, + connection: Connection, + data: RawData, + isBinary: boolean, +) { + const byteLength = Array.isArray(data) + ? data.reduce((total, part) => total + part.byteLength, 0) + : data.byteLength; + if (isBinary || byteLength > MAX_SOCKET_PAYLOAD_BYTES) { + sendError( + connection, + "payload_too_large", + "Collaboration messages must be JSON under 128 KiB.", + false, + ); + return; + } + let message; + try { + message = collaborationClientMessageSchema.parse( + JSON.parse( + Array.isArray(data) ? Buffer.concat(data).toString() : data.toString(), + ), + ); + } catch { + sendError( + connection, + "invalid_message", + "Invalid collaboration message.", + false, + ); + return; + } + try { + if (message.type === "join") { + connection.joined = true; + connection.resumeFrom = message.resumeFrom ?? null; + const roomKey = gen2CollaborationRoom(workspaceId); + const latest = await redisClient().xrevrange( + streamKey(roomKey), + "+", + "-", + "COUNT", + 1, + ); + send(connection, { + type: "welcome", + connectionId: connection.id, + user: connection.user, + heartbeatIntervalMs: HEARTBEAT_INTERVAL_MS, + streamId: latest[0]?.[0] ?? "0-0", + }); + await refreshPresence(roomKey, connection); + return; + } + if (!connection.joined) { + sendError(connection, "not_joined", "Join the workspace first.", false); + return; + } + if (message.type === "subscribe") + await subscribe( + workspaceId, + connection, + message.path, + message.stateVector, + ); + else if (message.type === "update") { + if (!connection.canEdit) { + sendError( + connection, + "forbidden", + "Edit permission is required to change workspace files.", + false, + message.path, + ); + return; + } + if (!connection.subscriptions.has(message.path)) { + sendError( + connection, + "not_subscribed", + "Subscribe before editing this file.", + false, + message.path, + ); + return; + } + await applyUpdate(workspaceId, connection, message.path, message.update); + } else if (message.type === "awareness") + await publishAwareness( + workspaceId, + connection, + message.path, + message.update, + ); + else { + connection.alive = true; + await refreshPresence(gen2CollaborationRoom(workspaceId), connection); + } + } catch { + sendError( + connection, + "internal_error", + "The collaboration service could not complete the operation.", + true, + "path" in message ? message.path : undefined, + ); + } +} + +export async function handleGen2CollaborationSocket( + workspaceId: string, + socket: WebSocket, + user: CollaborationUser, + options: { canEdit: boolean }, +) { + const roomKey = gen2CollaborationRoom(workspaceId); + const room = await startRoom(roomKey); + const connection = roomConnection(workspaceId, socket, user, options.canEdit); + room.connections.add(connection); + const heartbeat = setInterval(() => { + if (!connection.alive) return socket.terminate(); + connection.alive = false; + socket.ping(); + void refreshPresence(roomKey, connection); + }, HEARTBEAT_INTERVAL_MS); + heartbeat.unref(); + socket.on("pong", () => { + connection.alive = true; + }); + socket.on("message", (data, isBinary) => { + void handleMessage(workspaceId, connection, data, isBinary); + }); + socket.once("close", () => { + clearInterval(heartbeat); + room.connections.delete(connection); + void removePresence(roomKey, connection); + closeRoomIfEmpty(roomKey, room); + }); + socket.once("error", () => { + connection.alive = false; + }); +} diff --git a/apps/web/lib/gen2/superset.ts b/apps/web/lib/gen2/superset.ts index 518c0cc65..45e5dba2e 100644 --- a/apps/web/lib/gen2/superset.ts +++ b/apps/web/lib/gen2/superset.ts @@ -2,8 +2,10 @@ import "server-only"; import { gen2SupersetCreateEntryResponseSchema, + gen2SupersetDeleteEntryResponseSchema, gen2SupersetExternalFileChangesResponseSchema, gen2SupersetListFilesResponseSchema, + gen2SupersetMoveEntryResponseSchema, gen2SupersetReadFileResponseSchema, gen2SupersetSaveFileResponseSchema, type Gen2SupersetFile, @@ -22,6 +24,7 @@ import { Gen2FileConflictError, Gen2LifecycleError, } from "./errors"; +import { recordGen2DocumentSave } from "./collaboration-documents"; import { requireGen2Member } from "./workspaces"; const healthSchema = z.object({ status: z.literal("ok") }); @@ -78,7 +81,12 @@ export async function listGen2SupersetFiles( export async function createGen2SupersetEntry( workspaceId: string, userId: string, - input: { worktreeId: string; name: string; kind: "file" | "directory" }, + input: { + worktreeId: string; + parentPath: string; + name: string; + kind: "file" | "directory"; + }, ): Promise { const membership = await requireReadySupersetMember(workspaceId, userId); if (membership.role === "viewer") { @@ -97,6 +105,54 @@ export async function createGen2SupersetEntry( .entry; } +export async function moveGen2SupersetEntry( + workspaceId: string, + userId: string, + input: { + worktreeId: string; + path: string; + parentPath: string; + name: string; + }, +): Promise { + const membership = await requireReadySupersetMember(workspaceId, userId); + if (membership.role === "viewer") { + throw new Gen2AccessError( + "Edit permission is required to rename or move files and folders.", + 403, + ); + } + const response = await orchestratorRequest( + "POST", + `/v1/sandboxes/${workspaceId}/superset/entry/move`, + input, + 35_000, + ); + return gen2SupersetMoveEntryResponseSchema.parse(await response.json()).entry; +} + +export async function deleteGen2SupersetEntry( + workspaceId: string, + userId: string, + input: { worktreeId: string; path: string }, +): Promise { + const membership = await requireReadySupersetMember(workspaceId, userId); + if (membership.role === "viewer") { + throw new Gen2AccessError( + "Edit permission is required to delete files and folders.", + 403, + ); + } + const response = await orchestratorRequest( + "POST", + `/v1/sandboxes/${workspaceId}/superset/entry/delete`, + input, + 35_000, + ); + return gen2SupersetDeleteEntryResponseSchema.parse(await response.json()) + .path; +} + export async function readGen2SupersetFile( workspaceId: string, userId: string, @@ -137,7 +193,18 @@ export async function saveGen2SupersetFile( input, 35_000, ); - return gen2SupersetSaveFileResponseSchema.parse(await response.json()).file; + const file = gen2SupersetSaveFileResponseSchema.parse( + await response.json(), + ).file; + // A snapshot is recoverability metadata, never a reason to report a + // successful revision-checked filesystem save as failed. + await recordGen2DocumentSave({ + workspaceId, + path: file.path, + contents: file.contents, + revision: file.revision, + }).catch(() => undefined); + return file; } catch (error) { if (error instanceof OrchestratorError && error.status === 409) { throw new Gen2FileConflictError( diff --git a/apps/web/lib/gen2/turns.ts b/apps/web/lib/gen2/turns.ts index d4fc318cd..702c94760 100644 --- a/apps/web/lib/gen2/turns.ts +++ b/apps/web/lib/gen2/turns.ts @@ -14,6 +14,7 @@ import { type CodexExecChunk, } from "./codex-output"; import { reduceCodexTurn } from "./turn-events"; +import { reconcileGen2CollaborationPaths } from "./collaboration-events"; /** * Server-side accumulation of a running Codex turn. @@ -89,6 +90,22 @@ export async function recordGen2TurnChunks(input: { } const state = reduceCodexTurn(output); + const changedPaths = state.items.flatMap((item) => + item.kind === "fileChange" + ? item.changes.map((change) => change.path) + : [], + ); + if (changedPaths.length > 0) { + await reconcileGen2CollaborationPaths({ + workspaceId: turn.workspaceId, + userId: turn.userId, + paths: changedPaths, + }).catch((error) => { + logEvent("error", "gen2.collaboration.reconcile_failed", { + detail: error instanceof Error ? error.message : "unknown", + }); + }); + } const body = state.reply || state.error || ""; const message = body ? await appendGen2ChatMessage({ diff --git a/apps/web/lib/workspaces/collaboration-documents.ts b/apps/web/lib/workspaces/collaboration-documents.ts index 1b411c33c..194429abd 100644 --- a/apps/web/lib/workspaces/collaboration-documents.ts +++ b/apps/web/lib/workspaces/collaboration-documents.ts @@ -1,14 +1,28 @@ import "server-only"; -import { createHash } from "node:crypto"; - import { schema } from "@codev/db"; import { and, eq } from "drizzle-orm"; import * as Y from "yjs"; +import { + classifyFilesystemReconciliation, + docFromUpdate, + encodedDocument, + replaceDocumentContents, +} from "../collaboration/yjs-document"; import { getDatabase } from "@/lib/platform/database"; import { readSandboxFile } from "@/lib/runtime/orchestrator"; +export { + classifyFilesystemReconciliation, + collaborativeConflictRevision, + decodeBase64, + docFromUpdate, + encodedDocument, + encodeBase64, + replaceDocumentContents, +} from "../collaboration/yjs-document"; + export interface Snapshot { worktreeId: string; path: string; @@ -21,53 +35,6 @@ export interface Snapshot { conflictFilesystemRevision: string | null; } -export function classifyFilesystemReconciliation(input: { - snapshotContents: string; - collaborativeContents: string; - snapshotRevision: string; - filesystemRevision: string; -}) { - if (input.snapshotRevision === input.filesystemRevision) { - return "unchanged" as const; - } - return input.collaborativeContents === input.snapshotContents - ? ("ingest" as const) - : ("conflict" as const); -} - -export function collaborativeConflictRevision(contents: string): string { - return `editor-${createHash("sha256").update(contents).digest("hex").slice(0, 24)}`; -} - -export function decodeBase64(value: string) { - return new Uint8Array(Buffer.from(value, "base64")); -} - -export function encodeBase64(value: Uint8Array) { - return Buffer.from(value).toString("base64"); -} - -export function docFromUpdate(update: string) { - const doc = new Y.Doc(); - Y.applyUpdate(doc, decodeBase64(update), "snapshot"); - return doc; -} - -export function replaceDocumentContents(doc: Y.Doc, contents: string) { - const text = doc.getText("content"); - doc.transact(() => { - text.delete(0, text.length); - text.insert(0, contents); - }, "filesystem"); -} - -export function encodedDocument(doc: Y.Doc) { - return { - update: encodeBase64(Y.encodeStateAsUpdate(doc)), - stateVector: encodeBase64(Y.encodeStateVector(doc)), - }; -} - export async function resolveWorktree(workspaceId: string, requested?: string) { const conditions = [ eq(schema.worktrees.workspaceId, workspaceId), diff --git a/docs/SUPERSET_ADOPTION_MANIFEST.md b/docs/SUPERSET_ADOPTION_MANIFEST.md index 07176690a..986db0609 100644 --- a/docs/SUPERSET_ADOPTION_MANIFEST.md +++ b/docs/SUPERSET_ADOPTION_MANIFEST.md @@ -1,7 +1,7 @@ # Superset adoption manifest **Status:** Design; incremental adoption plan, not an implementation guarantee -**Date:** 2026-09-24 +**Date:** 2026-09-27 ## Source pin @@ -17,6 +17,48 @@ Keep the snapshot available for review and upstream updates, but build only the packages needed by CoDev. This map selects an integration slice; it does not recommend importing the whole Superset application into the web app. +## Adoption decision: replace, do not duplicate + +The vendor import is justified only when Superset host-service code replaces a +corresponding Gen 2 guest capability. It is not a component gallery or a source +of interaction ideas for a parallel CoDev implementation. + +For every adopted capability, the change must name: the Superset host-service +module that runs in the guest, the narrow CoDev gateway/adapter that authorizes +it, the old Gen 2 path that becomes a temporary fallback or is removed, and an +end-to-end acceptance check. Do not add the same filesystem, PTY, Git, +worktree, watcher, or agent-session behavior to both `codev-guestd` and the +Superset host service. + +The current concrete reuse is the separately built Superset host artifact, its +PTY daemon and host-local persistence, and the private CoDev file bridge. The +feature-gated file operations traverse CoDev authorization, the orchestrator, +`codev-guestd`, and the host service rather than reimplementing file management +in the browser. Terminal, Git/worktree, and agent-session replacement remain +the next proof points. + +After the terminal, Git status/diff, and worktree bridge slice, make a stop/go +decision. If those operations cannot run through the real Superset host service +with less duplicated guest code than the existing Gen 2 implementation, stop +the migration rather than retaining the vendor tree as reference material. + +### Provider connection and agent-launch contract + +Superset configures and supervises a CLI process; it is not CoDev's provider +connection system. For every launch, CoDev selects the requesting member's +eligible connection, enforces role and quota checks, reserves any required +provider lease, and creates a one-launch private credential profile. The guest +delivers that profile only to the selected Superset agent process in its +selected worktree. The browser, ordinary terminals, other members, and other +agent processes cannot read it. + +If a provider refreshes its CLI cache, the host returns the updated material +through the trusted guest/orchestrator path for CoDev to encrypt and persist; +CoDev then releases the lease and removes the private profile. Do not use +Superset host-wide default accounts, Superset cloud auth, member cookies, or a +shared process environment for a CoDev agent launch. CoDev remains the source +of truth for connection status, metering, actor attribution, and audit events. + ## Build and tooling boundary - The root `pnpm-workspace.yaml` includes `apps/*` and `packages/*`; it does @@ -168,16 +210,22 @@ that host package into the web dependency graph. ### Incremental browser integration -Do not design a complete replacement API before building the UI. Integrate one -Superset workspace feature at a time: +Integrate one runtime replacement at a time; do not port a Superset panel +ahead of the guest capability it represents: -1. Adapt the selected Superset component into the CoDev browser shell and - remove or replace its Electron-only dependencies. -2. Define the smallest CoDev browser API contract that component requires. - The route authenticates the member, checks permissions and quota, then +1. Extend the CoDev-specific bridge in the vendored host service and prove the + selected filesystem, terminal, Git, worktree, or agent operation runs there. + `codev-guestd` is limited to validation, bridge authentication, and transport + while that transition is in progress. +2. Define the smallest typed CoDev gateway contract. The route authenticates + the member, checks permissions and quota, persists any CoDev mapping, then forwards the authorized operation to the private host service. -3. Connect the component to the selected worktree and verify it in a real Gen - 2 workspace before starting the next feature. +3. Only after the host path works, adapt the selected Superset component into + the CoDev browser shell and replace its Electron-only dependencies with the + browser-safe CoDev client facade. +4. Verify the selected worktree in a real Gen 2 workspace, identify the old + path to retire or retain behind the feature flag, and only then begin the + next capability. The first browser slice is the Superset `FilePane` and `CodeEditor`. Its CoDev adapter must support file listing, read, revision-checked save, @@ -185,6 +233,27 @@ external-change notification, Yjs document binding, and shared presence. Finish that slice only when two members can edit the same file and an agent write is reconciled without silently losing either person's work. +### Shared-editor implementation (awaiting real-workspace verification) + +The Gen 2 FilePane/CodeEditor adapter is implemented, but is not yet a shipped +claim. It uses a Gen 2-scoped Yjs snapshot table and an authenticated CoDev +WebSocket; it does not reuse the Gen 1 worktree snapshot identity. CodeMirror +writes to the open file's `Y.Text`, while revision-checked file save remains +the durable persistence boundary. + +The adapter reuses CoDev's browser-safe Yjs update, awareness, Redis fan-out, +and conflict patterns. It does not import Superset's desktop document store, +Electron bridge, host client, or desktop settings. The editor currently shows +shared/syncing/conflict state; remote cursor decorations and richer member UI +remain follow-up work. Codex file-change events reconcile an open shared +document when a turn completes and publish either the reconciled document or a +non-destructive conflict. + +Before treating this slice as complete, verify in a real shared Gen 2 +workspace: concurrent edits by two members, viewer write denial, reconnect, +revision conflict recovery, and an agent write that both cleanly reconciles +and conflicts with an in-flight member edit. + After files and the shared editor, add Superset's terminal, changes/Git and branch-worktree panels, then agent and subagent/session panels, then preview. Each feature adds only the CoDev APIs it needs. The browser never receives a @@ -206,14 +275,20 @@ Do not include in the first browser slice: 1. Keep the Gen 2 host and guest lifecycle reliable across create, open, restart, hibernation, restore, and deletion. The health endpoint only proves that the host service starts; it does not prove workspace operations. -2. Ship the FilePane and CodeEditor slice, backed by the smallest authorized - CoDev file API and CoDev's Yjs/presence adapter. -3. Ship the terminal slice, followed by Git status/diff and worktree selection, - with every panel scoped to the same selected branch. -4. Ship agent, session, and subagent panels. A launch is scoped to the - requesting member's credential profile and a selected worktree. -5. Add a second worktree and a second concurrent agent; verify the page can - show both branches, sessions, files, and diffs together. +2. Verify the implemented file replacement in a real shared Gen 2 workspace: + the Superset file bridge, CoDev Yjs/presence adapter, revision recovery, + reconnect, role denial, and both clean and conflicting agent writes. +3. Extend the Superset host bridge for terminal, Git status/diff, and worktree + selection. Replace the matching Gen 2 guest paths; do not build a second + implementation. Port the matching panels only after their host operations + are verified. +4. Replace fresh `codex exec`/poll turns with Superset terminal-agent sessions. + CoDev persists the workspace/worktree/host-session mapping, selects the + requesting member's credential profile, authorizes and meters the launch, + and owns durable activity and recovery state. +5. Add a second worktree and a second concurrent agent. Independent agents use + isolated worktrees; human coediting stays in the selected integration + worktree unless an explicit exclusive claim permits an agent write there. 6. Add preview if it remains useful after the other panels, then verify host restart and Gen 2 snapshot/restore preserve files, worktrees, agent mappings, and recoverable session state. diff --git a/docs/SUPERSET_WORKSPACE_OWNERSHIP.md b/docs/SUPERSET_WORKSPACE_OWNERSHIP.md index edf83b6fb..337cefcb2 100644 --- a/docs/SUPERSET_WORKSPACE_OWNERSHIP.md +++ b/docs/SUPERSET_WORKSPACE_OWNERSHIP.md @@ -1,7 +1,7 @@ # Superset-powered workspace ownership contract **Status:** Design; Phase 1 target, not a description of shipped behavior -**Date:** 2026-09-24 +**Date:** 2026-09-27 ## Decision @@ -11,6 +11,15 @@ integration target is the Gen 2 Firecracker workspace, which already gives the editor, terminal, Git view, and Codex one filesystem. The existing workspace implementations remain available while the new path is proven. +The fork is a guest-runtime replacement, not a reference library. A Superset +capability is adopted only when its host-service implementation replaces the +matching Gen 2 guest behavior through a CoDev-authorized adapter. During a +feature-flagged transition the old path may remain as an explicit fallback, but +new filesystem, PTY, Git, worktree, watcher, and agent-session mechanics must +not be implemented in parallel on both sides. If terminal, Git/worktree, and +agent replacement do not reduce duplication, stop the migration rather than +maintaining the vendor tree for UI inspiration. + One CoDev workspace is one shared page, one Firecracker VM, and one project repository. It can contain several branch contexts backed by Git worktrees. Parallel, independent agent tasks use separate worktrees in that VM and remain diff --git a/docs/gen2-workspace.md b/docs/gen2-workspace.md index 253db6532..e89712037 100644 --- a/docs/gen2-workspace.md +++ b/docs/gen2-workspace.md @@ -1,7 +1,7 @@ # Gen 2 workspace **Status:** Current -**Date:** 2026-09-26 +**Date:** 2026-09-27 Gen 2 is a new workspace, isolated from `lib/workspaces`. The product is: @@ -57,6 +57,21 @@ rail and has three tabs: Files (tree + editor), Terminal, and Git. It is modelled on an agent UI, not an IDE: the chat is where the work is directed, and the workbench is how you watch and intervene. +### Shared editor (implemented, pending two-member verification) + +The open Superset-style file editor now uses a CoDev-backed Yjs document rather +than browser polling for filesystem changes. A Gen 2-scoped authenticated +WebSocket carries document updates, awareness/presence, reconnects, and +conflicts. CodeMirror binds directly to the document; the editor shows shared, +syncing, and conflict state. + +Filesystem writes remain explicit, revision-checked saves. The Yjs snapshot is +recoverability and collaboration state, not a replacement durable filesystem. +When Codex reports that it changed an open file at turn completion, CoDev +reconciles the shared document with the saved file; a concurrent member edit +becomes a visible non-destructive conflict. Remote cursor decorations and +richer member presence remain follow-up work. + ## The guest serialises some calls behind a running turn `services/orchestrator/src/guest.rs` takes a mutation lock and waits for Codex @@ -83,10 +98,10 @@ guest image without that account is detected at startup and falls back to the old close-on-turn behaviour, so an un-rebuilt host is safe rather than exposed. -When a turn ends, the tree, the Git panel, and any open buffer refresh. A -buffer with unsaved edits is never overwritten: it offers "Keep mine" or "Take -theirs" instead. Saves carry `expectedRevision`, so a stale write is a 409 with -the current revision rather than a silent clobber. +When a turn ends, the tree and Git panel refresh. An open shared document is +reconciled from Codex's reported file changes instead of a browser filesystem +poll. Saves carry `expectedRevision`, so a stale write is a 409 with the +current revision rather than a silent clobber. ## Turn transcripts live on the server @@ -122,9 +137,20 @@ Concurrent agents: the guest serialises Codex (`start_codex_exec` waits on more workspaces. Not yet built here: a browser/preview tab (live port forwarding is deferred in -`lib/runtime/preview.ts` and needs guest networking), file create/rename/delete, -Git staging and commit from the UI, and realtime fan-out between members — -two people in one workspace see each other's writes only on refresh. +`lib/runtime/preview.ts` and needs guest networking), Git staging and commit +from the UI, and realtime fan-out between members. The feature-flagged +`/superset` page supports nested file/folder creation, rename, and permanent +delete through the Superset host filesystem service; that page is separate from +the shared CodeMirror/Yjs editor and two people using it still see each other's +writes only on refresh. + +The Superset host artifact and private guest bridge are real guest-side reuse, +not a browser mock. Future Superset work must extend that host service to +replace the matching Gen 2 terminal, Git/worktree, and agent mechanics; do not +add duplicate `codev-guestd` implementations. CoDev continues to own member +authorization, provider credentials, quotas, Yjs documents, conflicts, and +durable product history. The adoption contract and stop/go gate live in +[`SUPERSET_ADOPTION_MANIFEST.md`](./SUPERSET_ADOPTION_MANIFEST.md). ## Routes @@ -133,7 +159,7 @@ two people in one workspace see each other's writes only on refresh. - `/gen2/join/[token]` — accept a share link API under `/api/gen2/workspaces/[id]`: `instance`, `share`, `chats`, `agent`, -`agent/poll`, `files`, `git`, `terminal`. +`agent/poll`, `files`, `git`, `terminal`, `collaboration`. Code lives under `apps/web/lib/gen2`, `apps/web/components/gen2`, and `apps/web/app/gen2`. diff --git a/packages/contracts/src/gen2.ts b/packages/contracts/src/gen2.ts index bcd83f284..a6586d138 100644 --- a/packages/contracts/src/gen2.ts +++ b/packages/contracts/src/gen2.ts @@ -317,9 +317,23 @@ export const gen2SupersetSaveFileResponseSchema = z.object({ file: gen2SupersetFileSchema, }); -/** A root-level entry is created atomically; nested creation comes later. */ +const gen2SupersetRelativePathSchema = gen2FilePathSchema.refine( + (value) => + !value + .split("/") + .some((part) => part.length === 0 || part === "." || part === ".."), + "Path must stay within the selected worktree.", +); + +const gen2SupersetParentPathSchema = z.union([ + z.literal(""), + gen2SupersetRelativePathSchema, +]); + +/** An entry is created atomically in the selected workspace folder. */ export const gen2SupersetCreateEntryRequestSchema = z.object({ worktreeId: gen2SupersetWorktreeIdSchema, + parentPath: gen2SupersetParentPathSchema.default(""), name: z .string() .trim() @@ -345,6 +359,27 @@ export const gen2SupersetCreateEntryResponseSchema = z.object({ entry: gen2SupersetEntrySchema, }); +/** Move also covers a rename when the parent path is unchanged. */ +export const gen2SupersetMoveEntryRequestSchema = z.object({ + worktreeId: gen2SupersetWorktreeIdSchema, + path: gen2SupersetRelativePathSchema, + parentPath: gen2SupersetParentPathSchema, + name: gen2SupersetCreateEntryRequestSchema.shape.name, +}); + +export const gen2SupersetMoveEntryResponseSchema = z.object({ + entry: gen2SupersetEntrySchema, +}); + +export const gen2SupersetDeleteEntryRequestSchema = z.object({ + worktreeId: gen2SupersetWorktreeIdSchema, + path: gen2SupersetRelativePathSchema, +}); + +export const gen2SupersetDeleteEntryResponseSchema = z.object({ + path: gen2SupersetRelativePathSchema, +}); + /** A 409 save response carries the host's current revision. */ export const gen2SupersetSaveConflictResponseSchema = z.object({ currentRevision: z.string().min(1), diff --git a/packages/db/drizzle/0055_gen2_yjs_documents.sql b/packages/db/drizzle/0055_gen2_yjs_documents.sql new file mode 100644 index 000000000..cf3e4b2ea --- /dev/null +++ b/packages/db/drizzle/0055_gen2_yjs_documents.sql @@ -0,0 +1,20 @@ +CREATE TABLE "gen2_yjs_documents" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "workspace_id" uuid NOT NULL, + "path" text NOT NULL, + "revision" text NOT NULL, + "update_base64" text NOT NULL, + "state_vector_base64" text DEFAULT '' NOT NULL, + "filesystem_contents" text DEFAULT '' NOT NULL, + "filesystem_revision" text, + "last_synced_at" timestamp with time zone, + "has_conflict" boolean DEFAULT false NOT NULL, + "conflict_filesystem_revision" text, + "conflict_detected_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "gen2_yjs_documents" ADD CONSTRAINT "gen2_yjs_documents_workspace_id_gen2_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."gen2_workspaces"("id") ON DELETE cascade ON UPDATE no action; +--> statement-breakpoint +CREATE UNIQUE INDEX "gen2_yjs_documents_workspace_path_idx" ON "gen2_yjs_documents" USING btree ("workspace_id", "path"); diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index ed3a66a58..87b184a19 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -386,6 +386,13 @@ "when": 1790398244312, "tag": "0054_long_gressill", "breakpoints": true + }, + { + "idx": 55, + "version": "7", + "when": 1790470000000, + "tag": "0055_gen2_yjs_documents", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index ec2c8d034..0a83f7c11 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -2071,6 +2071,42 @@ export const gen2WorkspaceMembers = pgTable( ], ); +/** + * The collaborative editor's recoverable document snapshot for a Gen 2 + * workspace. This is intentionally separate from `yjsSnapshots`: Gen 2 has + * one shared checkout, not the Gen 1 worktree aggregate that table belongs + * to. The sandbox file remains the durable source of truth; this record + * supplies Yjs state, revision fencing, and conflict recovery around it. + */ +export const gen2YjsDocuments = pgTable( + "gen2_yjs_documents", + { + id: uuid("id").defaultRandom().primaryKey(), + workspaceId: uuid("workspace_id") + .references(() => gen2Workspaces.id, { onDelete: "cascade" }) + .notNull(), + path: text("path").notNull(), + revision: text("revision").notNull(), + update: text("update_base64").notNull(), + stateVector: text("state_vector_base64").default("").notNull(), + filesystemContents: text("filesystem_contents").default("").notNull(), + filesystemRevision: text("filesystem_revision"), + lastSyncedAt: timestamp("last_synced_at", { withTimezone: true }), + hasConflict: boolean("has_conflict").default(false).notNull(), + conflictFilesystemRevision: text("conflict_filesystem_revision"), + conflictDetectedAt: timestamp("conflict_detected_at", { + withTimezone: true, + }), + ...timestamps, + }, + (table) => [ + uniqueIndex("gen2_yjs_documents_workspace_path_idx").on( + table.workspaceId, + table.path, + ), + ], +); + export const gen2Chats = pgTable( "gen2_chats", { diff --git a/services/orchestrator/src/backend/firecracker.rs b/services/orchestrator/src/backend/firecracker.rs index 1add04c65..601a4d9a5 100644 --- a/services/orchestrator/src/backend/firecracker.rs +++ b/services/orchestrator/src/backend/firecracker.rs @@ -34,10 +34,11 @@ use crate::{ CodexExecStartRequest, CreateRequest, ExecRequest, ExecResponse, FileResponse, Instance, PublicationExportRequest, PublicationExportResponse, RepositorySnapshot, Result, RuntimeError, SessionRestoreBeginRequest, SessionRestoreChunkRequest, - SessionRestoreFinalizeResponse, SupersetCreateEntryRequest, TerminalInputRequest, - TerminalPollRequest, TerminalPollResponse, TerminalResizeRequest, TerminalStartRequest, - WorktreeCheckpointRequest, WorktreeCheckpointResponse, WorktreeCreateRequest, - WorktreeMergeRequest, WorktreeMergeResponse, WorktreeRebaseRequest, WorktreeRebaseResponse, + SessionRestoreFinalizeResponse, SupersetCreateEntryRequest, SupersetDeleteEntryRequest, + SupersetMoveEntryRequest, TerminalInputRequest, TerminalPollRequest, TerminalPollResponse, + TerminalResizeRequest, TerminalStartRequest, WorktreeCheckpointRequest, + WorktreeCheckpointResponse, WorktreeCreateRequest, WorktreeMergeRequest, + WorktreeMergeResponse, WorktreeRebaseRequest, WorktreeRebaseResponse, WorktreeReviewResponse, WriteFileRequest, }, }; @@ -669,6 +670,28 @@ impl FirecrackerBackend { Ok(result) } + pub async fn superset_move_entry( + &self, + workspace_id: &str, + request: &SupersetMoveEntryRequest, + ) -> Result { + let machine = self.machine(workspace_id).await?; + let result = machine.guest.superset_move_entry(request).await?; + self.mark_activity(&machine); + Ok(result) + } + + pub async fn superset_delete_entry( + &self, + workspace_id: &str, + request: &SupersetDeleteEntryRequest, + ) -> Result { + let machine = self.machine(workspace_id).await?; + let result = machine.guest.superset_delete_entry(request).await?; + self.mark_activity(&machine); + Ok(result) + } + pub async fn superset_file_changes( &self, workspace_id: &str, diff --git a/services/orchestrator/src/backend/mod.rs b/services/orchestrator/src/backend/mod.rs index 9815fc120..e9aa56fd3 100644 --- a/services/orchestrator/src/backend/mod.rs +++ b/services/orchestrator/src/backend/mod.rs @@ -13,10 +13,11 @@ use crate::model::{ IdeSession, IdeStartRequest, IdeWriteFileRequest, Instance, PublicationExportRequest, PublicationExportResponse, Result, RuntimeError, SessionRestoreBeginRequest, SessionRestoreChunkRequest, SessionRestoreFinalizeResponse, SessionRestoreStatus, - SupersetCreateEntryRequest, TerminalInputRequest, TerminalPollRequest, TerminalPollResponse, - TerminalResizeRequest, TerminalStartRequest, WorktreeCheckpointRequest, - WorktreeCheckpointResponse, WorktreeCreateRequest, WorktreeMergeRequest, WorktreeMergeResponse, - WorktreeRebaseRequest, WorktreeRebaseResponse, WorktreeReviewResponse, WriteFileRequest, + SupersetCreateEntryRequest, SupersetDeleteEntryRequest, SupersetMoveEntryRequest, + TerminalInputRequest, TerminalPollRequest, TerminalPollResponse, TerminalResizeRequest, + TerminalStartRequest, WorktreeCheckpointRequest, WorktreeCheckpointResponse, + WorktreeCreateRequest, WorktreeMergeRequest, WorktreeMergeResponse, WorktreeRebaseRequest, + WorktreeRebaseResponse, WorktreeReviewResponse, WriteFileRequest, }; const MAX_ACTIVE_SESSIONS: usize = 3; @@ -364,6 +365,36 @@ impl Backend { } } + pub async fn superset_move_entry( + &self, + workspace_id: &str, + request: &SupersetMoveEntryRequest, + ) -> Result { + match self { + Self::Fake(_) => Err(RuntimeError::Unavailable( + "Superset host service is unavailable in the fake backend".into(), + )), + #[cfg(target_os = "linux")] + Self::Firecracker(backend) => backend.superset_move_entry(workspace_id, request).await, + } + } + + pub async fn superset_delete_entry( + &self, + workspace_id: &str, + request: &SupersetDeleteEntryRequest, + ) -> Result { + match self { + Self::Fake(_) => Err(RuntimeError::Unavailable( + "Superset host service is unavailable in the fake backend".into(), + )), + #[cfg(target_os = "linux")] + Self::Firecracker(backend) => { + backend.superset_delete_entry(workspace_id, request).await + } + } + } + pub async fn superset_file_changes( &self, workspace_id: &str, diff --git a/services/orchestrator/src/guest.rs b/services/orchestrator/src/guest.rs index dbb9994eb..943fad0a9 100644 --- a/services/orchestrator/src/guest.rs +++ b/services/orchestrator/src/guest.rs @@ -28,11 +28,11 @@ use crate::model::{ PublicationExportResponse, PublicationFile, RuntimeError, SESSION_RESTORE_CHUNK_BYTES, SESSION_RESTORE_FILE_BYTES, SESSION_RESTORE_TOTAL_BYTES, SessionRestoreBeginRequest, SessionRestoreChunkRequest, SessionRestoreFileKind, SessionRestoreFinalizeResponse, - SessionRestoreStatus, SupersetCreateEntryRequest, TerminalChunk, TerminalInputRequest, - TerminalPollRequest, TerminalPollResponse, TerminalResizeRequest, TerminalStartRequest, - WorktreeCheckpointRequest, WorktreeCheckpointResponse, WorktreeCreateRequest, - WorktreeMergeRequest, WorktreeMergeResponse, WorktreeRebaseRequest, WorktreeRebaseResponse, - WorktreeReviewResponse, WriteFileRequest, + SessionRestoreStatus, SupersetCreateEntryRequest, SupersetDeleteEntryRequest, + SupersetMoveEntryRequest, TerminalChunk, TerminalInputRequest, TerminalPollRequest, + TerminalPollResponse, TerminalResizeRequest, TerminalStartRequest, WorktreeCheckpointRequest, + WorktreeCheckpointResponse, WorktreeCreateRequest, WorktreeMergeRequest, WorktreeMergeResponse, + WorktreeRebaseRequest, WorktreeRebaseResponse, WorktreeReviewResponse, WriteFileRequest, }; const MAX_BODY_BYTES: usize = 2 << 20; @@ -245,6 +245,12 @@ impl GuestService { if path == "/v1/superset/entry/create" && method == "POST" { return self.superset_create_entry(body); } + if path == "/v1/superset/entry/move" && method == "POST" { + return self.superset_move_entry(body); + } + if path == "/v1/superset/entry/delete" && method == "POST" { + return self.superset_delete_entry(body); + } if path == "/v1/superset/file/changes" && method == "POST" { return self.superset_file_changes(body); } @@ -448,6 +454,9 @@ impl GuestService { if let Err(error) = validate_worktree_id(&request.worktree_id) { return GuestResponse::error(400, error); } + if !request.parent_path.is_empty() && !is_safe_relative_path(&request.parent_path) { + return GuestResponse::error(400, "invalid parent path"); + } if !is_safe_entry_name(&request.name) { return GuestResponse::error(400, "invalid file or folder name"); } @@ -457,6 +466,37 @@ impl GuestService { self.superset_bridge_request("POST", "/codev/entry", body) } + fn superset_move_entry(&self, body: &[u8]) -> GuestResponse { + let request: SupersetMoveEntryRequest = match decode(body) { + Ok(request) => request, + Err(error) => return GuestResponse::error(400, error), + }; + if let Err(error) = validate_worktree_id(&request.worktree_id) { + return GuestResponse::error(400, error); + } + if !is_safe_relative_path(&request.path) + || (!request.parent_path.is_empty() && !is_safe_relative_path(&request.parent_path)) + || !is_safe_entry_name(&request.name) + { + return GuestResponse::error(400, "invalid file move request"); + } + self.superset_bridge_request("POST", "/codev/entry/move", body) + } + + fn superset_delete_entry(&self, body: &[u8]) -> GuestResponse { + let request: SupersetDeleteEntryRequest = match decode(body) { + Ok(request) => request, + Err(error) => return GuestResponse::error(400, error), + }; + if let Err(error) = validate_worktree_id(&request.worktree_id) { + return GuestResponse::error(400, error); + } + if !is_safe_relative_path(&request.path) { + return GuestResponse::error(400, "invalid file deletion request"); + } + self.superset_bridge_request("POST", "/codev/entry/delete", body) + } + fn superset_file_changes(&self, body: &[u8]) -> GuestResponse { let request: SupersetListFilesRequest = match decode(body) { Ok(request) => request, diff --git a/services/orchestrator/src/guest_client.rs b/services/orchestrator/src/guest_client.rs index 7cf4feed0..5134b8201 100644 --- a/services/orchestrator/src/guest_client.rs +++ b/services/orchestrator/src/guest_client.rs @@ -12,11 +12,11 @@ use crate::model::{ ClaudeSetupStartRequest, CodexExecPollRequest, CodexExecPollResponse, CodexExecStartRequest, ExecRequest, ExecResponse, FileResponse, PublicationExportRequest, PublicationExportResponse, Result, RuntimeError, SessionRestoreBeginRequest, SessionRestoreChunkRequest, - SessionRestoreFinalizeResponse, SupersetCreateEntryRequest, TerminalInputRequest, - TerminalPollRequest, TerminalPollResponse, TerminalResizeRequest, TerminalStartRequest, - WorktreeCheckpointRequest, WorktreeCheckpointResponse, WorktreeCreateRequest, - WorktreeMergeRequest, WorktreeMergeResponse, WorktreeRebaseRequest, WorktreeRebaseResponse, - WorktreeReviewResponse, WriteFileRequest, + SessionRestoreFinalizeResponse, SupersetCreateEntryRequest, SupersetDeleteEntryRequest, + SupersetMoveEntryRequest, TerminalInputRequest, TerminalPollRequest, TerminalPollResponse, + TerminalResizeRequest, TerminalStartRequest, WorktreeCheckpointRequest, + WorktreeCheckpointResponse, WorktreeCreateRequest, WorktreeMergeRequest, WorktreeMergeResponse, + WorktreeRebaseRequest, WorktreeRebaseResponse, WorktreeReviewResponse, WriteFileRequest, }; const MAX_RESPONSE_BYTES: usize = 10 << 20; @@ -99,6 +99,22 @@ impl GuestClient { .await } + pub async fn superset_move_entry( + &self, + request: &SupersetMoveEntryRequest, + ) -> Result { + self.request("POST", "/v1/superset/entry/move", Some(request)) + .await + } + + pub async fn superset_delete_entry( + &self, + request: &SupersetDeleteEntryRequest, + ) -> Result { + self.request("POST", "/v1/superset/entry/delete", Some(request)) + .await + } + pub async fn flush_workspace(&self) -> Result<()> { self.request::<(), serde_json::Value>("POST", "/v1/workspace/flush", None) .await diff --git a/services/orchestrator/src/http_api.rs b/services/orchestrator/src/http_api.rs index ca7de47c1..378c24fcb 100644 --- a/services/orchestrator/src/http_api.rs +++ b/services/orchestrator/src/http_api.rs @@ -25,10 +25,10 @@ use crate::{ IDE_EXEC_MAX_ARGUMENTS, IDE_EXEC_MAX_TIMEOUT_SECONDS, IdeExecRequest, IdePrepareRequest, IdeStartRequest, IdeWriteFileRequest, MAX_IDE_FILE_BYTES, PublicationExportRequest, Result, RuntimeError, SESSION_RESTORE_CHUNK_BYTES, SessionRestoreBeginRequest, - SessionRestoreChunkRequest, SupersetCreateEntryRequest, TerminalInputRequest, - TerminalPollRequest, TerminalResizeRequest, TerminalStartRequest, - WorktreeCheckpointRequest, WorktreeCreateRequest, WorktreeMergeRequest, - WorktreeRebaseRequest, WriteFileRequest, + SessionRestoreChunkRequest, SupersetCreateEntryRequest, SupersetDeleteEntryRequest, + SupersetMoveEntryRequest, TerminalInputRequest, TerminalPollRequest, TerminalResizeRequest, + TerminalStartRequest, WorktreeCheckpointRequest, WorktreeCreateRequest, + WorktreeMergeRequest, WorktreeRebaseRequest, WriteFileRequest, }, }; @@ -102,6 +102,14 @@ pub fn router(backend: SharedBackend, ide: IdeBackend) -> Router { "/v1/sandboxes/{workspace_id}/superset/entry/create", post(superset_create_entry), ) + .route( + "/v1/sandboxes/{workspace_id}/superset/entry/move", + post(superset_move_entry), + ) + .route( + "/v1/sandboxes/{workspace_id}/superset/entry/delete", + post(superset_delete_entry), + ) .route( "/v1/sandboxes/{workspace_id}/superset/file/changes", post(superset_file_changes), @@ -440,6 +448,11 @@ async fn superset_create_entry( ) -> Result> { validate_workspace_id(&workspace_id)?; validate_optional_worktree_id(Some(&request.worktree_id))?; + if !request.parent_path.is_empty() && !is_safe_superset_relative_path(&request.parent_path) { + return Err(RuntimeError::BadRequest( + "invalid file creation request".into(), + )); + } if request.name.is_empty() || request.name.len() > 255 || matches!(request.name.as_str(), "." | "..") @@ -458,6 +471,48 @@ async fn superset_create_entry( )) } +async fn superset_move_entry( + State(backend): State, + Path(workspace_id): Path, + Json(request): Json, +) -> Result> { + validate_workspace_id(&workspace_id)?; + validate_optional_worktree_id(Some(&request.worktree_id))?; + if !is_safe_superset_relative_path(&request.path) + || (!request.parent_path.is_empty() + && !is_safe_superset_relative_path(&request.parent_path)) + || request.name.is_empty() + || request.name.len() > 255 + || matches!(request.name.as_str(), "." | "..") + || request.name.contains(['/', '\\', '\0']) + || request.name.chars().any(char::is_control) + { + return Err(RuntimeError::BadRequest("invalid file move request".into())); + } + Ok(Json( + backend.superset_move_entry(&workspace_id, &request).await?, + )) +} + +async fn superset_delete_entry( + State(backend): State, + Path(workspace_id): Path, + Json(request): Json, +) -> Result> { + validate_workspace_id(&workspace_id)?; + validate_optional_worktree_id(Some(&request.worktree_id))?; + if !is_safe_superset_relative_path(&request.path) { + return Err(RuntimeError::BadRequest( + "invalid file deletion request".into(), + )); + } + Ok(Json( + backend + .superset_delete_entry(&workspace_id, &request) + .await?, + )) +} + async fn superset_file_changes( State(backend): State, Path(workspace_id): Path, @@ -1192,6 +1247,17 @@ fn validate_worktree_id(worktree_id: &str) -> Result<()> { } } +fn is_safe_superset_relative_path(path: &str) -> bool { + !path.is_empty() + && path.len() <= 4_096 + && !path.starts_with('/') + && !path.contains(['\\', '\0']) + && !path.chars().any(char::is_control) + && path + .split('/') + .all(|part| !part.is_empty() && !matches!(part, "." | "..")) +} + fn validate_sha(value: &str, label: &str) -> Result<()> { if commit_sha_pattern().is_match(value) { Ok(()) diff --git a/services/orchestrator/src/model.rs b/services/orchestrator/src/model.rs index 3960eb4aa..32b51dcfc 100644 --- a/services/orchestrator/src/model.rs +++ b/services/orchestrator/src/model.rs @@ -162,10 +162,28 @@ pub struct WriteFileRequest { #[serde(rename_all = "camelCase")] pub struct SupersetCreateEntryRequest { pub worktree_id: String, + #[serde(default)] + pub parent_path: String, pub name: String, pub kind: String, } +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SupersetMoveEntryRequest { + pub worktree_id: String, + pub path: String, + pub parent_path: String, + pub name: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SupersetDeleteEntryRequest { + pub worktree_id: String, + pub path: String, +} + #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct ExecRequest { diff --git a/vendor/superset/packages/host-service/src/codev/files.ts b/vendor/superset/packages/host-service/src/codev/files.ts index 527c6bd48..7688c1ad8 100644 --- a/vendor/superset/packages/host-service/src/codev/files.ts +++ b/vendor/superset/packages/host-service/src/codev/files.ts @@ -55,15 +55,24 @@ const entryNameSchema = z { message: "Name must be a single file or folder name." }, ); const createEntrySchema = listSchema.extend({ + parentPath: z.union([z.literal(""), pathSchema]).default(""), name: entryNameSchema, kind: z.enum(["file", "directory"]), }); +const moveEntrySchema = listSchema.extend({ + path: pathSchema, + parentPath: z.union([z.literal(""), pathSchema]), + name: entryNameSchema, +}); +const deleteEntrySchema = listSchema.extend({ path: pathSchema }); type FileService = Pick< FsHostService, | "createUniqueEntry" + | "deletePath" | "getMetadata" | "listDirectory" + | "movePath" | "readFile" | "watchPath" | "writeFile" @@ -352,7 +361,7 @@ export function registerCoDevFileBridge({ await changes.start(parsed.data.worktreeId); const service = filesystem.getServiceForRootPath(root); const created = await service.createUniqueEntry({ - parentAbsolutePath: root, + parentAbsolutePath: resolve(root, parsed.data.parentPath), baseName: parsed.data.name, kind: parsed.data.kind, }); @@ -373,6 +382,47 @@ export function registerCoDevFileBridge({ } }); + app.post("/codev/entry/move", async (context) => { + if (!requireBridge(context.req.raw)) return context.json({ error: "Unauthorized" }, 401); + const parsed = moveEntrySchema.safeParse(await context.req.json().catch(() => undefined)); + if (!parsed.success) return context.json({ error: "Invalid file move request." }, 400); + try { + const root = await resolveCoDevWorktreeRoot(workspaceRoot, parsed.data.worktreeId); + await changes.start(parsed.data.worktreeId); + const service = filesystem.getServiceForRootPath(root); + const source = resolve(root, parsed.data.path); + const destination = resolve(root, parsed.data.parentPath, parsed.data.name); + await service.movePath({ sourceAbsolutePath: source, destinationAbsolutePath: destination }); + const path = asRelativePath(root, destination); + const metadata = await service.getMetadata({ absolutePath: destination }); + if (!metadata) throw new Error("Moved entry could not be read."); + return context.json({ + entry: + metadata.kind === "directory" + ? { path, kind: "directory" } + : { path, kind: "file", size: metadata.size ?? 0 }, + }); + } catch (error) { + return context.json({ error: error instanceof Error ? error.message : "Could not move file." }, 400); + } + }); + + app.post("/codev/entry/delete", async (context) => { + if (!requireBridge(context.req.raw)) return context.json({ error: "Unauthorized" }, 401); + const parsed = deleteEntrySchema.safeParse(await context.req.json().catch(() => undefined)); + if (!parsed.success) return context.json({ error: "Invalid file deletion request." }, 400); + try { + const root = await resolveCoDevWorktreeRoot(workspaceRoot, parsed.data.worktreeId); + await changes.start(parsed.data.worktreeId); + await filesystem.getServiceForRootPath(root).deletePath({ + absolutePath: resolve(root, parsed.data.path), + }); + return context.json({ path: parsed.data.path }); + } catch (error) { + return context.json({ error: error instanceof Error ? error.message : "Could not delete file." }, 400); + } + }); + app.get("/codev/file/changes", async (context) => { if (!requireBridge(context.req.raw)) return context.json({ error: "Unauthorized" }, 401); const parsed = queryInput(context.req.raw, listSchema);