diff --git a/src/features/workspaces/ai/ai-thread-runtime.ts b/src/features/workspaces/ai/ai-thread-runtime.ts index 752f71d91..6521f0c70 100644 --- a/src/features/workspaces/ai/ai-thread-runtime.ts +++ b/src/features/workspaces/ai/ai-thread-runtime.ts @@ -501,6 +501,7 @@ export function getAIThreadSoulPrompt() { "Never use private sandbox files as user-visible workspace items.", "Do not claim to have read actual workspace content unless an actual workspace tool returned it.", "Resolve this/it/that/here/above/the page/this file from current-turn context: selected quotes, then active view, then active/open items. Ask briefly before changes if ambiguous.", + "Treat workspace relationships as ambient navigation and provenance context. Use them silently to find and understand relevant items; do not present routine relationship maintenance as user-facing work. Mention relationships only when the user asks about them or when one materially affects the answer.", "Web tools read public web content only.", ], }, diff --git a/src/features/workspaces/cache-workspace.ts b/src/features/workspaces/cache-workspace.ts index 6c68ed85e..d569f8505 100644 --- a/src/features/workspaces/cache-workspace.ts +++ b/src/features/workspaces/cache-workspace.ts @@ -38,16 +38,16 @@ export function setWorkspacePageCache( input: { workspace: WorkspaceSummary; items: WorkspaceItemSummary[]; - itemFacts?: WorkspacePage["itemFacts"]; - revision?: number; + itemFacts: WorkspacePage["itemFacts"]; + revision: number; }, ) { - queryClient.setQueryData(workspacePageQueryKey(input.workspace.id), (current) => ({ + queryClient.setQueryData(workspacePageQueryKey(input.workspace.id), { workspace: input.workspace, items: input.items, - itemFacts: input.itemFacts ?? current?.itemFacts ?? [], - revision: input.revision ?? current?.revision ?? 0, - })); + itemFacts: input.itemFacts, + revision: input.revision, + }); } export function restoreWorkspaceListCache( diff --git a/src/features/workspaces/components/WorkspaceClipboardIntakeDialog.tsx b/src/features/workspaces/components/WorkspaceClipboardIntakeDialog.tsx index 2dd1afcd1..bc74a074d 100644 --- a/src/features/workspaces/components/WorkspaceClipboardIntakeDialog.tsx +++ b/src/features/workspaces/components/WorkspaceClipboardIntakeDialog.tsx @@ -12,12 +12,12 @@ import type { WorkspaceClipboardIntake } from "#/features/workspaces/clipboard/w import { getWorkspaceObjectRegistryEntry } from "#/features/workspaces/model/object-registry"; import { workspaceColors } from "#/features/workspaces/model/workspace-colors"; import { resolveWorkspaceFileTypeFromHint } from "#/features/workspaces/model/workspace-file"; -import { workspaceItemTypeColors } from "#/features/workspaces/model/workspace-item-colors"; import { cn } from "#/lib/utils"; const documentDisplay = getWorkspaceObjectRegistryEntry("document"); -const documentIconClassName = workspaceColors[workspaceItemTypeColors.document].iconClassName; -const fileIconClassName = workspaceColors[workspaceItemTypeColors.file].iconClassName; +const documentIconClassName = workspaceColors[documentDisplay.color].iconClassName; +const fileIconClassName = + workspaceColors[getWorkspaceObjectRegistryEntry("file").color].iconClassName; export function WorkspaceClipboardIntakeDialog({ intake, diff --git a/src/features/workspaces/components/WorkspaceLayout.tsx b/src/features/workspaces/components/WorkspaceLayout.tsx index e7027df80..fa5c1a3e1 100644 --- a/src/features/workspaces/components/WorkspaceLayout.tsx +++ b/src/features/workspaces/components/WorkspaceLayout.tsx @@ -87,12 +87,7 @@ export function WorkspaceShell({ } applyWorkspaceEventToCache(queryClient, event); }, - onReconnect: () => { - void queryClient.invalidateQueries({ - queryKey: workspacePageQueryKey(workspace.id), - }); - }, - onRevisionGap: () => { + onDesync: () => { void queryClient.invalidateQueries({ queryKey: workspacePageQueryKey(workspace.id), }); diff --git a/src/features/workspaces/components/ai-chat/ai-chat-tool-receipts.ts b/src/features/workspaces/components/ai-chat/ai-chat-tool-receipts.ts index 4ee8533ba..5112f104b 100644 --- a/src/features/workspaces/components/ai-chat/ai-chat-tool-receipts.ts +++ b/src/features/workspaces/components/ai-chat/ai-chat-tool-receipts.ts @@ -26,7 +26,11 @@ export function getRunningToolReceipt(input: { case "workspace_move_items": return running(`Moving ${formatCount(getArray(toolInput.paths).length, "item")}`); case "workspace_read_items": - return running(`Reading ${formatToolInputPaths(toolInput.paths)}`); + return running( + `Reading ${formatToolInputPaths( + getArray(toolInput.requests).map((request) => asRecord(request).path), + )}`, + ); case "workspace_rename_item": return running(`Renaming ${quoteName(getBaseName(getString(toolInput.path)))}`); case "web_links": @@ -249,22 +253,35 @@ function summarizeWorkspaceList(output: unknown): AiChatToolReceipt { function summarizeWorkspaceRead(output: unknown): AiChatToolReceipt { const record = asRecord(output); - const items = getArray(record.items); - const failedCount = - getArray(record.failed).length + - items.filter((item) => getString(asRecord(item).status) === "failed").length; - const readyItems = items.filter((item) => getString(asRecord(item).status) === "ready"); + const results = getArray(record.results); + const failedCount = results.filter( + (result) => getString(asRecord(result).status) === "failed", + ).length; + const readyItems = results.filter((item) => getString(asRecord(item).status) === "ready"); + const pendingItems = results.filter((item) => getString(asRecord(item).status) === "pending"); - if (readyItems.length === 0 && failedCount > 0) { + if (readyItems.length === 0 && pendingItems.length === 0 && failedCount > 0) { return failed(`Couldn’t read ${formatCount(failedCount, "item")}`); } + if (readyItems.length === 0) { + return completed( + appendFailureCount( + `Extraction in progress for ${formatCount(pendingItems.length, "item")}`, + failedCount, + ), + ); + } const summary = readyItems.length === 1 ? `Read ${quoteName(getBaseName(getString(asRecord(readyItems[0]).path)))}` : `Read ${formatCount(readyItems.length, "item")}`; - return completed(appendFailureCount(summary, failedCount)); + const pendingSummary = + pendingItems.length > 0 + ? `${summary} · ${formatCount(pendingItems.length, "item")} still processing` + : summary; + return completed(appendFailureCount(pendingSummary, failedCount)); } function summarizeWebSearch(output: unknown, toolInput: unknown) { diff --git a/src/features/workspaces/content/workspace-content-contract.test.ts b/src/features/workspaces/content/workspace-content-contract.test.ts new file mode 100644 index 000000000..317894767 --- /dev/null +++ b/src/features/workspaces/content/workspace-content-contract.test.ts @@ -0,0 +1,89 @@ +import { asSchema } from "ai"; +import { describe, expect, it } from "vitest"; + +import { + workspaceReadItemsInputSchema, + workspaceReadItemsOutputSchema, +} from "#/features/workspaces/content/workspace-content-contract"; + +describe("workspace read tool schemas", () => { + it("uses one explicit read mode per request", () => { + expect( + workspaceReadItemsInputSchema.safeParse({ + requests: [ + { mode: "start", path: "/Notes" }, + { mode: "pages", path: "/Book.pdf", range: "1-3" }, + { cursor: "opaque", mode: "continue", path: "/Notes" }, + ], + }).success, + ).toBe(true); + expect( + workspaceReadItemsInputSchema.safeParse({ + requests: [{ path: "/Book.pdf", pages: "1-3" }], + }).success, + ).toBe(false); + expect( + workspaceReadItemsInputSchema.safeParse({ requests: [{ path: "/Notes" }] }).success, + ).toBe(false); + }); + + it("emits a strict-provider-compatible JSON Schema", () => { + const { jsonSchema } = asSchema(workspaceReadItemsInputSchema); + + expect(jsonSchema).toMatchObject({ + additionalProperties: false, + properties: { + requests: { + items: { + anyOf: [ + { additionalProperties: false, required: ["path", "mode"] }, + { additionalProperties: false, required: ["path", "mode", "range"] }, + { additionalProperties: false, required: ["path", "cursor", "mode"] }, + ], + }, + }, + }, + required: ["requests"], + }); + expect(JSON.stringify(jsonSchema)).not.toContain('"oneOf"'); + }); + + it("keeps document and file result shapes disjoint", () => { + expect( + workspaceReadItemsOutputSchema.safeParse({ + results: [ + { + content: "# Notes", + format: "markdown", + location: { endLine: 1, kind: "lines", startLine: 1, totalLines: 1 }, + path: "/Notes", + status: "ready", + type: "document", + }, + { + content: "Page one", + format: "markdown", + location: { kind: "pages", requested: "1", returned: [1], total: 1 }, + path: "/Book.pdf", + status: "ready", + type: "file", + }, + ], + }).success, + ).toBe(true); + expect( + workspaceReadItemsOutputSchema.safeParse({ + results: [ + { + content: "Page one", + format: "markdown", + location: { kind: "pages", requested: "1", returned: [1], total: 1 }, + path: "/Notes", + status: "ready", + type: "document", + }, + ], + }).success, + ).toBe(false); + }); +}); diff --git a/src/features/workspaces/content/workspace-content-contract.ts b/src/features/workspaces/content/workspace-content-contract.ts new file mode 100644 index 000000000..7b8157837 --- /dev/null +++ b/src/features/workspaces/content/workspace-content-contract.ts @@ -0,0 +1,118 @@ +import { z } from "zod"; + +import { workspaceRelationKindSchema } from "#/features/workspaces/contracts"; + +const workspacePathSchema = z.string().min(1); + +const readWorkspaceItemsFailureCodes = [ + "content_changed", + "invalid_cursor", + "invalid_selection", + "page_range_out_of_range", + "page_selection_too_large", + "read_budget_exceeded", + "path_is_folder", + "path_not_absolute", + "path_not_found", + "projection_failed", + "unsupported_item_type", +] as const; + +const workspacePageRangeSchema = z + .string() + .trim() + .min(1) + .regex(/^\d+(?:\s*-\s*\d+)?(?:\s*,\s*\d+(?:\s*-\s*\d+)?)*$/) + .describe( + "Up to 20 physical pages from an extracted file, like 1, 3, 5-7, or 1,4-6. Defaults to page 1.", + ); + +const workspaceContentReadRequestBase = { + path: workspacePathSchema.describe("Absolute path of the workspace item to read."), +}; + +const workspaceContentReadRequestSchema = z.union([ + z.strictObject({ + ...workspaceContentReadRequestBase, + mode: z.literal("start"), + }), + z.strictObject({ + ...workspaceContentReadRequestBase, + mode: z.literal("pages"), + range: workspacePageRangeSchema, + }), + z.strictObject({ + ...workspaceContentReadRequestBase, + cursor: z.string().min(1).max(4_096).describe("Opaque cursor returned by a previous read."), + mode: z.literal("continue"), + }), +]); + +export const workspaceReadItemsInputSchema = z.object({ + requests: z + .array(workspaceContentReadRequestSchema) + .min(1) + .max(20) + .describe("Ordered workspace content reads."), +}); + +const workspaceReadPagesSchema = z.object({ + requested: z.string().describe("Requested page range."), + returned: z.array(z.number().int().min(1)).describe("Page numbers included in content."), + total: z.number().int().min(1).describe("Total pages available."), +}); + +const workspaceReadRelationsSchema = z.array( + z.object({ + direction: z.enum(["incoming", "outgoing"]), + kind: workspaceRelationKindSchema, + note: z.string().optional(), + path: workspacePathSchema, + }), +); + +const workspaceContentReadResultSchema = z.union([ + z.object({ + content: z.string(), + format: z.literal("markdown"), + location: z.object({ + endLine: z.number().int().nonnegative(), + kind: z.literal("lines"), + startLine: z.number().int().nonnegative(), + totalLines: z.number().int().nonnegative(), + }), + nextCursor: z.string().optional(), + path: workspacePathSchema, + relations: workspaceReadRelationsSchema.optional(), + status: z.literal("ready"), + type: z.literal("document"), + }), + z.object({ + content: z.string(), + format: z.literal("markdown"), + location: workspaceReadPagesSchema.extend({ kind: z.literal("pages") }), + nextCursor: z.string().optional(), + path: workspacePathSchema, + relations: workspaceReadRelationsSchema.optional(), + status: z.literal("ready"), + type: z.literal("file"), + }), + z.object({ + path: workspacePathSchema, + status: z.literal("pending"), + type: z.literal("file"), + }), + z.object({ + code: z.enum(readWorkspaceItemsFailureCodes), + path: workspacePathSchema, + status: z.literal("failed"), + type: z.literal("file").optional(), + }), +]); + +export const workspaceReadItemsOutputSchema = z.object({ + results: z.array(workspaceContentReadResultSchema), +}); + +export type WorkspaceContentReadRequest = z.output; +export type WorkspaceContentReadResult = z.output; diff --git a/src/features/workspaces/content/workspace-content-cursor.ts b/src/features/workspaces/content/workspace-content-cursor.ts new file mode 100644 index 000000000..54ff00b50 --- /dev/null +++ b/src/features/workspaces/content/workspace-content-cursor.ts @@ -0,0 +1,37 @@ +import { z } from "zod"; + +import { decodeBase64UrlText, encodeBase64UrlText } from "#/lib/binary"; + +const workspaceContentCursorSchema = z.discriminatedUnion("kind", [ + z.object({ + itemId: z.string().min(1), + kind: z.literal("document"), + offset: z.number().int().nonnegative(), + revision: z.string().min(1), + version: z.literal(1), + }), + z.object({ + itemId: z.string().min(1), + kind: z.literal("file"), + nextPage: z.number().int().positive(), + sourceHash: z.string().min(1), + version: z.literal(1), + }), +]); + +type WorkspaceContentCursor = z.infer; + +export function encodeWorkspaceContentCursor(cursor: WorkspaceContentCursor) { + return encodeBase64UrlText(JSON.stringify(cursor)); +} + +export function decodeWorkspaceContentCursor(value: string): WorkspaceContentCursor | null { + if (value.length > 4_096) { + return null; + } + try { + return workspaceContentCursorSchema.parse(JSON.parse(decodeBase64UrlText(value))); + } catch { + return null; + } +} diff --git a/src/features/workspaces/content/workspace-content-reader.test.ts b/src/features/workspaces/content/workspace-content-reader.test.ts new file mode 100644 index 000000000..3d525f686 --- /dev/null +++ b/src/features/workspaces/content/workspace-content-reader.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { WorkspaceItemSummary } from "#/features/workspaces/contracts"; +import type { WorkspaceContentReadRequest } from "#/features/workspaces/content/workspace-content-contract"; +import { createDocumentMarkdownSnapshot } from "#/features/workspaces/documents/document-markdown-chunk"; +import type { WorkspaceKernelClient } from "#/features/workspaces/kernel/workspace-kernel-access"; +import type { WorkspaceKernelPathResolution } from "#/features/workspaces/kernel/workspace-kernel-types"; +import { readWorkspaceContent } from "#/features/workspaces/content/workspace-content-reader"; +import { encodeWorkspaceContentCursor } from "#/features/workspaces/content/workspace-content-cursor"; + +const documentItem: WorkspaceItemSummary = { + id: "document-1", + workspaceId: "workspace-1", + parentId: null, + type: "document", + title: "Notes", + name: "Notes", + meta: "Document", + color: null, + metadataJson: {}, + sortOrder: 1, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + deletedAt: null, +}; + +describe("WorkspaceContentReader", () => { + it("continues a large live document with a revision-guarded cursor", async () => { + const markdown = Array.from({ length: 20_000 }, (_, index) => `line ${index + 1}`).join("\n"); + const session = createDocumentSession({ markdown, revision: "revision-1" }); + const read = createReader({ + bucket: {} as R2Bucket, + getDocumentSession: () => session, + kernel: createKernel(), + }); + + const [first] = await read([{ mode: "start", path: "/Notes" }]); + expect(first).toMatchObject({ + format: "markdown", + location: { kind: "lines", startLine: 1, totalLines: 20_000 }, + path: "/Notes", + status: "ready", + type: "document", + }); + if ( + !first || + first.status !== "ready" || + first.location.kind !== "lines" || + !first.nextCursor + ) { + throw new Error("Expected the first document chunk to have a continuation cursor."); + } + + const [second] = await read([{ cursor: first.nextCursor, mode: "continue", path: "/Notes" }]); + expect(second).toMatchObject({ + location: { kind: "lines" }, + path: "/Notes", + status: "ready", + type: "document", + }); + if (!second || second.status !== "ready" || second.location.kind !== "lines") { + throw new Error("Expected a continued document chunk."); + } + expect(second.location.startLine).toBeGreaterThan(first.location.startLine); + }); + + it("rejects continuation when the live document revision changed", async () => { + const session = createDocumentSession({ + markdown: "a\n".repeat(40_000), + revision: "revision-1", + }); + const read = createReader({ + bucket: {} as R2Bucket, + getDocumentSession: () => session, + kernel: createKernel(), + }); + const [first] = await read([{ mode: "start", path: "/Notes" }]); + if (!first || first.status !== "ready" || !first.nextCursor) { + throw new Error("Expected a continuation cursor."); + } + + session.readMarkdownChunk = vi.fn(async () => ({ status: "content_changed" })); + await expect( + read([{ cursor: first.nextCursor, mode: "continue", path: "/Notes" }]), + ).resolves.toEqual([{ code: "content_changed", path: "/Notes", status: "failed" }]); + }); + + it("preserves document whitespace across chunk boundaries", async () => { + const markdown = `heading \n\n indented code\n${"x".repeat(64_000)}\n`; + const read = createReader({ + bucket: {} as R2Bucket, + getDocumentSession: () => createDocumentSession({ markdown, revision: "revision-1" }), + kernel: createKernel(), + }); + + const contents: string[] = []; + let request: WorkspaceContentReadRequest = { mode: "start", path: "/Notes" }; + for (;;) { + const [result] = await read([request]); + expect(result).toMatchObject({ status: "ready", type: "document" }); + if (!result || result.status !== "ready") { + throw new Error("Expected a document chunk."); + } + contents.push(result.content); + if (!result.nextCursor) { + break; + } + request = { cursor: result.nextCursor, mode: "continue", path: "/Notes" }; + } + expect(contents.join("")).toBe(markdown); + }); + + it("rejects a nonzero continuation offset for an empty document", async () => { + const read = createReader({ + bucket: {} as R2Bucket, + getDocumentSession: () => createDocumentSession({ markdown: "", revision: "revision-1" }), + kernel: createKernel(), + }); + const cursor = encodeWorkspaceContentCursor({ + itemId: documentItem.id, + kind: "document", + offset: 1, + revision: "revision-1", + version: 1, + }); + + await expect(read([{ cursor, mode: "continue", path: "/Notes" }])).resolves.toEqual([ + { code: "invalid_cursor", path: "/Notes", status: "failed" }, + ]); + }); + + it("bounds total content returned by a batch", async () => { + const read = createReader({ + bucket: {} as R2Bucket, + getDocumentSession: () => + createDocumentSession({ markdown: "😀".repeat(40_000), revision: "revision-1" }), + kernel: createKernel(), + }); + const requests = Array.from({ length: 20 }, (_, index) => ({ + mode: "start" as const, + path: `/Notes ${index + 1}`, + })); + + const results = await read(requests); + expect(results.filter((result) => result.status === "ready")).toHaveLength(16); + expect(results.slice(16)).toEqual( + requests.slice(16).map((request) => ({ + code: "read_budget_exceeded", + path: request.path, + status: "failed", + })), + ); + }); + + it("keeps one ordered result for every requested path", async () => { + const kernel = createKernel(); + kernel.resolvePaths = vi.fn( + async () => + [ + { code: "path_not_absolute", path: "Notes", status: "invalid_path" }, + { path: "/Missing", status: "not_found" }, + { path: "/", status: "root" }, + ] satisfies WorkspaceKernelPathResolution[], + ); + const read = createReader({ + bucket: {} as R2Bucket, + getDocumentSession: () => createDocumentSession({ markdown: "", revision: "revision-1" }), + kernel, + }); + + await expect( + read([ + { mode: "start", path: "Notes" }, + { mode: "start", path: "/Missing" }, + { mode: "start", path: "/" }, + ]), + ).resolves.toEqual([ + { code: "path_not_absolute", path: "Notes", status: "failed" }, + { code: "path_not_found", path: "/Missing", status: "failed" }, + { code: "path_is_folder", path: "/", status: "failed" }, + ]); + }); +}); + +function createDocumentSession(snapshot: { markdown: string; revision: string }) { + return { + readMarkdownChunk: vi.fn(async ({ expectedRevision, offset }) => { + if (expectedRevision && expectedRevision !== snapshot.revision) { + return { status: "content_changed" as const }; + } + const chunk = createDocumentMarkdownSnapshot(snapshot.markdown).readChunk(offset); + return chunk + ? { ...chunk, revision: snapshot.revision, status: "ready" as const } + : { status: "invalid_offset" as const }; + }), + }; +} + +function createKernel() { + return { + resolvePaths: vi.fn(async ({ paths }: { paths: string[] }) => + paths.map((path) => ({ item: documentItem, path, status: "item" as const })), + ), + listItemRelations: vi.fn(async () => []), + getItemPaths: vi.fn(async () => [{ itemId: documentItem.id, path: "/Notes" }]), + } as unknown as WorkspaceKernelClient; +} + +function createReader(input: { + bucket: R2Bucket; + getDocumentSession: (itemId: string) => ReturnType; + kernel: WorkspaceKernelClient; +}) { + return (requests: WorkspaceContentReadRequest[]) => readWorkspaceContent({ ...input, requests }); +} diff --git a/src/features/workspaces/content/workspace-content-reader.ts b/src/features/workspaces/content/workspace-content-reader.ts new file mode 100644 index 000000000..4a2cc7cef --- /dev/null +++ b/src/features/workspaces/content/workspace-content-reader.ts @@ -0,0 +1,289 @@ +import type { WorkspaceItemSummary } from "#/features/workspaces/contracts"; +import type { + WorkspaceContentReadRequest, + WorkspaceContentReadResult, +} from "#/features/workspaces/content/workspace-content-contract"; +import type { + DocumentMarkdownChunkReadInput, + DocumentMarkdownChunkReadResult, +} from "#/features/workspaces/documents/document-markdown-chunk"; +import { readWorkspacePageProjection } from "#/features/workspaces/extraction/workspace-page-projection"; +import type { WorkspaceKernelClient } from "#/features/workspaces/kernel/workspace-kernel-access"; +import { resolveWorkspaceFileTypeFromItem } from "#/features/workspaces/model/workspace-file"; +import { serializeWorkspaceRelations } from "#/features/workspaces/operations/relations"; +import { WorkspacePageSelectionError } from "#/features/workspaces/read-page-selection"; +import { + decodeWorkspaceContentCursor, + encodeWorkspaceContentCursor, +} from "#/features/workspaces/content/workspace-content-cursor"; + +const maxWorkspaceContentBatchBytes = 2 * 1024 * 1024 + 64 * 1024; + +interface DocumentContentReader { + readMarkdownChunk( + input: DocumentMarkdownChunkReadInput, + ): Promise; +} + +interface PendingReadyResult { + item: WorkspaceItemSummary; + read: Extract; + relations: Awaited>; +} + +export async function readWorkspaceContent(input: { + bucket: R2Bucket; + getDocumentSession: (itemId: string) => DocumentContentReader; + kernel: WorkspaceKernelClient; + requests: WorkspaceContentReadRequest[]; +}): Promise { + const { requests } = input; + const encoder = new TextEncoder(); + const resolutions = await input.kernel.resolvePaths({ + paths: requests.map((request) => request.path), + }); + const results: WorkspaceContentReadResult[] = []; + const readyResults: PendingReadyResult[] = []; + let returnedContentBytes = 0; + + // Reads stay ordered so each body is consumed before the shared byte budget advances. + for (const [index, resolution] of resolutions.entries()) { + const request = requests[index]; + if (!request) { + throw new Error("Workspace content resolution did not match its request."); + } + if (resolution.status === "invalid_path") { + results.push({ code: resolution.code, path: resolution.path, status: "failed" }); + continue; + } + if (resolution.status === "root") { + results.push({ code: "path_is_folder", path: resolution.path, status: "failed" }); + continue; + } + if (resolution.status === "not_found") { + results.push({ code: "path_not_found", path: resolution.path, status: "failed" }); + continue; + } + if (resolution.item.type === "folder") { + results.push({ code: "path_is_folder", path: resolution.path, status: "failed" }); + continue; + } + + try { + const read = await readWorkspaceItem({ + ...input, + item: resolution.item, + request, + path: resolution.path, + }); + if (read.status !== "ready") { + results.push(read); + continue; + } + const contentBytes = encoder.encode(read.content).byteLength; + if (returnedContentBytes + contentBytes > maxWorkspaceContentBatchBytes) { + results.push({ + code: "read_budget_exceeded", + path: resolution.path, + status: "failed", + ...(resolution.item.type === "file" ? { type: "file" as const } : {}), + }); + continue; + } + returnedContentBytes += contentBytes; + + const pending = { + item: resolution.item, + read, + relations: await input.kernel.listItemRelations({ itemId: resolution.item.id }), + }; + readyResults.push(pending); + results.push(read); + } catch (error) { + if (error instanceof WorkspacePageSelectionError) { + results.push({ code: error.code, path: resolution.path, status: "failed" }); + continue; + } + throw error; + } + } + + await attachRelationPaths(input.kernel, readyResults); + return results; +} + +async function readWorkspaceItem(input: { + bucket: R2Bucket; + getDocumentSession: (itemId: string) => DocumentContentReader; + item: WorkspaceItemSummary; + kernel: WorkspaceKernelClient; + path: string; + request: WorkspaceContentReadRequest; +}): Promise { + if (input.item.type === "document") { + return readDocument(input); + } + if (input.item.type === "file") { + return readFile(input); + } + return { code: "unsupported_item_type", path: input.path, status: "failed" }; +} + +async function readDocument(input: { + getDocumentSession: (itemId: string) => DocumentContentReader; + item: WorkspaceItemSummary; + path: string; + request: WorkspaceContentReadRequest; +}): Promise { + if (input.request.mode === "pages") { + return { code: "invalid_selection", path: input.path, status: "failed" }; + } + + const encodedCursor = input.request.mode === "continue" ? input.request.cursor : undefined; + const cursor = encodedCursor ? decodeWorkspaceContentCursor(encodedCursor) : undefined; + if (encodedCursor && (!cursor || cursor.kind !== "document" || cursor.itemId !== input.item.id)) { + return { code: "invalid_cursor", path: input.path, status: "failed" }; + } + + const chunk = await input.getDocumentSession(input.item.id).readMarkdownChunk({ + expectedRevision: cursor?.kind === "document" ? cursor.revision : undefined, + offset: cursor?.kind === "document" ? cursor.offset : 0, + }); + if (chunk.status === "content_changed") { + return { code: "content_changed", path: input.path, status: "failed" }; + } + if (chunk.status === "invalid_offset") { + return { code: "invalid_cursor", path: input.path, status: "failed" }; + } + + return { + content: chunk.content, + format: "markdown", + location: { kind: "lines", ...chunk.location }, + ...(chunk.nextOffset === undefined + ? {} + : { + nextCursor: encodeWorkspaceContentCursor({ + itemId: input.item.id, + kind: "document", + offset: chunk.nextOffset, + revision: chunk.revision, + version: 1, + }), + }), + path: input.path, + status: "ready", + type: "document", + }; +} + +async function readFile(input: { + bucket: R2Bucket; + item: WorkspaceItemSummary; + kernel: WorkspaceKernelClient; + path: string; + request: WorkspaceContentReadRequest; +}): Promise { + const fileType = resolveWorkspaceFileTypeFromItem(input.item); + if (!fileType || fileType.aiReadStrategy !== "markdown_extraction") { + return { code: "unsupported_item_type", path: input.path, status: "failed" }; + } + + const projection = await input.kernel.readFileProjection({ + itemId: input.item.id, + format: "pages", + }); + if ( + !projection || + projection.status === "not_started" || + projection.status === "queued" || + projection.status === "processing" + ) { + return { path: input.path, status: "pending", type: "file" }; + } + if ( + projection.status !== "ready" || + projection.objectKey === null || + projection.sourceHash === null + ) { + return { code: "projection_failed", path: input.path, status: "failed", type: "file" }; + } + + const encodedCursor = input.request.mode === "continue" ? input.request.cursor : undefined; + const cursor = encodedCursor ? decodeWorkspaceContentCursor(encodedCursor) : undefined; + if (encodedCursor && (!cursor || cursor.kind !== "file" || cursor.itemId !== input.item.id)) { + return { code: "invalid_cursor", path: input.path, status: "failed" }; + } + if (cursor?.kind === "file" && cursor.sourceHash !== projection.sourceHash) { + return { code: "content_changed", path: input.path, status: "failed" }; + } + let pageRead: Awaited>; + try { + pageRead = await readWorkspacePageProjection({ + bucket: input.bucket, + expectedSourceHash: projection.sourceHash, + manifestObjectKey: projection.objectKey, + pages: + cursor?.kind === "file" + ? String(cursor.nextPage) + : input.request.mode === "pages" + ? input.request.range + : undefined, + }); + } catch (error) { + if (error instanceof WorkspacePageSelectionError) { + throw error; + } + return { code: "projection_failed", path: input.path, status: "failed", type: "file" }; + } + const nextPage = Math.max(...pageRead.pages.returned) + 1; + return { + content: pageRead.content, + format: "markdown", + location: { kind: "pages", ...pageRead.pages }, + ...(nextPage > pageRead.pages.total + ? {} + : { + nextCursor: encodeWorkspaceContentCursor({ + itemId: input.item.id, + kind: "file", + nextPage, + sourceHash: projection.sourceHash, + version: 1, + }), + }), + path: input.path, + status: "ready", + type: "file", + }; +} + +async function attachRelationPaths( + kernel: WorkspaceKernelClient, + readyResults: PendingReadyResult[], +) { + if (readyResults.length === 0) { + return; + } + const relatedItemIds = new Set(); + for (const result of readyResults) { + relatedItemIds.add(result.item.id); + for (const relation of result.relations) { + relatedItemIds.add(relation.fromItemId); + relatedItemIds.add(relation.toItemId); + } + } + const itemPaths = await kernel.getItemPaths({ itemIds: Array.from(relatedItemIds) }); + const pathsByItemId = new Map(itemPaths.map((item) => [item.itemId, item.path])); + + for (const result of readyResults) { + const relations = serializeWorkspaceRelations({ + item: result.item, + pathsByItemId, + relations: result.relations, + }); + if (relations.length > 0) { + result.read.relations = relations; + } + } +} diff --git a/src/features/workspaces/contracts.ts b/src/features/workspaces/contracts.ts index 901955304..3f82a6408 100644 --- a/src/features/workspaces/contracts.ts +++ b/src/features/workspaces/contracts.ts @@ -1,5 +1,13 @@ import { z } from "zod"; +import { + workspaceItemTypeSchema, + type WorkspaceItemType, +} from "#/features/workspaces/workspace-item-registry"; + +export { workspaceItemTypeSchema }; +export type { WorkspaceItemType }; + export type JsonValue = | string | number @@ -190,8 +198,6 @@ export const workspaceSummarySchema = z.object({ membershipRole: workspaceMembershipRoleSchema, }); -export const workspaceItemTypeSchema = z.enum(["folder", "document", "file", "flashcard", "quiz"]); - export const workspaceItemFactsSchema = z.object({ itemId: z.string(), pageCount: z.number().int().positive().optional(), @@ -216,7 +222,7 @@ export const workspaceItemSummarySchema = z.object({ export const createWorkspaceItemInputSchema = z .object({ - id: z.uuid().optional(), + id: z.uuid(), workspaceId: z.string().min(1), parentId: z.string().min(1).nullable().optional(), type: workspaceItemTypeSchema, @@ -308,7 +314,6 @@ export type WorkspaceColor = z.infer; export type WorkspaceItemColor = z.infer; export type WorkspaceSummary = z.infer; export type WorkspaceDetail = WorkspaceSummary; -export type WorkspaceItemType = z.infer; export type WorkspaceItemFacts = z.infer; export type WorkspaceItemSummary = z.infer; export type CreateWorkspaceItemInput = z.infer; diff --git a/src/features/workspaces/defaults.ts b/src/features/workspaces/defaults.ts index f2b760227..526e8d607 100644 --- a/src/features/workspaces/defaults.ts +++ b/src/features/workspaces/defaults.ts @@ -3,6 +3,7 @@ import type { WorkspaceIcon, WorkspaceItemType, } from "#/features/workspaces/contracts"; +import { getWorkspaceItemRegistryEntry } from "#/features/workspaces/workspace-item-registry"; export const DEFAULT_WORKSPACE_NAME = "Untitled Workspace"; export const DEFAULT_WORKSPACE_COLOR = "sky" satisfies WorkspaceColor; @@ -10,33 +11,11 @@ export const DEFAULT_WORKSPACE_ICON = "compass" satisfies WorkspaceIcon; export const WORKSPACE_ITEM_SORT_STEP = 1024; export function getDefaultWorkspaceItemName(type: WorkspaceItemType) { - switch (type) { - case "folder": - return "New folder"; - case "document": - return "New document"; - case "file": - return "New file"; - case "flashcard": - return "New flashcards"; - case "quiz": - return "New quiz"; - } + return getWorkspaceItemRegistryEntry(type).defaultName; } export function getWorkspaceItemTypeMeta(type: WorkspaceItemType) { - switch (type) { - case "folder": - return "Folder"; - case "document": - return "Document"; - case "file": - return "File"; - case "flashcard": - return "Flashcards"; - case "quiz": - return "Quiz"; - } + return getWorkspaceItemRegistryEntry(type).menuLabel; } export function getAvailableWorkspaceItemName(input: { diff --git a/src/features/workspaces/document-session-access.ts b/src/features/workspaces/document-session-access.ts index d4561ebf6..993298df0 100644 --- a/src/features/workspaces/document-session-access.ts +++ b/src/features/workspaces/document-session-access.ts @@ -1,17 +1,8 @@ import { getDocumentSessionRoomName } from "#/features/workspaces/agent-routes"; -import type { DocumentSessionApplyMarkdownEditsResult } from "#/features/workspaces/documents/document-session"; -import type { DocumentMarkdownEdit } from "#/features/workspaces/documents/document-markdown-edits"; - -export interface DocumentSessionClient { - applyMarkdownEdits(input: { - edits: DocumentMarkdownEdit[]; - }): Promise; - purgeForDeletion(): Promise; -} export function getDocumentSessionFromEnv( env: Cloudflare.Env, input: { itemId: string; workspaceId: string }, -): DocumentSessionClient { - return env.DocumentSession.getByName(getDocumentSessionRoomName(input)) as DocumentSessionClient; +) { + return env.DocumentSession.getByName(getDocumentSessionRoomName(input)); } diff --git a/src/features/workspaces/documents/document-item-content.ts b/src/features/workspaces/documents/document-item-content.ts index f27c9205a..334bd4ef7 100644 --- a/src/features/workspaces/documents/document-item-content.ts +++ b/src/features/workspaces/documents/document-item-content.ts @@ -14,12 +14,10 @@ export function prepareDocumentItemMetadata( /** Shared create-time content + metadata for kernel writes and optimistic UI. */ export function buildWorkspaceItemCreateBootstrap(input: { type: WorkspaceItemType; - name: string; metadataJson?: Record; initialContent?: string; }) { - const initialContent = - input.initialContent ?? getInitialWorkspaceKernelContent(input.type, input.name); + const initialContent = input.initialContent ?? getInitialWorkspaceKernelContent(input.type); const metadataJson = input.type === "document" ? prepareDocumentItemMetadata(input.metadataJson ?? {}, initialContent) diff --git a/src/features/workspaces/documents/document-markdown-chunk.test.ts b/src/features/workspaces/documents/document-markdown-chunk.test.ts new file mode 100644 index 000000000..2da2607b8 --- /dev/null +++ b/src/features/workspaces/documents/document-markdown-chunk.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; + +import { createDocumentMarkdownSnapshot } from "#/features/workspaces/documents/document-markdown-chunk"; + +describe("document Markdown snapshots", () => { + it("preserves exact content and indexed line locations across chunks", () => { + const markdown = `heading \n\n${"x".repeat(64_000)}\ntail\n`; + const snapshot = createDocumentMarkdownSnapshot(markdown); + const first = snapshot.readChunk(0); + if (!first?.nextOffset) { + throw new Error("Expected a continuation offset."); + } + const second = snapshot.readChunk(first.nextOffset); + if (!second) { + throw new Error("Expected a second chunk."); + } + + expect(first.content + second.content).toBe(markdown); + expect(first.location).toEqual({ endLine: 3, startLine: 1, totalLines: 5 }); + expect(second.location).toEqual({ endLine: 5, startLine: 3, totalLines: 5 }); + }); + + it("rejects nonzero offsets for empty or exhausted snapshots", () => { + expect(createDocumentMarkdownSnapshot("").readChunk(1)).toBeUndefined(); + expect(createDocumentMarkdownSnapshot("text").readChunk(4)).toBeUndefined(); + }); + + it("keeps surrogate pairs intact at a hard chunk boundary", () => { + const markdown = `${"a".repeat(63_999)}😀tail`; + const snapshot = createDocumentMarkdownSnapshot(markdown); + const first = snapshot.readChunk(0); + if (!first?.nextOffset) { + throw new Error("Expected a continuation offset."); + } + const second = snapshot.readChunk(first.nextOffset); + + expect(first.content.endsWith("a")).toBe(true); + expect(second?.content.startsWith("😀")).toBe(true); + expect(first.content + second?.content).toBe(markdown); + }); +}); diff --git a/src/features/workspaces/documents/document-markdown-chunk.ts b/src/features/workspaces/documents/document-markdown-chunk.ts new file mode 100644 index 000000000..52e9ca754 --- /dev/null +++ b/src/features/workspaces/documents/document-markdown-chunk.ts @@ -0,0 +1,93 @@ +const maxDocumentChunkCharacters = 64_000; +const minDocumentChunkCharacters = maxDocumentChunkCharacters / 2; + +export interface DocumentMarkdownChunk { + content: string; + location: { + endLine: number; + startLine: number; + totalLines: number; + }; + nextOffset?: number; +} + +export interface DocumentMarkdownChunkReadInput { + expectedRevision?: string; + offset: number; +} + +export type DocumentMarkdownChunkReadResult = + | { status: "content_changed" } + | { status: "invalid_offset" } + | ({ revision: string; status: "ready" } & DocumentMarkdownChunk); + +export interface DocumentMarkdownSnapshot { + readChunk(offset: number): DocumentMarkdownChunk | undefined; +} + +export function createDocumentMarkdownSnapshot(markdown: string): DocumentMarkdownSnapshot { + const lineStarts = getLineStarts(markdown); + + return { + readChunk(offset) { + if (offset < 0 || (offset !== 0 && offset >= markdown.length)) { + return undefined; + } + + const candidateEnd = Math.min(markdown.length, offset + maxDocumentChunkCharacters); + const hardEnd = splitsSurrogatePair(markdown, candidateEnd) ? candidateEnd - 1 : candidateEnd; + const newlineEnd = markdown.lastIndexOf("\n", hardEnd); + const end = + hardEnd < markdown.length && newlineEnd > offset + minDocumentChunkCharacters + ? newlineEnd + 1 + : hardEnd; + const content = markdown.slice(offset, end); + + return { + content, + location: { + endLine: content ? findLineNumber(lineStarts, end) : 0, + startLine: content ? findLineNumber(lineStarts, offset) : 0, + totalLines: lineStarts.length, + }, + ...(end < markdown.length ? { nextOffset: end } : {}), + }; + }, + }; +} + +function splitsSurrogatePair(value: string, offset: number) { + const previous = value.charCodeAt(offset - 1); + const next = value.charCodeAt(offset); + return previous >= 0xd800 && previous <= 0xdbff && next >= 0xdc00 && next <= 0xdfff; +} + +function getLineStarts(markdown: string) { + if (!markdown) { + return []; + } + + const lineStarts = [0]; + for ( + let index = markdown.indexOf("\n"); + index !== -1; + index = markdown.indexOf("\n", index + 1) + ) { + lineStarts.push(index + 1); + } + return lineStarts; +} + +function findLineNumber(lineStarts: number[], offset: number) { + let low = 0; + let high = lineStarts.length; + while (low < high) { + const middle = Math.floor((low + high) / 2); + if ((lineStarts[middle] ?? 0) <= offset) { + low = middle + 1; + } else { + high = middle; + } + } + return low; +} diff --git a/src/features/workspaces/documents/document-session.ts b/src/features/workspaces/documents/document-session.ts index bd0a76c60..d9ee7f9dc 100644 --- a/src/features/workspaces/documents/document-session.ts +++ b/src/features/workspaces/documents/document-session.ts @@ -11,6 +11,12 @@ import { parseMarkdownToTiptapDocumentProjection, serializeTiptapDocumentToMarkdown, } from "#/features/workspaces/documents/document-markdown"; +import { + createDocumentMarkdownSnapshot, + type DocumentMarkdownChunkReadInput, + type DocumentMarkdownChunkReadResult, + type DocumentMarkdownSnapshot, +} from "#/features/workspaces/documents/document-markdown-chunk"; import { applyDocumentMarkdownEdits, type DocumentMarkdownEdit, @@ -36,6 +42,7 @@ import { type WorkspaceKernelClient, } from "#/features/workspaces/kernel/workspace-kernel-access"; import { recordOperationalFailure } from "#/integrations/observability/operational-events"; +import { sha256Base64Url } from "#/lib/binary"; const persistedYDocUpdateKey = "document-session:yjs-update"; const checkpointDelayMs = 1_500; @@ -61,6 +68,12 @@ export class DocumentSession extends YServer { hibernate: true, }; + private markdownSnapshot?: { + revision: string; + snapshot: DocumentMarkdownSnapshot; + stateVector: Uint8Array; + }; + static override callbackOptions = { debounceWait: checkpointDelayMs, debounceMaxWait: checkpointMaxWaitMs, @@ -107,14 +120,10 @@ export class DocumentSession extends YServer { override async onLoad() { const room = getDocumentSessionRoomNameParts(this.name); const kernel = await this.getWorkspaceKernel(room.workspaceId); - const { item, content } = await kernel.readItem({ itemId: room.itemId }); - - if (item.type !== "document") { - throw new Error("Document session can only open document items."); - } - - const persistedUpdate = await this.ctx.storage.get(persistedYDocUpdateKey); - + const [{ content }, persistedUpdate] = await Promise.all([ + kernel.readDocumentCheckpoint({ itemId: room.itemId }), + this.ctx.storage.get(persistedYDocUpdateKey), + ]); if (persistedUpdate) { Y.applyUpdate(this.document, persistedUpdate, this); return; @@ -182,6 +191,30 @@ export class DocumentSession extends YServer { }; } + async readMarkdownChunk( + input: DocumentMarkdownChunkReadInput, + ): Promise { + const stateVector = Uint8Array.from(Y.encodeStateVector(this.document)); + let currentSnapshot = this.markdownSnapshot; + if (!currentSnapshot || !uint8ArraysEqual(currentSnapshot.stateVector, stateVector)) { + const markdown = serializeTiptapDocumentToMarkdown(this.getCurrentTiptapDocument()); + currentSnapshot = { + revision: await sha256Base64Url(stateVector), + snapshot: createDocumentMarkdownSnapshot(markdown), + stateVector, + }; + this.markdownSnapshot = currentSnapshot; + } + if (input.expectedRevision && input.expectedRevision !== currentSnapshot.revision) { + return { status: "content_changed" }; + } + + const chunk = currentSnapshot.snapshot.readChunk(input.offset); + return chunk + ? { ...chunk, revision: currentSnapshot.revision, status: "ready" } + : { status: "invalid_offset" }; + } + async purgeForDeletion(): Promise { await this.ctx.storage.deleteAll(); } @@ -193,7 +226,7 @@ export class DocumentSession extends YServer { ); const kernel = await this.getWorkspaceKernel(room.workspaceId); - await kernel.writeItem({ + await kernel.commitDocumentCheckpoint({ itemId: room.itemId, content: stringifyTiptapDocumentJson(document), actorUserId: null, @@ -223,6 +256,10 @@ export class DocumentSession extends YServer { } } +function uint8ArraysEqual(left: Uint8Array, right: Uint8Array) { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + function getDocumentSessionRoomNameParts(roomName: string): DocumentSessionRouteParams { const separatorIndex = roomName.indexOf(":"); diff --git a/src/features/workspaces/extraction/binary.ts b/src/features/workspaces/extraction/binary.ts deleted file mode 100644 index b82795d12..000000000 --- a/src/features/workspaces/extraction/binary.ts +++ /dev/null @@ -1,22 +0,0 @@ -export function toArrayBuffer(bytes: Uint8Array) { - const copy = new Uint8Array(bytes.byteLength); - copy.set(bytes); - - return copy.buffer; -} - -export async function sha256Base64Url(bytes: Uint8Array) { - const digest = await crypto.subtle.digest("SHA-256", toArrayBuffer(bytes)); - - return bytesToBase64Url(new Uint8Array(digest)); -} - -export async function sha256Base64UrlText(value: string) { - return await sha256Base64Url(new TextEncoder().encode(value)); -} - -function bytesToBase64Url(bytes: Uint8Array) { - const base64 = btoa(String.fromCharCode(...bytes)); - - return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); -} diff --git a/src/features/workspaces/extraction/liteparse-projection.ts b/src/features/workspaces/extraction/liteparse-projection.ts index 161b46f0b..4766e7877 100644 --- a/src/features/workspaces/extraction/liteparse-projection.ts +++ b/src/features/workspaces/extraction/liteparse-projection.ts @@ -66,6 +66,7 @@ export async function publishLiteParseProjection( provisional: true, }, actorUserId: params.actorUserId, + clientMutationId: `${runId}:projection:liteparse-ready`, }); return { diff --git a/src/features/workspaces/extraction/request-workspace-file-extraction.ts b/src/features/workspaces/extraction/request-workspace-file-extraction.ts index 18f5b5ce2..11b2d8eab 100644 --- a/src/features/workspaces/extraction/request-workspace-file-extraction.ts +++ b/src/features/workspaces/extraction/request-workspace-file-extraction.ts @@ -1,6 +1,6 @@ import { env } from "cloudflare:workers"; -import { sha256Base64UrlText } from "#/features/workspaces/extraction/binary"; +import { sha256Base64UrlText } from "#/lib/binary"; import type { WorkspaceFileExtractionWorkflowParams } from "#/features/workspaces/extraction/types"; import { getWorkspaceKernel } from "#/features/workspaces/kernel/workspace-kernel-access"; import type { WorkspaceFileAssetKind } from "#/features/workspaces/model/workspace-file"; @@ -54,6 +54,7 @@ export async function requestWorkspaceFileExtraction(input: { status: "failed", errorMessage, actorUserId: input.actorUserId, + clientMutationId: `${input.requestId}:projection:queue-failed`, }); } catch (statusError) { recordOperationalFailure({ diff --git a/src/features/workspaces/extraction/workspace-file-extraction-workflow.ts b/src/features/workspaces/extraction/workspace-file-extraction-workflow.ts index 0143ce6d7..446226008 100644 --- a/src/features/workspaces/extraction/workspace-file-extraction-workflow.ts +++ b/src/features/workspaces/extraction/workspace-file-extraction-workflow.ts @@ -31,6 +31,7 @@ export class WorkspaceFileExtractionWorkflow extends WorkflowEntrypoint< format: "pages", status: "processing", actorUserId: params.actorUserId, + clientMutationId: `${event.instanceId}:projection:processing`, }); return { status: "processing" }; @@ -132,6 +133,7 @@ export class WorkspaceFileExtractionWorkflow extends WorkflowEntrypoint< sourceHash: extraction.sourceHash, metadataJson, actorUserId: params.actorUserId, + clientMutationId: `${event.instanceId}:projection:enhanced-ready`, }); return { @@ -183,6 +185,7 @@ export class WorkspaceFileExtractionWorkflow extends WorkflowEntrypoint< status: "failed", errorMessage, actorUserId: params.actorUserId, + clientMutationId: `${event.instanceId}:projection:failed`, }); return { status: "failed", errorMessage }; diff --git a/src/features/workspaces/extraction/workspace-page-projection.test.ts b/src/features/workspaces/extraction/workspace-page-projection.test.ts index d3edae9b1..61139a0fd 100644 --- a/src/features/workspaces/extraction/workspace-page-projection.test.ts +++ b/src/features/workspaces/extraction/workspace-page-projection.test.ts @@ -28,6 +28,7 @@ describe("workspace page projections", () => { storage.readKeys.length = 0; const result = await readWorkspacePageProjection({ bucket: storage.bucket, + expectedSourceHash: "etag-1", manifestObjectKey: reference.manifestObjectKey, pages: "2-3", }); @@ -64,6 +65,7 @@ describe("workspace page projections", () => { await expect( readWorkspacePageProjection({ bucket: storage.bucket, + expectedSourceHash: "etag-1", manifestObjectKey: reference.manifestObjectKey, pages: "2", }), @@ -93,12 +95,128 @@ describe("workspace page projections", () => { await expect( readWorkspacePageProjection({ bucket: storage.bucket, + expectedSourceHash: "etag-1", manifestObjectKey: reference.manifestObjectKey, pages: "1-21", }), ).rejects.toMatchObject({ code: "page_selection_too_large" }); }); + it("consumes each R2 response body before opening the next page", async () => { + const storage = createObjectStorage(); + const reference = await writeWorkspacePageProjection({ + bucket: storage.bucket, + itemId: "item-1", + pages: Array.from({ length: 20 }, (_, index) => ({ + pageNumber: index + 1, + markdown: `Page ${index + 1}`, + })), + provider: "liteparse", + providerMode: "fast", + runId: "run-1", + sourceHash: "etag-1", + tier: "fast", + workspaceId: "workspace-1", + }); + + await readWorkspacePageProjection({ + bucket: storage.bucket, + expectedSourceHash: "etag-1", + manifestObjectKey: reference.manifestObjectKey, + pages: "1-20", + }); + + expect(storage.maxOpenBodies()).toBe(1); + expect(storage.openBodies()).toBe(0); + }); + + it("rejects oversized selections before opening page objects when sizes are published", async () => { + const storage = createObjectStorage(); + const reference = await writeWorkspacePageProjection({ + bucket: storage.bucket, + itemId: "item-1", + pages: Array.from({ length: 3 }, (_, index) => ({ + pageNumber: index + 1, + markdown: "x".repeat(800_000), + })), + provider: "liteparse", + providerMode: "fast", + runId: "run-1", + sourceHash: "etag-1", + tier: "fast", + workspaceId: "workspace-1", + }); + + storage.readKeys.length = 0; + await expect( + readWorkspacePageProjection({ + bucket: storage.bucket, + expectedSourceHash: "etag-1", + manifestObjectKey: reference.manifestObjectKey, + pages: "1-3", + }), + ).rejects.toMatchObject({ code: "page_selection_too_large" }); + expect(storage.readKeys).toEqual([reference.manifestObjectKey]); + }); + + it("reads projections published before per-page sizes were added", async () => { + const storage = createObjectStorage(); + const reference = await writeWorkspacePageProjection({ + bucket: storage.bucket, + itemId: "item-1", + pages: [{ pageNumber: 1, markdown: "Page 1" }], + provider: "liteparse", + providerMode: "fast", + runId: "run-1", + sourceHash: "etag-1", + tier: "fast", + workspaceId: "workspace-1", + }); + const { pages: _pages, ...manifestWithoutPages } = reference.manifest; + storage.values.set( + reference.manifestObjectKey, + JSON.stringify({ ...manifestWithoutPages, schemaVersion: 1 }), + ); + + await expect( + readWorkspacePageProjection({ + bucket: storage.bucket, + expectedSourceHash: "etag-1", + manifestObjectKey: reference.manifestObjectKey, + }), + ).resolves.toEqual({ + content: "## Page 1\n\nPage 1", + pages: { requested: "1", returned: [1], total: 1 }, + }); + }); + + it("preserves extracted Markdown whitespace", async () => { + const storage = createObjectStorage(); + const markdown = " indented code \n\ntrailing hard break \n"; + const reference = await writeWorkspacePageProjection({ + bucket: storage.bucket, + itemId: "item-1", + pages: [{ pageNumber: 1, markdown }], + provider: "liteparse", + providerMode: "fast", + runId: "run-1", + sourceHash: "etag-1", + tier: "fast", + workspaceId: "workspace-1", + }); + + await expect( + readWorkspacePageProjection({ + bucket: storage.bucket, + expectedSourceHash: "etag-1", + manifestObjectKey: reference.manifestObjectKey, + }), + ).resolves.toEqual({ + content: `## Page 1\n\n${markdown}`, + pages: { requested: "1", returned: [1], total: 1 }, + }); + }); + it("removes partial artifacts when publication fails", async () => { const storage = createObjectStorage(); @@ -125,6 +243,8 @@ describe("workspace page projections", () => { function createObjectStorage() { const values = new Map(); const readKeys: string[] = []; + let currentOpenBodies = 0; + let highestOpenBodies = 0; const bucket = { async delete(keys: string | string[]) { for (const key of Array.isArray(keys) ? keys : [keys]) { @@ -137,11 +257,27 @@ function createObjectStorage() { if (value === undefined) { return null; } + currentOpenBodies += 1; + highestOpenBodies = Math.max(highestOpenBodies, currentOpenBodies); + let consumed = false; + const consume = () => { + if (!consumed) { + consumed = true; + currentOpenBodies -= 1; + } + }; return { + body: { cancel: async () => consume() }, key, size: new TextEncoder().encode(value).byteLength, - text: async () => value, - json: async () => JSON.parse(value) as unknown, + text: async () => { + consume(); + return value; + }, + json: async () => { + consume(); + return JSON.parse(value) as unknown; + }, }; }, async put(key: string, value: string) { @@ -156,5 +292,11 @@ function createObjectStorage() { }, } as R2Bucket; - return { bucket, readKeys, values }; + return { + bucket, + openBodies: () => currentOpenBodies, + maxOpenBodies: () => highestOpenBodies, + readKeys, + values, + }; } diff --git a/src/features/workspaces/extraction/workspace-page-projection.ts b/src/features/workspaces/extraction/workspace-page-projection.ts index e15210eb6..72136fb09 100644 --- a/src/features/workspaces/extraction/workspace-page-projection.ts +++ b/src/features/workspaces/extraction/workspace-page-projection.ts @@ -1,3 +1,5 @@ +import { z } from "zod"; + import { jsonValueSchema, type JsonValue } from "#/features/workspaces/contracts"; import type { MarkdownProjectionPage } from "#/features/workspaces/extraction/page-markdown-projection"; import { getWorkspaceFileItemObjectPrefix } from "#/features/workspaces/files/workspace-file-object-keys"; @@ -14,25 +16,32 @@ const pageWriteConcurrency = 8; const maxPageMarkdownBytes = 1024 * 1024; const maxPageReadBytes = 2 * 1024 * 1024; -export interface WorkspacePageProjectionManifest { - createdAt: string; - itemId: string; - markdownBytes: number; - markdownLength: number; - metadata: Record; - pageCount: number; - provider: string; - providerMode: string; - runId: string; - schemaVersion: typeof projectionSchemaVersion; - sourceHash: string; - workspaceId: string; -} - -export interface WorkspacePageProjectionReference { - manifestObjectKey: string; - manifest: WorkspacePageProjectionManifest; -} +const workspacePageProjectionManifestPageSchema = z.object({ + markdownBytes: z.number().int().nonnegative(), + pageNumber: z.number().int().positive(), +}); + +const workspacePageProjectionManifestSchema = z.object({ + createdAt: z.string(), + itemId: z.string(), + markdownBytes: z.number().int().nonnegative(), + markdownLength: z.number().int().nonnegative(), + metadata: z.record(z.string(), jsonValueSchema), + pageCount: z.number().int().positive(), + pages: z.array(workspacePageProjectionManifestPageSchema).optional(), + provider: z.string(), + providerMode: z.string(), + runId: z.string(), + schemaVersion: z.literal(projectionSchemaVersion), + sourceHash: z.string(), + workspaceId: z.string(), +}); + +type WorkspacePageProjectionManifest = z.infer; + +type WorkspacePageProjectionManifestPage = z.infer< + typeof workspacePageProjectionManifestPageSchema +>; export async function writeWorkspacePageProjection(input: { bucket: R2Bucket; @@ -45,13 +54,14 @@ export async function writeWorkspacePageProjection(input: { sourceHash: string; tier: "enhanced" | "fast"; workspaceId: string; -}): Promise { +}) { const prefix = getWorkspacePageProjectionPrefix(input); const encoder = new TextEncoder(); const writes: Promise[] = []; let lastPageNumber = 0; let markdownBytes = 0; let markdownLength = 0; + const pages: WorkspacePageProjectionManifestPage[] = []; let usablePageCount = 0; try { @@ -63,6 +73,7 @@ export async function writeWorkspacePageProjection(input: { for (let pageNumber = lastPageNumber + 1; pageNumber < page.pageNumber; pageNumber += 1) { await schedulePageWrite(input.bucket, writes, prefix, pageNumber, ""); + pages.push({ markdownBytes: 0, pageNumber }); } const pageBytes = encoder.encode(page.markdown).byteLength; @@ -71,6 +82,7 @@ export async function writeWorkspacePageProjection(input: { } await schedulePageWrite(input.bucket, writes, prefix, page.pageNumber, page.markdown); + pages.push({ markdownBytes: pageBytes, pageNumber: page.pageNumber }); lastPageNumber = page.pageNumber; markdownBytes += pageBytes; markdownLength += page.markdown.length; @@ -91,6 +103,7 @@ export async function writeWorkspacePageProjection(input: { markdownLength, metadata: input.metadata ?? {}, pageCount: lastPageNumber, + pages, provider: input.provider, providerMode: input.providerMode, runId: input.runId, @@ -132,39 +145,61 @@ export async function writeWorkspacePageProjection(input: { export async function readWorkspacePageProjection(input: { bucket: R2Bucket; + expectedSourceHash: string; manifestObjectKey: string; pages?: string; }): Promise<{ content: string; pages: WorkspaceReadPages }> { const manifest = await readWorkspacePageProjectionManifest(input.bucket, input.manifestObjectKey); + if (manifest.sourceHash !== input.expectedSourceHash) { + throw new Error("Workspace page projection source does not match its published revision."); + } const requested = input.pages?.trim() || "1"; const selectedPageNumbers = parseWorkspacePageRange(requested, manifest.pageCount); - - const prefix = getManifestPrefix(input.manifestObjectKey); - const objects = await Promise.all( - selectedPageNumbers.map(async (pageNumber) => { - const object = await input.bucket.get(getWorkspacePageObjectKey(prefix, pageNumber)); - if (!object) { - throw new Error(`Extracted page ${pageNumber} was not found.`); - } - return { object, pageNumber }; - }), - ); - const totalBytes = objects.reduce((total, entry) => total + entry.object.size, 0); - if (totalBytes > maxPageReadBytes) { + const pageMetadataByNumber = manifest.pages + ? new Map(manifest.pages.map((page) => [page.pageNumber, page] as const)) + : null; + const selectedManifestBytes = pageMetadataByNumber + ? selectedPageNumbers.reduce( + (total, pageNumber) => + total + requireManifestPage(pageMetadataByNumber, pageNumber).markdownBytes, + 0, + ) + : null; + if (selectedManifestBytes !== null && selectedManifestBytes > maxPageReadBytes) { throw new WorkspacePageSelectionError("page_selection_too_large"); } - const pages = await Promise.all( - objects.map(async ({ object, pageNumber }) => ({ - markdown: (await object.text()).trim(), + const prefix = getManifestPrefix(input.manifestObjectKey); + const pages: Array<{ markdown: string; pageNumber: number }> = []; + let totalBytes = 0; + + // Consume each R2 body before opening the next one; never retain a batch of live responses. + for (const pageNumber of selectedPageNumbers) { + const object = await input.bucket.get(getWorkspacePageObjectKey(prefix, pageNumber)); + if (!object) { + throw new Error(`Extracted page ${pageNumber} was not found.`); + } + + totalBytes += object.size; + if (totalBytes > maxPageReadBytes) { + await object.body.cancel(); + throw new WorkspacePageSelectionError("page_selection_too_large"); + } + + const manifestPage = pageMetadataByNumber?.get(pageNumber); + if (manifestPage && manifestPage.markdownBytes !== object.size) { + await object.body.cancel(); + throw new Error(`Extracted page ${pageNumber} does not match its manifest.`); + } + + pages.push({ + markdown: await object.text(), pageNumber, - })), - ); + }); + } return { - content: pages - .map((page) => `## Page ${page.pageNumber}\n\n${page.markdown}`.trimEnd()) - .join("\n\n"), + content: pages.map(formatProjectionPage).join("\n\n"), pages: { requested, returned: selectedPageNumbers, @@ -173,7 +208,18 @@ export async function readWorkspacePageProjection(input: { }; } -export async function readWorkspacePageProjectionManifest( +function requireManifestPage( + pagesByNumber: ReadonlyMap, + pageNumber: number, +) { + const page = pagesByNumber.get(pageNumber); + if (!page) { + throw new Error(`Workspace page projection manifest is missing page ${pageNumber}.`); + } + return page; +} + +async function readWorkspacePageProjectionManifest( bucket: R2Bucket, manifestObjectKey: string, ): Promise { @@ -185,7 +231,7 @@ export async function readWorkspacePageProjectionManifest( return parseWorkspacePageProjectionManifest(await object.json()); } -export function getWorkspacePageProjectionPrefix(input: { +function getWorkspacePageProjectionPrefix(input: { itemId: string; runId: string; tier: "enhanced" | "fast"; @@ -199,47 +245,16 @@ export function getWorkspacePageObjectKey(prefix: string, pageNumber: number) { } function parseWorkspacePageProjectionManifest(value: unknown): WorkspacePageProjectionManifest { - if (!isRecord(value)) { + const manifest = workspacePageProjectionManifestSchema.parse(value); + if (manifest.pages && manifest.pages.length !== manifest.pageCount) { throw new Error("Workspace page projection manifest is invalid."); } - if ( - value.schemaVersion !== projectionSchemaVersion || - typeof value.workspaceId !== "string" || - typeof value.itemId !== "string" || - typeof value.runId !== "string" || - typeof value.sourceHash !== "string" || - typeof value.provider !== "string" || - typeof value.providerMode !== "string" || - typeof value.pageCount !== "number" || - !Number.isInteger(value.pageCount) || - value.pageCount < 1 || - typeof value.markdownLength !== "number" || - typeof value.markdownBytes !== "number" || - typeof value.createdAt !== "string" || - !isRecord(value.metadata) - ) { - throw new Error("Workspace page projection manifest is invalid."); - } - - const metadata = jsonValueSchema.parse(value.metadata); - if (!isJsonObject(metadata)) { - throw new Error("Workspace page projection manifest is invalid."); + for (const [index, page] of (manifest.pages ?? []).entries()) { + if (page.pageNumber !== index + 1) { + throw new Error("Workspace page projection manifest is invalid."); + } } - - return { - createdAt: value.createdAt, - itemId: value.itemId, - markdownBytes: value.markdownBytes, - markdownLength: value.markdownLength, - metadata, - pageCount: value.pageCount, - provider: value.provider, - providerMode: value.providerMode, - runId: value.runId, - schemaVersion: projectionSchemaVersion, - sourceHash: value.sourceHash, - workspaceId: value.workspaceId, - }; + return manifest; } async function schedulePageWrite( @@ -277,7 +292,13 @@ function normalizeProjectionPage(page: MarkdownProjectionPage): MarkdownProjecti if (typeof page.markdown !== "string") { throw new Error("Extracted page Markdown is invalid."); } - return { pageNumber: page.pageNumber, markdown: page.markdown.trim() }; + return page; +} + +function formatProjectionPage(page: { markdown: string; pageNumber: number }) { + return page.markdown + ? `## Page ${page.pageNumber}\n\n${page.markdown}` + : `## Page ${page.pageNumber}`; } function getManifestPrefix(manifestObjectKey: string) { @@ -290,11 +311,3 @@ function getManifestPrefix(manifestObjectKey: string) { function encodePathPart(value: string) { return encodeURIComponent(value); } - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function isJsonObject(value: JsonValue): value is { [key: string]: JsonValue } { - return typeof value === "object" && value !== null && !Array.isArray(value); -} diff --git a/src/features/workspaces/kernel/workspace-kernel-access.ts b/src/features/workspaces/kernel/workspace-kernel-access.ts index 739497d38..b8e6a3477 100644 --- a/src/features/workspaces/kernel/workspace-kernel-access.ts +++ b/src/features/workspaces/kernel/workspace-kernel-access.ts @@ -4,7 +4,6 @@ import type { ResourcePurgeResult } from "#/features/workspaces/resource-purge-r import type { CreateWorkspaceItemInput, DeleteWorkspaceItemsInput, - JsonValue, MoveWorkspaceItemsInput, RenameWorkspaceItemInput, UpdateWorkspaceItemColorInput, @@ -15,19 +14,26 @@ import type { import { requireAppliedWorkspaceKernelMutation, type CreateWorkspaceKernelFileFromUploadArgs, - type CreateWorkspaceKernelRelationArgs, + type CreateWorkspaceKernelItemArgs, type DeleteWorkspaceKernelItemsResult, + type GetWorkspaceKernelItemPathsArgs, type ListWorkspaceKernelItemRelationsArgs, + type ListWorkspaceKernelItemsArgs, + type LinkWorkspaceKernelItemsArgs, type MoveWorkspaceKernelItemsResult, type ReadWorkspaceKernelFilePreviewResult, type ReadWorkspaceKernelFileProjectionArgs, type ReadWorkspaceKernelFileProjectionResult, type UpsertWorkspaceKernelFileProjectionArgs, + type ResolveWorkspaceKernelPathsArgs, type WorkspaceKernelFileSource, type WorkspaceKernelItemRelation, type WorkspaceKernelNameConflictPolicy, type WorkspaceKernelMutationOutcome, + type WorkspaceKernelItemPath, + type WorkspaceKernelPathResolution, } from "#/features/workspaces/kernel/workspace-kernel-types"; +import type { ListWorkspaceKernelItemsResult } from "#/features/workspaces/kernel/workspace-kernel-list"; import type { WorkspaceFileAssetKind } from "#/features/workspaces/model/workspace-file"; import type { WorkspaceCommandResult } from "#/features/workspaces/realtime/messages"; import { @@ -48,23 +54,18 @@ export interface WorkspaceKernelClient { itemFacts: WorkspaceItemFacts[]; revision: number; }>; - createRelations(input: { relations: CreateWorkspaceKernelRelationArgs[] }): Promise; + listTreeItems(input?: ListWorkspaceKernelItemsArgs): Promise; + resolvePaths(input: ResolveWorkspaceKernelPathsArgs): Promise; + getItemPaths(input: GetWorkspaceKernelItemPathsArgs): Promise; + linkItems( + input: LinkWorkspaceKernelItemsArgs, + ): Promise>; listItemRelations( input: ListWorkspaceKernelItemRelationsArgs, ): Promise; - createItem(input: { - id?: string; - parentId?: string | null; - type: CreateWorkspaceItemInput["type"]; - name?: string; - onNameConflict?: WorkspaceKernelNameConflictPolicy; - color?: CreateWorkspaceItemInput["color"]; - metadataJson?: Record; - initialContent?: string; - initialRelations?: CreateWorkspaceKernelRelationArgs[]; - actorUserId?: string | null; - clientMutationId?: string | null; - }): Promise>; + createItem( + input: CreateWorkspaceKernelItemArgs, + ): Promise>; createFileFromUpload( input: CreateWorkspaceKernelFileFromUploadArgs, ): Promise>; @@ -96,16 +97,18 @@ export interface WorkspaceKernelClient { actorUserId?: string | null; clientMutationId?: string | null; }): Promise>; - readItem(input: { + readDocumentCheckpoint(input: { itemId: string; - }): Promise<{ item: WorkspaceItemSummary; content: string | null }>; + }): Promise<{ item: WorkspaceItemSummary; content: string }>; getFileSource(input: { itemId: string }): Promise; readFilePreview(input: { itemId: string }): Promise; - upsertFileProjection(input: UpsertWorkspaceKernelFileProjectionArgs): Promise; + upsertFileProjection( + input: UpsertWorkspaceKernelFileProjectionArgs, + ): Promise>; readFileProjection( input: ReadWorkspaceKernelFileProjectionArgs, ): Promise; - writeItem(input: { + commitDocumentCheckpoint(input: { itemId: string; content: string; actorUserId?: string | null; diff --git a/src/features/workspaces/kernel/workspace-kernel-events.test.ts b/src/features/workspaces/kernel/workspace-kernel-events.test.ts deleted file mode 100644 index d86a442fd..000000000 --- a/src/features/workspaces/kernel/workspace-kernel-events.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import { WorkspaceKernelEventBus } from "#/features/workspaces/kernel/workspace-kernel-events"; -import type { KernelEventRow } from "#/features/workspaces/kernel/workspace-kernel-rows"; -import type { WorkspaceKernelSql } from "#/features/workspaces/kernel/workspace-kernel-schema"; - -describe("workspace kernel event idempotency", () => { - it("recovers a committed item creation by client mutation id", () => { - const events = createEventBus(createItemEventRow("item-1")); - - expect( - events.getCreatedItemEvent({ - clientMutationId: "mutation-1", - itemId: "item-1", - }), - ).toMatchObject({ - clientMutationId: "mutation-1", - payload: { item: { id: "item-1" } }, - type: "workspace.item.created", - }); - }); - - it("rejects reuse of a client mutation id for another item", () => { - const events = createEventBus(createItemEventRow("item-1")); - - expect(() => - events.getCreatedItemEvent({ - clientMutationId: "mutation-1", - itemId: "item-2", - }), - ).toThrow("client mutation id was already used"); - }); -}); - -function createEventBus(row: KernelEventRow) { - const sql = vi.fn(() => [row]) as unknown as WorkspaceKernelSql; - return new WorkspaceKernelEventBus({ - broadcast: vi.fn(), - getNextRevision: () => 2, - sql, - workspaceId: () => "workspace-1", - }); -} - -function createItemEventRow(itemId: string): KernelEventRow { - return { - actor_user_id: "user-1", - client_mutation_id: "mutation-1", - created_at: Date.parse("2026-07-15T00:00:00Z"), - id: "event-1", - payload_json: JSON.stringify({ item: { id: itemId } }), - revision: 1, - type: "workspace.item.created", - }; -} diff --git a/src/features/workspaces/kernel/workspace-kernel-events.ts b/src/features/workspaces/kernel/workspace-kernel-events.ts index 3aa3c0def..1b1edcd6c 100644 --- a/src/features/workspaces/kernel/workspace-kernel-events.ts +++ b/src/features/workspaces/kernel/workspace-kernel-events.ts @@ -1,7 +1,4 @@ -import type { KernelEventRow } from "#/features/workspaces/kernel/workspace-kernel-rows"; -import { mapKernelEventRow } from "#/features/workspaces/kernel/workspace-kernel-rows"; import type { WorkspaceKernelSql } from "#/features/workspaces/kernel/workspace-kernel-schema"; -import type { ListWorkspaceKernelEventsArgs } from "#/features/workspaces/kernel/workspace-kernel-types"; import type { WorkspaceRealtimeEvent, WorkspaceRealtimeServerMessage, @@ -25,43 +22,6 @@ export class WorkspaceKernelEventBus { this.broadcast = input.broadcast; } - getEventsSince({ - afterRevision, - limit = 100, - }: ListWorkspaceKernelEventsArgs): WorkspaceRealtimeEvent[] { - const rows = this.sql` - SELECT * - FROM kernel_events - WHERE revision > ${Math.max(0, afterRevision)} - ORDER BY revision ASC - LIMIT ${Math.max(1, Math.min(limit, 500))} - `; - - return rows.map((row) => mapKernelEventRow(row, this.workspaceId())); - } - - getCreatedItemEvent(input: { clientMutationId: string; itemId: string }) { - const [row] = this.sql` - SELECT * - FROM kernel_events - WHERE type = 'workspace.item.created' - AND client_mutation_id = ${input.clientMutationId} - ORDER BY revision ASC - LIMIT 1 - `; - - if (!row) { - return null; - } - - const event = mapKernelEventRow(row, this.workspaceId()); - if (event.type !== "workspace.item.created" || event.payload.item.id !== input.itemId) { - throw new Error("Workspace client mutation id was already used."); - } - - return event; - } - commit(input: Omit) { const createdAt = Date.now(); const event = { diff --git a/src/features/workspaces/kernel/workspace-kernel-file-commands.ts b/src/features/workspaces/kernel/workspace-kernel-file-commands.ts index 87b03a016..1133f952a 100644 --- a/src/features/workspaces/kernel/workspace-kernel-file-commands.ts +++ b/src/features/workspaces/kernel/workspace-kernel-file-commands.ts @@ -1,4 +1,8 @@ -import type { JsonValue, WorkspaceItemSummary } from "#/features/workspaces/contracts"; +import type { + JsonValue, + WorkspaceItemFacts, + WorkspaceItemSummary, +} from "#/features/workspaces/contracts"; import { getWorkspaceFileItemObjectPrefix } from "#/features/workspaces/files/workspace-file-object-keys"; import { WORKSPACE_FILE_PREVIEW_CONTENT_TYPE } from "#/features/workspaces/files/workspace-file-preview.constants"; import type { WorkspaceKernelEventBus } from "#/features/workspaces/kernel/workspace-kernel-events"; @@ -56,21 +60,6 @@ export class WorkspaceKernelFileCommands { input: CreateWorkspaceKernelFileFromUploadArgs, ): Promise> { const parentId = input.parentId ?? null; - const getPriorResult = () => { - const event = input.clientMutationId - ? this.events.getCreatedItemEvent({ - clientMutationId: input.clientMutationId, - itemId: input.id, - }) - : null; - - return event ? { event, result: this.store.requireItem(input.id) } : null; - }; - const priorResult = getPriorResult(); - - if (priorResult) { - return priorResult; - } this.store.assertParentIsValid(parentId); @@ -100,11 +89,6 @@ export class WorkspaceKernelFileCommands { throw new Error("Uploaded file preview size did not match the upload request."); } - const concurrentResult = getPriorResult(); - if (concurrentResult) { - return concurrentResult; - } - const descriptor = getWorkspaceUploadFamily(input.assetKind); if (Boolean(descriptor.previewGenerator) !== Boolean(input.preview)) { throw new Error("Workspace file preview preparation did not match the file type."); @@ -194,11 +178,12 @@ export class WorkspaceKernelFileCommands { } const item = this.store.requireItem(itemId); + const itemFacts = this.store.getItemFacts([item]); const event = this.events.commit({ type: "workspace.item.created", actorUserId: input.actorUserId ?? null, clientMutationId: input.clientMutationId ?? null, - payload: { item }, + payload: { item, itemFacts }, }); return { result: item, event }; @@ -264,7 +249,9 @@ export class WorkspaceKernelFileCommands { }; } - async upsertFileProjection(input: UpsertWorkspaceKernelFileProjectionArgs): Promise { + async upsertFileProjection( + input: UpsertWorkspaceKernelFileProjectionArgs, + ): Promise> { const row = this.store.assertActiveItem(input.itemId); if (row.type !== "file") { @@ -293,6 +280,14 @@ export class WorkspaceKernelFileCommands { projection: input, now, }); + const itemFacts = this.store.getItemFacts([this.store.requireItem(input.itemId)]); + const event = this.events.commit({ + type: "workspace.item.projection.updated", + actorUserId: input.actorUserId ?? null, + clientMutationId: input.clientMutationId ?? null, + payload: { itemFacts }, + }); + return { event, result: itemFacts }; } private writeProjectionRow(input: { diff --git a/src/features/workspaces/kernel/workspace-kernel-files.ts b/src/features/workspaces/kernel/workspace-kernel-files.ts index 4ac20a433..5d2b6fce3 100644 --- a/src/features/workspaces/kernel/workspace-kernel-files.ts +++ b/src/features/workspaces/kernel/workspace-kernel-files.ts @@ -3,13 +3,15 @@ import { createInitialTiptapDocumentJson, stringifyTiptapDocumentJson, } from "#/features/workspaces/documents/tiptap-document"; +import { getWorkspaceItemRegistryEntry } from "#/features/workspaces/workspace-item-registry"; export function getWorkspaceKernelShellPath(input: { id: string; type: WorkspaceItemType }) { - if (input.type === "folder") { + const extension = getWorkspaceItemRegistryEntry(input.type).extension; + if (!extension) { return `/items/${input.id}`; } - return `/items/${input.id}/content.${getContentExtension(input.type)}`; + return `/items/${input.id}/content.${extension}`; } export function getWorkspaceKernelFileShellPath(input: { itemId: string; extension: string }) { @@ -17,43 +19,18 @@ export function getWorkspaceKernelFileShellPath(input: { itemId: string; extensi } export function getWorkspaceKernelContentMimeType(type: WorkspaceItemType) { - switch (type) { - case "document": - return "application/json"; - case "flashcard": - case "quiz": - return "application/json"; - case "file": - return "text/plain"; - case "folder": - return "inode/directory"; - } + return getWorkspaceItemRegistryEntry(type).mimeType; } -export function getInitialWorkspaceKernelContent(type: WorkspaceItemType, _name: string) { - switch (type) { +export function getInitialWorkspaceKernelContent(type: WorkspaceItemType) { + switch (getWorkspaceItemRegistryEntry(type).contentKind) { case "document": return stringifyTiptapDocumentJson(createInitialTiptapDocumentJson()); case "flashcard": return JSON.stringify({ version: 1, cards: [] }, null, 2); case "quiz": return JSON.stringify({ version: 1, questions: [] }, null, 2); - case "file": - case "folder": - return ""; - } -} - -function getContentExtension(type: WorkspaceItemType) { - switch (type) { - case "document": - return "json"; - case "flashcard": - case "quiz": - return "json"; - case "file": - return "txt"; - case "folder": + case "empty": return ""; } } diff --git a/src/features/workspaces/kernel/workspace-kernel-item-commands.ts b/src/features/workspaces/kernel/workspace-kernel-item-commands.ts index 5d531c0bd..2459803ab 100644 --- a/src/features/workspaces/kernel/workspace-kernel-item-commands.ts +++ b/src/features/workspaces/kernel/workspace-kernel-item-commands.ts @@ -26,10 +26,10 @@ import type { DeleteWorkspaceKernelItemsResult, MoveWorkspaceKernelItemsArgs, MoveWorkspaceKernelItemsResult, - ReadWorkspaceKernelItemArgs, + ReadWorkspaceDocumentCheckpointArgs, RenameWorkspaceKernelItemArgs, UpdateWorkspaceKernelItemColorArgs, - WriteWorkspaceKernelItemArgs, + CommitWorkspaceDocumentCheckpointArgs, WorkspaceKernelMutationOutcome, } from "#/features/workspaces/kernel/workspace-kernel-types"; import { @@ -72,24 +72,8 @@ export class WorkspaceKernelItemCommands { throw new Error("Binary workspace files must be created through the upload flow."); } - const id = input.id ?? crypto.randomUUID(); + const id = input.id; const parentId = input.parentId ?? null; - const getPriorResult = () => { - const event = - input.id && input.clientMutationId - ? this.events.getCreatedItemEvent({ - clientMutationId: input.clientMutationId, - itemId: input.id, - }) - : null; - - return event ? { event, result: this.store.requireItem(id) } : null; - }; - const priorResult = getPriorResult(); - - if (priorResult) { - return { command: priorResult, status: "applied" }; - } const color = resolveWorkspaceItemColorForCreate({ type, @@ -127,23 +111,16 @@ export class WorkspaceKernelItemCommands { const shellPath = getWorkspaceKernelShellPath({ id, type }); const { initialContent, metadataJson } = buildWorkspaceItemCreateBootstrap({ type, - name, metadataJson: input.metadataJson ?? {}, initialContent: input.initialContent, }); await this.createWorkspaceFile({ type, - name, shellPath, initialContent, }); - const concurrentResult = getPriorResult(); - if (concurrentResult) { - return { command: concurrentResult, status: "applied" }; - } - // Keep these writes synchronous. SQLite-backed Durable Objects coalesce // writes without an intervening await into one atomic implicit transaction. this.sql` @@ -177,11 +154,17 @@ export class WorkspaceKernelItemCommands { const item = this.store.requireItem(id); this.relations.createRelations(initialRelations); + const factItemIds = Array.from( + new Set([id, ...initialRelations.flatMap((relation) => [relation.toItemId])]), + ); + const itemFacts = this.store.getItemFacts( + factItemIds.map((itemId) => this.store.requireItem(itemId)), + ); const event = this.events.commit({ type: "workspace.item.created", actorUserId: input.actorUserId ?? null, clientMutationId: input.clientMutationId ?? null, - payload: { item }, + payload: { item, itemFacts }, }); return { command: { result: item, event }, status: "applied" }; @@ -305,15 +288,19 @@ export class WorkspaceKernelItemCommands { const rowsToRemove = deleteIds .map((id) => this.store.getItemRowIncludingDeleted(id)) .filter((row): row is KernelItemRow => Boolean(row)); + const relatedItemIds = this.relations.listRelatedItemIds(deleteIds); this.store.softDeleteItems(deleteIds, Date.now()); this.relations.deleteRelationsForItems(deleteIds); + const itemFacts = this.store.getItemFacts( + relatedItemIds.map((itemId) => this.store.requireItem(itemId)), + ); const result = { itemIds: rootIds, deletedItemIds: deleteIds }; const event = this.events.commit({ type: "workspace.item.deleted", actorUserId: input.actorUserId ?? null, clientMutationId: input.clientMutationId ?? null, - payload: { itemIds: rootIds, deletedItemIds: deleteIds }, + payload: { itemIds: rootIds, deletedItemIds: deleteIds, itemFacts }, }); try { @@ -339,26 +326,26 @@ export class WorkspaceKernelItemCommands { return { result, event }; } - async readItem(input: ReadWorkspaceKernelItemArgs) { + async readDocumentCheckpoint(input: ReadWorkspaceDocumentCheckpointArgs) { const item = this.store.assertActiveItem(input.itemId); + if (item.type !== "document") { + throw new Error("Only document items have document checkpoints."); + } const itemSummary = mapKernelItemRow(item, this.workspaceId()); - - return item.type === "folder" || item.type === "file" - ? { item: itemSummary, content: null } - : { - item: itemSummary, - content: await this.workspace.readFile(item.shell_path), - }; + return { + item: itemSummary, + content: await this.workspace.readFile(item.shell_path), + }; } - async writeItem( - input: WriteWorkspaceKernelItemArgs, + async commitDocumentCheckpoint( + input: CommitWorkspaceDocumentCheckpointArgs, ): Promise> { const item = this.store.assertActiveItem(input.itemId); const type = workspaceItemTypeSchema.parse(item.type); - if (type === "folder" || type === "file") { - throw new Error("This workspace item does not have writable text content."); + if (type !== "document") { + throw new Error("Only document checkpoints can update workspace text content."); } await this.workspace.writeFile( @@ -395,7 +382,6 @@ export class WorkspaceKernelItemCommands { private async createWorkspaceFile(input: { type: WorkspaceItemSummary["type"]; - name: string; shellPath: string; initialContent?: string; }) { @@ -406,7 +392,7 @@ export class WorkspaceKernelItemCommands { await this.workspace.writeFile( input.shellPath, - input.initialContent ?? getInitialWorkspaceKernelContent(input.type, input.name), + input.initialContent ?? getInitialWorkspaceKernelContent(input.type), getWorkspaceKernelContentMimeType(input.type), ); } diff --git a/src/features/workspaces/kernel/workspace-kernel-list.ts b/src/features/workspaces/kernel/workspace-kernel-list.ts index 99e9c73a0..3c4b4f383 100644 --- a/src/features/workspaces/kernel/workspace-kernel-list.ts +++ b/src/features/workspaces/kernel/workspace-kernel-list.ts @@ -36,8 +36,10 @@ export interface ListWorkspaceKernelItemsFailure { path: string; } -interface WorkspaceKernelListedItems { - items: ListWorkspaceKernelItem[]; +interface WorkspaceKernelListSelection { + failed: ListWorkspaceKernelItemsFailure[]; + path: string; + rows: WorkspaceKernelListRow[]; total: number; nextOffset?: number; } @@ -48,31 +50,44 @@ interface WorkspaceKernelListRow { } export function listWorkspaceKernelTreeItems(input: { + getItemFacts: (items: WorkspaceItemSummary[]) => WorkspaceItemFacts[]; tree: WorkspaceKernelTree; - itemFactsById: ReadonlyMap; offset?: number; path?: string; recursive?: boolean; limit?: number; }): ListWorkspaceKernelItemsResult { + const selection = selectWorkspaceKernelTreeItems(input); + return formatWorkspaceKernelListSelection( + selection, + input.getItemFacts(selection.rows.map((row) => row.item)), + ); +} + +function selectWorkspaceKernelTreeItems(input: { + tree: WorkspaceKernelTree; + offset?: number; + path?: string; + recursive?: boolean; + limit?: number; +}): WorkspaceKernelListSelection { try { const cwd = resolveWorkspaceKernelCwd(input.path ?? "/", input.tree); const boundedLimit = clampWorkspaceListLimit(input.limit); - const listing = collectWorkspaceKernelListItems({ + const listing = collectWorkspaceKernelListRows({ offset: input.offset ?? 0, parentId: cwd.parentId, basePath: cwd.path, recursive: input.recursive ?? false, limit: boundedLimit, childrenByParentId: input.tree.childrenByParentId, - itemFactsById: input.itemFactsById, }); return { path: cwd.path, total: listing.total, ...(listing.nextOffset !== undefined ? { nextOffset: listing.nextOffset } : {}), - items: listing.items, + rows: listing.rows, failed: [], }; } catch (error) { @@ -81,7 +96,7 @@ export function listWorkspaceKernelTreeItems(input: { return { path, total: 0, - items: [], + rows: [], failed: [ { code: error.code, @@ -95,14 +110,33 @@ export function listWorkspaceKernelTreeItems(input: { } } -function collectWorkspaceKernelListItems({ +function formatWorkspaceKernelListSelection( + selection: WorkspaceKernelListSelection, + itemFacts: WorkspaceItemFacts[], +): ListWorkspaceKernelItemsResult { + const itemFactsById = new Map(itemFacts.map((facts) => [facts.itemId, facts])); + return { + failed: selection.failed, + items: selection.rows.map((row) => + formatWorkspaceKernelListItem({ + facts: itemFactsById.get(row.item.id), + item: row.item, + path: row.path, + }), + ), + ...(selection.nextOffset !== undefined ? { nextOffset: selection.nextOffset } : {}), + path: selection.path, + total: selection.total, + }; +} + +function collectWorkspaceKernelListRows({ offset, parentId, basePath, recursive, limit, childrenByParentId, - itemFactsById, }: { offset: number; parentId: string | null; @@ -110,8 +144,7 @@ function collectWorkspaceKernelListItems({ recursive: boolean; limit: number; childrenByParentId: Map; - itemFactsById: ReadonlyMap; -}): WorkspaceKernelListedItems { +}): Pick { const rows: WorkspaceKernelListRow[] = []; const visitedIds = new Set(); @@ -140,13 +173,7 @@ function collectWorkspaceKernelListItems({ const nextOffset = offset + pageRows.length; return { - items: pageRows.map((row) => - formatWorkspaceKernelListItem({ - facts: itemFactsById.get(row.item.id), - item: row.item, - path: row.path, - }), - ), + rows: pageRows, total: rows.length, ...(nextOffset < rows.length ? { nextOffset } : {}), }; diff --git a/src/features/workspaces/kernel/workspace-kernel-relations.ts b/src/features/workspaces/kernel/workspace-kernel-relations.ts index 64e5e3233..84dd03d8f 100644 --- a/src/features/workspaces/kernel/workspace-kernel-relations.ts +++ b/src/features/workspaces/kernel/workspace-kernel-relations.ts @@ -63,6 +63,27 @@ export class WorkspaceKernelRelations { } } + listRelatedItemIds(itemIds: string[]) { + const relatedItemIds = new Set(); + const deletingIds = new Set(itemIds); + const itemIdsJson = JSON.stringify(itemIds); + const rows = this.sql<{ from_item_id: string; to_item_id: string }>` + SELECT from_item_id, to_item_id + FROM kernel_relations + WHERE from_item_id IN (SELECT value FROM json_each(${itemIdsJson})) + OR to_item_id IN (SELECT value FROM json_each(${itemIdsJson})) + `; + for (const row of rows) { + if (!deletingIds.has(row.from_item_id)) { + relatedItemIds.add(row.from_item_id); + } + if (!deletingIds.has(row.to_item_id)) { + relatedItemIds.add(row.to_item_id); + } + } + return Array.from(relatedItemIds); + } + listItemRelations(itemId: string, limit = 40): WorkspaceKernelRelation[] { return this.sql` SELECT id, from_item_id, to_item_id, kind, note diff --git a/src/features/workspaces/kernel/workspace-kernel-rows.ts b/src/features/workspaces/kernel/workspace-kernel-rows.ts index 0a64d201f..f970974ee 100644 --- a/src/features/workspaces/kernel/workspace-kernel-rows.ts +++ b/src/features/workspaces/kernel/workspace-kernel-rows.ts @@ -2,7 +2,6 @@ import type { WorkspaceItemSummary } from "#/features/workspaces/contracts"; import { workspaceItemTypeSchema } from "#/features/workspaces/contracts"; import { getWorkspaceItemTypeMeta } from "#/features/workspaces/defaults"; import { parseWorkspaceMetadataJson } from "#/features/workspaces/kernel/workspace-kernel-metadata"; -import type { WorkspaceRealtimeEvent } from "#/features/workspaces/realtime/messages"; import type { WorkspaceKernelFileProjectionFormat, WorkspaceKernelFileProjectionStatus, @@ -23,16 +22,6 @@ export type KernelItemRow = { deleted_at: number | null; }; -export type KernelEventRow = { - id: string; - revision: number; - type: WorkspaceRealtimeEvent["type"]; - actor_user_id: string | null; - client_mutation_id: string | null; - payload_json: string; - created_at: number; -}; - export type KernelItemProjectionRow = { item_id: string; format: WorkspaceKernelFileProjectionFormat; @@ -66,23 +55,3 @@ export function mapKernelItemRow(row: KernelItemRow, workspaceId: string): Works deletedAt: row.deleted_at ? new Date(row.deleted_at).toISOString() : null, }; } - -export function mapKernelEventRow( - row: KernelEventRow, - workspaceId: string, -): WorkspaceRealtimeEvent { - return { - id: row.id, - revision: row.revision, - workspaceId, - type: row.type, - actorUserId: row.actor_user_id, - clientMutationId: row.client_mutation_id, - createdAt: new Date(row.created_at).toISOString(), - payload: parseWorkspaceEventPayload(row), - } as WorkspaceRealtimeEvent; -} - -function parseWorkspaceEventPayload(row: KernelEventRow): WorkspaceRealtimeEvent["payload"] { - return JSON.parse(row.payload_json) as WorkspaceRealtimeEvent["payload"]; -} diff --git a/src/features/workspaces/kernel/workspace-kernel-schema.ts b/src/features/workspaces/kernel/workspace-kernel-schema.ts index 88eb76864..1fb0c590a 100644 --- a/src/features/workspaces/kernel/workspace-kernel-schema.ts +++ b/src/features/workspaces/kernel/workspace-kernel-schema.ts @@ -85,9 +85,6 @@ export function initializeWorkspaceKernelStorage(sql: WorkspaceKernelSql) { `; sql`CREATE INDEX IF NOT EXISTS kernel_events_revision_idx ON kernel_events (revision)`; - sql`CREATE INDEX IF NOT EXISTS kernel_events_client_mutation_idx - ON kernel_events (client_mutation_id, type, revision) - WHERE client_mutation_id IS NOT NULL`; } function createSiblingNameIndexes(sql: WorkspaceKernelSql) { diff --git a/src/features/workspaces/kernel/workspace-kernel-store.ts b/src/features/workspaces/kernel/workspace-kernel-store.ts index 690f1a024..ac8c0e4c4 100644 --- a/src/features/workspaces/kernel/workspace-kernel-store.ts +++ b/src/features/workspaces/kernel/workspace-kernel-store.ts @@ -40,6 +40,10 @@ export class WorkspaceKernelStore { } getItemFacts(items: WorkspaceItemSummary[]): WorkspaceItemFacts[] { + if (items.length === 0) { + return []; + } + const itemIdsJson = JSON.stringify(items.map((item) => item.id)); const factsByItemId = new Map( this.sql<{ id: string; @@ -57,7 +61,8 @@ export class WorkspaceKernelStore { FROM kernel_items i LEFT JOIN kernel_item_projections p ON p.item_id = i.id AND p.format = 'pages' AND p.status = 'ready' - WHERE i.deleted_at IS NULL + WHERE i.deleted_at IS NULL + AND i.id IN (SELECT value FROM json_each(${itemIdsJson})) `.map((row) => [row.id, row] as const), ); @@ -84,23 +89,6 @@ export class WorkspaceKernelStore { `.map((row) => row.id); } - listItems(input: { parentId?: string | null; limit?: number } = {}): WorkspaceItemSummary[] { - const parentFilter = input.parentId ?? null; - const rows = this.sql` - SELECT * - FROM kernel_items - WHERE deleted_at IS NULL - AND ( - (${parentFilter} IS NULL AND parent_id IS NULL) - OR parent_id = ${parentFilter} - ) - ORDER BY sort_order ASC, name ASC - LIMIT ${Math.max(1, Math.min(input.limit ?? 80, 500))} - `; - - return rows.map((row) => mapKernelItemRow(row, this.workspaceId())); - } - getCurrentRevision() { const [row] = this.sql<{ value: string }>` SELECT value diff --git a/src/features/workspaces/kernel/workspace-kernel-types.ts b/src/features/workspaces/kernel/workspace-kernel-types.ts index a860a33ba..cf4ef4c0a 100644 --- a/src/features/workspaces/kernel/workspace-kernel-types.ts +++ b/src/features/workspaces/kernel/workspace-kernel-types.ts @@ -26,6 +26,12 @@ export interface CreateWorkspaceKernelRelationArgs { toItemId: string; } +export interface LinkWorkspaceKernelItemsArgs { + actorUserId?: string | null; + clientMutationId?: string | null; + relations: CreateWorkspaceKernelRelationArgs[]; +} + export interface ListWorkspaceKernelItemRelationsArgs { itemId: string; limit?: number; @@ -40,8 +46,43 @@ export interface WorkspaceKernelItemRelation { } export interface ListWorkspaceKernelItemsArgs { - parentId?: string | null; limit?: number; + offset?: number; + path?: string; + recursive?: boolean; +} + +export type WorkspaceKernelPathResolution = + | { + code: "path_not_absolute"; + path: string; + status: "invalid_path"; + } + | { + path: string; + status: "not_found"; + } + | { + path: string; + status: "root"; + } + | { + item: WorkspaceItemSummary; + path: string; + status: "item"; + }; + +export interface ResolveWorkspaceKernelPathsArgs { + paths: string[]; +} + +export interface GetWorkspaceKernelItemPathsArgs { + itemIds: string[]; +} + +export interface WorkspaceKernelItemPath { + itemId: string; + path: string; } export type WorkspaceKernelNameConflictPolicy = "rename" | "error"; @@ -75,7 +116,7 @@ export function requireAppliedWorkspaceKernelMutation( } export interface CreateWorkspaceKernelItemArgs { - id?: string; + id: string; parentId?: string | null; type: WorkspaceItemType; name?: string; @@ -122,7 +163,7 @@ export interface DeleteWorkspaceKernelItemsArgs { clientMutationId?: string | null; } -export interface ReadWorkspaceKernelItemArgs { +export interface ReadWorkspaceDocumentCheckpointArgs { itemId: string; } @@ -211,7 +252,7 @@ export interface ReadWorkspaceKernelFileProjectionResult { updatedAt: string; } -export interface WriteWorkspaceKernelItemArgs { +export interface CommitWorkspaceDocumentCheckpointArgs { itemId: string; content: string; actorUserId?: string | null; @@ -245,8 +286,3 @@ export interface DeleteWorkspaceKernelItemsResult { itemIds: string[]; deletedItemIds: string[]; } - -export interface ListWorkspaceKernelEventsArgs { - afterRevision: number; - limit?: number; -} diff --git a/src/features/workspaces/kernel/workspace-kernel.ts b/src/features/workspaces/kernel/workspace-kernel.ts index 5c9a52dae..474a3717b 100644 --- a/src/features/workspaces/kernel/workspace-kernel.ts +++ b/src/features/workspaces/kernel/workspace-kernel.ts @@ -17,33 +17,45 @@ import { type WorkspaceKernelSql, } from "#/features/workspaces/kernel/workspace-kernel-schema"; import { WorkspaceKernelRelations } from "#/features/workspaces/kernel/workspace-kernel-relations"; +import { + listWorkspaceKernelTreeItems, + type ListWorkspaceKernelItemsResult, +} from "#/features/workspaces/kernel/workspace-kernel-list"; +import { + buildWorkspaceKernelItemPathIndex, + buildWorkspaceKernelTree, + normalizeWorkspacePath, + resolveWorkspaceKernelItemPath, + WorkspaceKernelPathError, +} from "#/features/workspaces/kernel/workspace-kernel-paths"; import { WorkspaceKernelStore } from "#/features/workspaces/kernel/workspace-kernel-store"; import type { CreateWorkspaceKernelFileFromUploadArgs, CreateWorkspaceKernelItemArgs, - CreateWorkspaceKernelRelationArgs, DeleteWorkspaceKernelItemsArgs, DeleteWorkspaceKernelItemsResult, - ListWorkspaceKernelEventsArgs, + GetWorkspaceKernelItemPathsArgs, ListWorkspaceKernelItemRelationsArgs, ListWorkspaceKernelItemsArgs, + LinkWorkspaceKernelItemsArgs, MoveWorkspaceKernelItemsArgs, MoveWorkspaceKernelItemsResult, ReadWorkspaceKernelFileSourceArgs, ReadWorkspaceKernelFileProjectionArgs, - ReadWorkspaceKernelItemArgs, + ReadWorkspaceDocumentCheckpointArgs, + ResolveWorkspaceKernelPathsArgs, RenameWorkspaceKernelItemArgs, UpdateWorkspaceKernelItemColorArgs, UpsertWorkspaceKernelFileProjectionArgs, WorkspaceKernelPage, WorkspaceKernelMutationOutcome, - WriteWorkspaceKernelItemArgs, + CommitWorkspaceDocumentCheckpointArgs, + WorkspaceKernelPathResolution, } from "#/features/workspaces/kernel/workspace-kernel-types"; import { getChatAttachmentWorkspacePrefix } from "#/features/workspaces/ai/chat-attachment-storage"; import type { WorkspaceCommandResult, WorkspaceConnectionState, - WorkspaceRealtimeEvent, WorkspaceRealtimeServerMessage, } from "#/features/workspaces/realtime/messages"; import { @@ -126,17 +138,70 @@ export class WorkspaceKernel extends Agent { }; } - async listItems(input: ListWorkspaceKernelItemsArgs = {}) { - return this.store.listItems(input); + async listTreeItems( + input: ListWorkspaceKernelItemsArgs = {}, + ): Promise { + const items = this.store.getPageItems(); + return listWorkspaceKernelTreeItems({ + getItemFacts: (listedItems) => this.store.getItemFacts(listedItems), + tree: buildWorkspaceKernelTree(items), + ...input, + }); + } + + async resolvePaths( + input: ResolveWorkspaceKernelPathsArgs, + ): Promise { + const tree = buildWorkspaceKernelTree(this.store.getPageItems()); + + return input.paths.map((path) => { + try { + const normalizedPath = normalizeWorkspacePath(path); + if (normalizedPath === "/") { + return { path: normalizedPath, status: "root" }; + } + + const item = resolveWorkspaceKernelItemPath(normalizedPath, tree); + return item + ? { item, path: normalizedPath, status: "item" } + : { path: normalizedPath, status: "not_found" }; + } catch (error) { + if (error instanceof WorkspaceKernelPathError && error.code === "path_not_absolute") { + return { code: error.code, path, status: "invalid_path" }; + } + throw error; + } + }); } - async createRelations(input: { relations: CreateWorkspaceKernelRelationArgs[] }) { + async getItemPaths(input: GetWorkspaceKernelItemPathsArgs) { + const pathsByItemId = buildWorkspaceKernelItemPathIndex(this.store.getPageItems()); + return input.itemIds.flatMap((itemId) => { + const path = pathsByItemId.get(itemId); + return path ? [{ itemId, path }] : []; + }); + } + + async linkItems(input: LinkWorkspaceKernelItemsArgs) { for (const relation of input.relations) { this.store.assertActiveItem(relation.fromItemId); this.store.assertActiveItem(relation.toItemId); } this.relations.createRelations(input.relations); + const itemIds = Array.from( + new Set(input.relations.flatMap((relation) => [relation.fromItemId, relation.toItemId])), + ); + const itemFacts = this.store.getItemFacts( + itemIds.map((itemId) => this.store.requireItem(itemId)), + ); + const event = this.events.commit({ + type: "workspace.relations.updated", + actorUserId: input.actorUserId ?? null, + clientMutationId: input.clientMutationId ?? null, + payload: { itemFacts }, + }); + return { event, result: itemFacts }; } async listItemRelations(input: ListWorkspaceKernelItemRelationsArgs) { @@ -165,7 +230,9 @@ export class WorkspaceKernel extends Agent { } async upsertFileProjection(input: UpsertWorkspaceKernelFileProjectionArgs) { - return await this.fileCommands.upsertFileProjection(input); + return await this.runMutation("upsert_file_projection", input, 1, () => + this.fileCommands.upsertFileProjection(input), + ); } async readFileProjection(input: ReadWorkspaceKernelFileProjectionArgs) { @@ -204,14 +271,16 @@ export class WorkspaceKernel extends Agent { }); } - async readItem(input: ReadWorkspaceKernelItemArgs) { - return await this.itemCommands.readItem(input); + async readDocumentCheckpoint(input: ReadWorkspaceDocumentCheckpointArgs) { + return await this.itemCommands.readDocumentCheckpoint(input); } - async writeItem( - input: WriteWorkspaceKernelItemArgs, + async commitDocumentCheckpoint( + input: CommitWorkspaceDocumentCheckpointArgs, ): Promise> { - return this.runMutation("write_item", input, 1, () => this.itemCommands.writeItem(input)); + return this.runMutation("commit_document_checkpoint", input, 1, () => + this.itemCommands.commitDocumentCheckpoint(input), + ); } private async runMutation( @@ -245,13 +314,6 @@ export class WorkspaceKernel extends Agent { } } - async getEventsSince({ - afterRevision, - limit = 100, - }: ListWorkspaceKernelEventsArgs): Promise { - return this.events.getEventsSince({ afterRevision, limit }); - } - async purgeForDeletion(): Promise { const workspaceId = this.name; const documentItemIds = this.store.getAllDocumentItemIds(); diff --git a/src/features/workspaces/model/item-display.ts b/src/features/workspaces/model/item-display.ts index fa61a3be6..3bd89d117 100644 --- a/src/features/workspaces/model/item-display.ts +++ b/src/features/workspaces/model/item-display.ts @@ -1,16 +1,10 @@ -import { type LucideIcon, Mic, Upload } from "lucide-react"; +import { Mic, Upload } from "lucide-react"; -import { - creatableWorkspaceObjectEntries, - getWorkspaceObjectRegistryEntry, -} from "#/features/workspaces/model/object-registry"; +import { getWorkspaceObjectRegistryEntry } from "#/features/workspaces/model/object-registry"; import type { WorkspaceItem } from "#/features/workspaces/model/types"; import { workspaceColors } from "#/features/workspaces/model/workspace-colors"; import { resolveWorkspaceFileTypeFromItem } from "#/features/workspaces/model/workspace-file"; -import { - getWorkspaceItemPalette, - workspaceItemTypeColors, -} from "#/features/workspaces/model/workspace-item-colors"; +import { getWorkspaceItemPalette } from "#/features/workspaces/model/workspace-item-colors"; export function getWorkspaceItemDisplay(item: WorkspaceItem) { const typeDisplay = getWorkspaceObjectRegistryEntry(item.type); @@ -26,47 +20,32 @@ export function getWorkspaceItemDisplay(item: WorkspaceItem) { }; } -export const workspaceItemCreateActions = creatableWorkspaceObjectEntries.map((display) => ({ - type: display.type, - label: display.menuLabel, - group: display.menuGroup, - Icon: display.icon, - iconClassName: workspaceColors[workspaceItemTypeColors[display.type]].iconClassName, -})); - const workspaceItemPrimaryCreateActionOrder = ["document", "folder"] as const; +const workspaceItemLearnCreateActionOrder = ["flashcard", "quiz"] as const; -export const workspaceItemPrimaryCreateActions = workspaceItemPrimaryCreateActionOrder.map( - (type) => { - const action = workspaceItemCreateActions.find((item) => item.type === type); - - if (!action) { - throw new Error(`Missing workspace create action for type: ${type}`); - } - - return action; - }, -); +export const workspaceItemPrimaryCreateActions = + workspaceItemPrimaryCreateActionOrder.map(createWorkspaceItemAction); -export const workspaceItemLearnCreateActions = workspaceItemCreateActions.filter( - (action) => action.group === "learn", -); +export const workspaceItemLearnCreateActions = + workspaceItemLearnCreateActionOrder.map(createWorkspaceItemAction); -export interface WorkspaceItemAcquisitionAction { - id: "upload-file" | "record-audio"; - label: string; - description?: string; - Icon: LucideIcon; - iconClassName: string; - disabled: boolean; +function createWorkspaceItemAction(type: "document" | "folder" | "flashcard" | "quiz") { + const display = getWorkspaceObjectRegistryEntry(type); + return { + type, + label: display.menuLabel, + Icon: display.icon, + iconClassName: workspaceColors[display.color].iconClassName, + }; } -export const workspaceItemAcquisitionActions: WorkspaceItemAcquisitionAction[] = [ +export const workspaceItemAcquisitionActions = [ { id: "upload-file", label: "Upload", + description: undefined, Icon: Upload, - iconClassName: workspaceColors[workspaceItemTypeColors.file].iconClassName, + iconClassName: workspaceColors[getWorkspaceObjectRegistryEntry("file").color].iconClassName, disabled: false, }, { @@ -77,4 +56,4 @@ export const workspaceItemAcquisitionActions: WorkspaceItemAcquisitionAction[] = iconClassName: workspaceColors.orange.iconClassName, disabled: true, }, -]; +] as const; diff --git a/src/features/workspaces/model/object-registry.ts b/src/features/workspaces/model/object-registry.ts index 2daf4021d..229436aa7 100644 --- a/src/features/workspaces/model/object-registry.ts +++ b/src/features/workspaces/model/object-registry.ts @@ -1,67 +1,20 @@ -import { FilePen, Folder, Layers3, ListChecks, type LucideIcon, Paperclip } from "lucide-react"; +import { FilePen, Folder, Layers3, ListChecks, Paperclip } from "lucide-react"; import type { WorkspaceItemType } from "#/features/workspaces/contracts"; -export type WorkspaceItemCreateGroup = "primary" | "learn"; +import { getWorkspaceItemRegistryEntry } from "#/features/workspaces/workspace-item-registry"; -export interface WorkspaceItemRegistryEntry { - type: WorkspaceItemType; - label: string; - menuLabel: string; - menuGroup: WorkspaceItemCreateGroup; - creatable: boolean; - icon: LucideIcon; -} - -export const workspaceObjectRegistry = { - folder: { - type: "folder", - label: "Folder", - menuLabel: "Folder", - menuGroup: "primary", - creatable: true, - icon: Folder, - }, - document: { - type: "document", - label: "Document", - menuLabel: "Document", - menuGroup: "primary", - creatable: true, - icon: FilePen, - }, - file: { - type: "file", - label: "File", - menuLabel: "Upload file", - menuGroup: "primary", - creatable: false, - icon: Paperclip, - }, - flashcard: { - type: "flashcard", - label: "Flashcard deck", - menuLabel: "Flashcards", - menuGroup: "learn", - creatable: true, - icon: Layers3, - }, - quiz: { - type: "quiz", - label: "Quiz", - menuLabel: "Quiz", - menuGroup: "learn", - creatable: true, - icon: ListChecks, - }, -} satisfies Record; +const workspaceItemIcons = { + document: FilePen, + file: Paperclip, + flashcard: Layers3, + folder: Folder, + quiz: ListChecks, +} satisfies Record; export function getWorkspaceObjectRegistryEntry(type: WorkspaceItemType) { - return workspaceObjectRegistry[type]; + return { + ...getWorkspaceItemRegistryEntry(type), + icon: workspaceItemIcons[type], + type, + }; } - -export const workspaceObjectRegistryEntries: WorkspaceItemRegistryEntry[] = - Object.values(workspaceObjectRegistry); - -export const creatableWorkspaceObjectEntries = workspaceObjectRegistryEntries.filter( - (entry) => entry.creatable, -); diff --git a/src/features/workspaces/model/workspace-file/index.ts b/src/features/workspaces/model/workspace-file/index.ts index 855b489c5..c2b376dc5 100644 --- a/src/features/workspaces/model/workspace-file/index.ts +++ b/src/features/workspaces/model/workspace-file/index.ts @@ -29,13 +29,14 @@ export { type WorkspaceFileItemLike, workspaceItemRequiresHeavyViewerRuntime, } from "#/features/workspaces/model/workspace-file/resolve"; -export type { - WorkspaceFileAiReadStrategy, - WorkspaceFileAssetKind, - WorkspaceFileExtractionMode, - WorkspaceFileExtractionProviderId, - WorkspaceFileExtractionRoute, - WorkspaceFilePreviewGeneratorId, +export { + type WorkspaceFileAiReadStrategy, + type WorkspaceFileAssetKind, + type WorkspaceFileExtractionMode, + type WorkspaceFileExtractionProviderId, + type WorkspaceFileExtractionRoute, + type WorkspaceFilePreviewGeneratorId, + workspaceFileAssetKindSchema, workspaceFileAssetKinds, workspaceFileExtractionProviders, } from "#/features/workspaces/model/workspace-file/types"; diff --git a/src/features/workspaces/model/workspace-file/types.ts b/src/features/workspaces/model/workspace-file/types.ts index 9cffeb8a4..8c0707013 100644 --- a/src/features/workspaces/model/workspace-file/types.ts +++ b/src/features/workspaces/model/workspace-file/types.ts @@ -1,4 +1,7 @@ +import { z } from "zod"; + export const workspaceFileAssetKinds = ["pdf", "image"] as const; +export const workspaceFileAssetKindSchema = z.enum(workspaceFileAssetKinds); export type WorkspaceFileAssetKind = (typeof workspaceFileAssetKinds)[number]; diff --git a/src/features/workspaces/model/workspace-item-colors.ts b/src/features/workspaces/model/workspace-item-colors.ts index 7b2a1d015..7ecb64a41 100644 --- a/src/features/workspaces/model/workspace-item-colors.ts +++ b/src/features/workspaces/model/workspace-item-colors.ts @@ -8,14 +8,7 @@ import { workspaceColorOptions, workspaceColors, } from "#/features/workspaces/model/workspace-colors"; - -export const workspaceItemTypeColors = { - document: "sky", - file: "rose", - flashcard: "violet", - folder: "amber", - quiz: "emerald", -} as const satisfies Record; +import { getWorkspaceItemRegistryEntry } from "#/features/workspaces/workspace-item-registry"; export const workspaceItemColorOptions = workspaceColorOptions; @@ -29,16 +22,16 @@ export function getWorkspaceItemColorValue(color: string | null): WorkspaceItemC return parsed.success ? parsed.data : null; } -// Non-folder items ignore stored color; palette comes from workspaceItemTypeColors. +// Non-folder items ignore stored color; palette comes from the item registry. export function resolveWorkspaceItemColor(input: { type: WorkspaceItemType; color: string | null; }): WorkspaceItemColor { if (input.type === "folder") { - return getWorkspaceItemColorValue(input.color) ?? workspaceItemTypeColors.folder; + return getWorkspaceItemColorValue(input.color) ?? getWorkspaceItemRegistryEntry("folder").color; } - return workspaceItemTypeColors[input.type]; + return getWorkspaceItemRegistryEntry(input.type).color; } export function resolveWorkspaceItemColorForCreate(input: { diff --git a/src/features/workspaces/model/workspace-page.test.ts b/src/features/workspaces/model/workspace-page.test.ts new file mode 100644 index 000000000..ca653f52a --- /dev/null +++ b/src/features/workspaces/model/workspace-page.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; + +import type { WorkspacePage } from "#/features/workspaces/contracts"; +import { applyWorkspaceEventToPage } from "#/features/workspaces/model/workspace-page"; +import type { WorkspaceRealtimeEvent } from "#/features/workspaces/realtime/messages"; + +describe("applyWorkspaceEventToPage", () => { + it.each(["workspace.relations.updated", "workspace.item.projection.updated"] as const)( + "applies %s facts and revision", + (type) => { + const page = { + workspace: {} as WorkspacePage["workspace"], + items: [], + itemFacts: [{ itemId: "item-1", relationshipCount: 0 }], + revision: 4, + } satisfies WorkspacePage; + const event = { + id: "event-1", + revision: 5, + workspaceId: "workspace-1", + createdAt: "2026-01-01T00:00:00.000Z", + actorUserId: null, + clientMutationId: null, + type, + payload: { + itemFacts: [{ itemId: "item-1", pageCount: 12, relationshipCount: 2 }], + }, + } satisfies WorkspaceRealtimeEvent; + + expect(applyWorkspaceEventToPage(page, event)).toMatchObject({ + itemFacts: [{ itemId: "item-1", pageCount: 12, relationshipCount: 2 }], + revision: 5, + }); + }, + ); +}); diff --git a/src/features/workspaces/model/workspace-page.ts b/src/features/workspaces/model/workspace-page.ts index 816129dda..0d1eec425 100644 --- a/src/features/workspaces/model/workspace-page.ts +++ b/src/features/workspaces/model/workspace-page.ts @@ -18,8 +18,17 @@ export function applyWorkspaceEventToPage( page: WorkspacePage, event: WorkspaceRealtimeEvent, ): WorkspacePage { + if (event.revision <= page.revision) { + return page; + } + switch (event.type) { case "workspace.item.created": + return upsertWorkspaceItemFactsInPage( + upsertWorkspaceItemInPage(page, event.payload.item, event.revision), + event.payload.itemFacts, + event.revision, + ); case "workspace.item.renamed": case "workspace.item.moved": case "workspace.item.color.updated": @@ -28,10 +37,34 @@ export function applyWorkspaceEventToPage( case "workspace.items.moved": return upsertWorkspaceItemsInPage(page, event.payload.items, event.revision); case "workspace.item.deleted": - return removeWorkspaceItemsFromPage(page, event.payload.deletedItemIds, event.revision); + return upsertWorkspaceItemFactsInPage( + removeWorkspaceItemsFromPage(page, event.payload.deletedItemIds, event.revision), + event.payload.itemFacts, + event.revision, + ); + case "workspace.relations.updated": + case "workspace.item.projection.updated": + return upsertWorkspaceItemFactsInPage(page, event.payload.itemFacts, event.revision); } } +function upsertWorkspaceItemFactsInPage( + page: WorkspacePage, + itemFacts: WorkspacePage["itemFacts"], + revision: number, +): WorkspacePage { + const nextFactsByItemId = new Map(itemFacts.map((facts) => [facts.itemId, facts])); + const currentItemIds = new Set(page.itemFacts.map((facts) => facts.itemId)); + return { + ...page, + itemFacts: [ + ...page.itemFacts.map((facts) => nextFactsByItemId.get(facts.itemId) ?? facts), + ...itemFacts.filter((facts) => !currentItemIds.has(facts.itemId)), + ], + revision: Math.max(page.revision, revision), + }; +} + export function createWorkspaceItemInPage( page: WorkspacePage, input: CreateWorkspaceItemInput & { id: string }, @@ -47,7 +80,6 @@ export function createWorkspaceItemInPage( const { metadataJson } = buildWorkspaceItemCreateBootstrap({ type: input.type, - name, initialContent: input.initialContent, }); @@ -196,6 +228,7 @@ export function removeWorkspaceItemsFromPage( ...page, revision: Math.max(page.revision, revision), items: page.items.filter((item) => !deletedIds.has(item.id)), + itemFacts: page.itemFacts.filter((facts) => !deletedIds.has(facts.itemId)), }; } diff --git a/src/features/workspaces/operations/create-items.ts b/src/features/workspaces/operations/create-items.ts index 4ff312d8f..bd93fcfc8 100644 --- a/src/features/workspaces/operations/create-items.ts +++ b/src/features/workspaces/operations/create-items.ts @@ -1,14 +1,11 @@ -import { - getWorkspaceOperationContext, - resolveWorkspaceOperationPath, -} from "#/features/workspaces/operations/workspace-operation-context"; +import { getAuthorizedWorkspaceKernel } from "#/features/workspaces/operations/workspace-operation-context"; import { resolveWorkspaceRelations, type WorkspaceRelationInput, workspaceRelationFailureCodes, } from "#/features/workspaces/operations/relations"; import type { WorkspaceAccessContext } from "#/features/workspaces/operations/workspace-access-context"; -import type { WorkspaceItemSummary } from "#/features/workspaces/contracts"; +import type { WorkspaceKernelPathResolution } from "#/features/workspaces/kernel/workspace-kernel-types"; import { parseMarkdownToTiptapDocumentProjection } from "#/features/workspaces/documents/document-markdown"; import { stringifyTiptapDocumentJson } from "#/features/workspaces/documents/tiptap-document"; import { @@ -17,7 +14,6 @@ import { joinWorkspaceItemPath, normalizeWorkspacePath, WorkspaceKernelPathError, - type WorkspaceKernelTree, } from "#/features/workspaces/kernel/workspace-kernel-paths"; export interface CreateWorkspaceItemOperationInput { @@ -78,14 +74,14 @@ export async function createWorkspaceItemsOperation( accessContext: WorkspaceAccessContext, input: CreateWorkspaceItemsOperationInput, ): Promise { - const workspaceContext = await getWorkspaceOperationContext({ + const kernel = await getAuthorizedWorkspaceKernel({ access: "mutate", context: accessContext, }); const items: CreatedWorkspaceItem[] = []; const failed: CreateWorkspaceItemsFailure[] = []; - const createdItemsByPath = new Map(); + // Preserve order so path resolution observes items committed earlier in this batch. for (const [index, itemInput] of input.items.entries()) { const id = crypto.randomUUID(); const path = resolveCreateWorkspaceItemPath(itemInput.path); @@ -99,11 +95,13 @@ export async function createWorkspaceItemsOperation( continue; } - const parent = resolveCreateWorkspaceItemParent({ - createdItemsByPath, - parentPath: path.parentPath, - tree: workspaceContext.tree, + const [parentResolution, ...relationTargets] = await kernel.resolvePaths({ + paths: [path.parentPath, ...(itemInput.relations ?? []).map((relation) => relation.path)], }); + if (!parentResolution) { + throw new Error("Workspace kernel did not resolve the requested create parent."); + } + const parent = resolveCreateWorkspaceItemParent(parentResolution); if (parent.status === "failed") { failed.push({ @@ -126,10 +124,9 @@ export async function createWorkspaceItemsOperation( } const relations = resolveWorkspaceRelations({ - createdItemsByPath, fromItemId: id, relations: itemInput.relations, - tree: workspaceContext.tree, + targets: relationTargets, }); if (relations.status === "failed") { @@ -141,7 +138,7 @@ export async function createWorkspaceItemsOperation( continue; } - const outcome = await workspaceContext.kernel.createItem({ + const outcome = await kernel.createItem({ id, parentId: parent.parentId, type: itemInput.type, @@ -177,10 +174,6 @@ export async function createWorkspaceItemsOperation( ? { warnings: initialContent.warnings } : {}), }); - createdItemsByPath.set(createdPath, { - id: command.result.id, - type: command.result.type, - }); } return { @@ -189,11 +182,7 @@ export async function createWorkspaceItemsOperation( }; } -function resolveCreateWorkspaceItemParent(input: { - createdItemsByPath: ReadonlyMap; - parentPath: string; - tree: WorkspaceKernelTree; -}): +function resolveCreateWorkspaceItemParent(resolution: WorkspaceKernelPathResolution): | { code: "path_not_folder" | "path_not_found"; status: "failed"; @@ -203,52 +192,26 @@ function resolveCreateWorkspaceItemParent(input: { path: string; status: "parent"; } { - if (input.parentPath === "/") { + if (resolution.status === "root") { return { parentId: null, - path: "/", - status: "parent", - }; - } - - const createdParent = input.createdItemsByPath.get(input.parentPath); - - if (createdParent) { - if (createdParent.type !== "folder") { - return { - code: "path_not_folder", - status: "failed", - }; - } - - return { - parentId: createdParent.id, - path: input.parentPath, + path: resolution.path, status: "parent", }; } - const parent = resolveWorkspaceOperationPath({ - path: input.parentPath, - tree: input.tree, - }); - - if (parent.status === "invalid_path") { - throw new Error(`Unexpected invalid create parent path: ${input.parentPath}`); + if (resolution.status === "invalid_path") { + throw new Error(`Unexpected invalid create parent path: ${resolution.path}`); } - if (parent.status === "not_found") { + if (resolution.status === "not_found") { return { code: "path_not_found", status: "failed", }; } - if (parent.status === "root") { - throw new Error(`Unexpected root create parent path: ${input.parentPath}`); - } - - if (parent.item.type !== "folder") { + if (resolution.item.type !== "folder") { return { code: "path_not_folder", status: "failed", @@ -256,8 +219,8 @@ function resolveCreateWorkspaceItemParent(input: { } return { - parentId: parent.item.id, - path: parent.path, + parentId: resolution.item.id, + path: resolution.path, status: "parent", }; } diff --git a/src/features/workspaces/operations/delete-items.ts b/src/features/workspaces/operations/delete-items.ts index 32bba73bd..4064db94a 100644 --- a/src/features/workspaces/operations/delete-items.ts +++ b/src/features/workspaces/operations/delete-items.ts @@ -1,5 +1,5 @@ import { - getWorkspaceOperationContext, + getAuthorizedWorkspaceKernel, resolveWorkspaceExistingItemPath, } from "#/features/workspaces/operations/workspace-operation-context"; import type { WorkspaceAccessContext } from "#/features/workspaces/operations/workspace-access-context"; @@ -35,7 +35,7 @@ export async function deleteWorkspaceItemsOperation( accessContext: WorkspaceAccessContext, input: DeleteWorkspaceItemsOperationInput, ): Promise { - const workspaceContext = await getWorkspaceOperationContext({ + const kernel = await getAuthorizedWorkspaceKernel({ access: "mutate", context: accessContext, }); @@ -44,12 +44,12 @@ export async function deleteWorkspaceItemsOperation( item: WorkspaceItemSummary; path: string; }> = []; + const resolutions = await kernel.resolvePaths({ paths: input.paths }); - for (const [index, path] of input.paths.entries()) { + for (const [index, pathResolution] of resolutions.entries()) { const resolution = resolveWorkspaceExistingItemPath({ - path, + resolution: pathResolution, rootFailureCode: "cannot_delete_root", - tree: workspaceContext.tree, }); if (resolution.status === "failed") { @@ -74,7 +74,7 @@ export async function deleteWorkspaceItemsOperation( }; } - const command = await workspaceContext.kernel.deleteItems({ + const command = await kernel.deleteItems({ itemIds: resolvedItems.map((resolved) => resolved.item.id), actorUserId: accessContext.actor.userId, clientMutationId: accessContext.operationId, diff --git a/src/features/workspaces/operations/edit-item.ts b/src/features/workspaces/operations/edit-item.ts index ee843d6c0..c0583e746 100644 --- a/src/features/workspaces/operations/edit-item.ts +++ b/src/features/workspaces/operations/edit-item.ts @@ -1,13 +1,8 @@ import { getDocumentSessionFromEnv } from "#/features/workspaces/document-session-access"; import { - getWorkspaceOperationContext, + getAuthorizedWorkspaceKernel, resolveWorkspaceExistingItemPath, } from "#/features/workspaces/operations/workspace-operation-context"; -import { - resolveWorkspaceRelations, - type WorkspaceRelationInput, - workspaceRelationFailureCodes, -} from "#/features/workspaces/operations/relations"; import type { WorkspaceAccessContext } from "#/features/workspaces/operations/workspace-access-context"; import { type DocumentMarkdownEdit, @@ -16,10 +11,8 @@ import { export const editWorkspaceItemFailureCodes = [ "cannot_edit_root", - "missing_edit_operation", "path_not_absolute", "path_not_found", - ...workspaceRelationFailureCodes, "unsupported_item_type", ...documentMarkdownEditFailureCodes, "invalid_document_projection", @@ -28,9 +21,8 @@ export const editWorkspaceItemFailureCodes = [ type EditWorkspaceItemFailureCode = (typeof editWorkspaceItemFailureCodes)[number]; export interface EditWorkspaceItemOperationInput { - edits?: DocumentMarkdownEdit[]; + edits: DocumentMarkdownEdit[]; path: string; - relations?: WorkspaceRelationInput[]; } interface EditWorkspaceItemFailure { @@ -49,25 +41,19 @@ export async function editWorkspaceItemOperation( accessContext: WorkspaceAccessContext, input: EditWorkspaceItemOperationInput, ): Promise { - const edits = input.edits ?? []; - - if (edits.length === 0 && (input.relations?.length ?? 0) === 0) { - return { - path: input.path, - warnings: [], - ...failedWorkspaceEditResult("missing_edit_operation", 1), - }; - } - - const workspaceContext = await getWorkspaceOperationContext({ + const edits = input.edits; + const kernel = await getAuthorizedWorkspaceKernel({ access: "mutate", context: accessContext, }); const failureCount = Math.max(edits.length, 1); + const [pathResolution] = await kernel.resolvePaths({ paths: [input.path] }); + if (!pathResolution) { + throw new Error("Workspace kernel did not resolve the requested edit path."); + } const resolution = resolveWorkspaceExistingItemPath({ - path: input.path, + resolution: pathResolution, rootFailureCode: "cannot_edit_root", - tree: workspaceContext.tree, }); if (resolution.status === "failed") { @@ -78,34 +64,6 @@ export async function editWorkspaceItemOperation( }; } - const relations = resolveWorkspaceRelations({ - excludeItemId: resolution.item.id, - fromItemId: resolution.item.id, - relations: input.relations, - tree: workspaceContext.tree, - }); - - if (relations.status === "failed") { - return { - path: relations.failure.path, - warnings: [], - ...failedWorkspaceEditResult(relations.failure.code, failureCount), - }; - } - - if (edits.length === 0) { - if (relations.relations.length > 0) { - await workspaceContext.kernel.createRelations({ relations: relations.relations }); - } - - return { - applied: 0, - failed: [], - path: resolution.path, - warnings: [], - }; - } - if (resolution.item.type !== "document") { return { path: resolution.path, @@ -123,10 +81,6 @@ export async function editWorkspaceItemOperation( edits, }); - if (result.failures.length === 0 && relations.relations.length > 0) { - await workspaceContext.kernel.createRelations({ relations: relations.relations }); - } - return { applied: result.applied, failed: result.failures, diff --git a/src/features/workspaces/operations/link-items.ts b/src/features/workspaces/operations/link-items.ts index 362127f1b..6e8e42e04 100644 --- a/src/features/workspaces/operations/link-items.ts +++ b/src/features/workspaces/operations/link-items.ts @@ -6,7 +6,7 @@ import { } from "#/features/workspaces/operations/relations"; import type { WorkspaceAccessContext } from "#/features/workspaces/operations/workspace-access-context"; import { - getWorkspaceOperationContext, + getAuthorizedWorkspaceKernel, resolveWorkspaceExistingItemPath, } from "#/features/workspaces/operations/workspace-operation-context"; @@ -41,14 +41,19 @@ export async function linkWorkspaceItemsOperation( accessContext: WorkspaceAccessContext, input: LinkWorkspaceItemsOperationInput, ): Promise { - const workspaceContext = await getWorkspaceOperationContext({ + const kernel = await getAuthorizedWorkspaceKernel({ access: "mutate", context: accessContext, }); + const [pathResolution, ...relationTargets] = await kernel.resolvePaths({ + paths: [input.path, ...input.relations.map((relation) => relation.path)], + }); + if (!pathResolution) { + throw new Error("Workspace kernel did not resolve the requested link source."); + } const resolution = resolveWorkspaceExistingItemPath({ - path: input.path, + resolution: pathResolution, rootFailureCode: "cannot_link_root", - tree: workspaceContext.tree, }); if (resolution.status === "failed") { @@ -66,7 +71,7 @@ export async function linkWorkspaceItemsOperation( excludeItemId: resolution.item.id, fromItemId: resolution.item.id, relations: input.relations, - tree: workspaceContext.tree, + targets: relationTargets, }); if (relations.status === "failed") { @@ -80,7 +85,11 @@ export async function linkWorkspaceItemsOperation( }; } - await workspaceContext.kernel.createRelations({ relations: relations.relations }); + await kernel.linkItems({ + relations: relations.relations, + actorUserId: accessContext.actor.userId, + clientMutationId: accessContext.operationId, + }); return { failed: [], diff --git a/src/features/workspaces/operations/list-items.ts b/src/features/workspaces/operations/list-items.ts index 1225e5b60..2435e3b3b 100644 --- a/src/features/workspaces/operations/list-items.ts +++ b/src/features/workspaces/operations/list-items.ts @@ -1,9 +1,6 @@ -import { getWorkspaceOperationContext } from "#/features/workspaces/operations/workspace-operation-context"; +import { getAuthorizedWorkspaceKernel } from "#/features/workspaces/operations/workspace-operation-context"; import type { WorkspaceAccessContext } from "#/features/workspaces/operations/workspace-access-context"; -import { - listWorkspaceKernelTreeItems, - type ListWorkspaceKernelItemsResult, -} from "#/features/workspaces/kernel/workspace-kernel-list"; +import type { ListWorkspaceKernelItemsResult } from "#/features/workspaces/kernel/workspace-kernel-list"; export interface ListWorkspaceItemsOperationInput { limit?: number; @@ -16,14 +13,12 @@ export async function listWorkspaceItemsOperation( accessContext: WorkspaceAccessContext, input: ListWorkspaceItemsOperationInput, ): Promise { - const workspaceContext = await getWorkspaceOperationContext({ + const kernel = await getAuthorizedWorkspaceKernel({ access: "read", context: accessContext, }); - return listWorkspaceKernelTreeItems({ - tree: workspaceContext.tree, - itemFactsById: workspaceContext.itemFactsById, + return await kernel.listTreeItems({ offset: input.offset, path: input.path, recursive: input.recursive, diff --git a/src/features/workspaces/operations/move-items.ts b/src/features/workspaces/operations/move-items.ts index fd2a28a1b..a5fa7aff2 100644 --- a/src/features/workspaces/operations/move-items.ts +++ b/src/features/workspaces/operations/move-items.ts @@ -1,15 +1,14 @@ import { - getWorkspaceOperationContext, + getAuthorizedWorkspaceKernel, resolveWorkspaceExistingItemPath, - resolveWorkspaceOperationPath, } from "#/features/workspaces/operations/workspace-operation-context"; import type { WorkspaceAccessContext } from "#/features/workspaces/operations/workspace-access-context"; import type { WorkspaceItemSummary } from "#/features/workspaces/contracts"; import { getParentWorkspacePath, joinWorkspaceItemPath, - type WorkspaceKernelTree, } from "#/features/workspaces/kernel/workspace-kernel-paths"; +import type { WorkspaceKernelPathResolution } from "#/features/workspaces/kernel/workspace-kernel-types"; export interface MoveWorkspaceItemsOperationInput { destinationPath: string; @@ -57,13 +56,18 @@ export async function moveWorkspaceItemsOperation( accessContext: WorkspaceAccessContext, input: MoveWorkspaceItemsOperationInput, ): Promise { - const workspaceContext = await getWorkspaceOperationContext({ + const kernel = await getAuthorizedWorkspaceKernel({ access: "mutate", context: accessContext, }); + const [destinationResolution, ...itemResolutions] = await kernel.resolvePaths({ + paths: [input.destinationPath, ...input.paths], + }); + if (!destinationResolution) { + throw new Error("Workspace kernel did not resolve the requested move destination."); + } const destination = resolveMoveWorkspaceDestination({ - path: input.destinationPath, - tree: workspaceContext.tree, + resolution: destinationResolution, }); if (destination.status === "failed") { @@ -85,11 +89,10 @@ export async function moveWorkspaceItemsOperation( path: string; }> = []; - for (const [index, path] of input.paths.entries()) { + for (const [index, pathResolution] of itemResolutions.entries()) { const resolution = resolveWorkspaceExistingItemPath({ - path, + resolution: pathResolution, rootFailureCode: "cannot_move_root", - tree: workspaceContext.tree, }); if (resolution.status === "failed") { @@ -140,7 +143,7 @@ export async function moveWorkspaceItemsOperation( const pendingItems = [...resolvedItems]; while (pendingItems.length > 0) { - const outcome = await workspaceContext.kernel.moveItems({ + const outcome = await kernel.moveItems({ items: pendingItems.map((resolved) => ({ itemId: resolved.item.id })), parentId: destination.parentId, onNameConflict: "error", @@ -193,7 +196,7 @@ export async function moveWorkspaceItemsOperation( }; } -function resolveMoveWorkspaceDestination(input: { path: string; tree: WorkspaceKernelTree }): +function resolveMoveWorkspaceDestination(input: { resolution: WorkspaceKernelPathResolution }): | { failure: MoveWorkspaceDestinationFailure; status: "failed"; @@ -203,7 +206,7 @@ function resolveMoveWorkspaceDestination(input: { path: string; tree: WorkspaceK path: string; status: "destination"; } { - const resolution = resolveWorkspaceOperationPath(input); + const { resolution } = input; if (resolution.status === "invalid_path") { return { diff --git a/src/features/workspaces/operations/read-items.ts b/src/features/workspaces/operations/read-items.ts index c473fe17a..3ac3be416 100644 --- a/src/features/workspaces/operations/read-items.ts +++ b/src/features/workspaces/operations/read-items.ts @@ -1,294 +1,40 @@ import { env } from "cloudflare:workers"; import { - getWorkspaceOperationContext, - resolveWorkspaceOperationPath, -} from "#/features/workspaces/operations/workspace-operation-context"; -import { - serializeWorkspaceRelations, - type WorkspaceRelationOutput, -} from "#/features/workspaces/operations/relations"; -import { - parseWorkspacePageRange, - WorkspacePageSelectionError, - type WorkspaceReadPages, -} from "#/features/workspaces/read-page-selection"; -import type { WorkspaceItemSummary } from "#/features/workspaces/contracts"; -import { serializeTiptapDocumentToMarkdown } from "#/features/workspaces/documents/document-markdown"; -import { readWorkspacePageProjection } from "#/features/workspaces/extraction/workspace-page-projection"; -import { parseTiptapDocumentJson } from "#/features/workspaces/documents/tiptap-document"; -import type { WorkspaceKernelClient } from "#/features/workspaces/kernel/workspace-kernel-access"; -import { buildWorkspaceKernelItemPathIndex } from "#/features/workspaces/kernel/workspace-kernel-paths"; -import { resolveWorkspaceFileTypeFromItem } from "#/features/workspaces/model/workspace-file"; + type WorkspaceContentReadRequest, + type WorkspaceContentReadResult, +} from "#/features/workspaces/content/workspace-content-contract"; +import { readWorkspaceContent } from "#/features/workspaces/content/workspace-content-reader"; +import { getDocumentSessionFromEnv } from "#/features/workspaces/document-session-access"; import type { WorkspaceAccessContext } from "#/features/workspaces/operations/workspace-access-context"; +import { getAuthorizedWorkspaceKernel } from "#/features/workspaces/operations/workspace-operation-context"; export interface ReadWorkspaceItemsOperationInput { - pages?: string; - paths: string[]; -} - -export interface WorkspaceReadItem { - content?: string; - pages?: WorkspaceReadPages; - path: string; - relations?: WorkspaceRelationOutput[]; - status: "failed" | "pending" | "ready" | "unsupported"; - type: "document" | "file" | "flashcard" | "quiz"; + requests: WorkspaceContentReadRequest[]; } export interface WorkspaceReadItemsResult { - items: WorkspaceReadItem[]; - failed: WorkspaceReadItemsFailure[]; -} - -const WORKSPACE_READ_MARKDOWN_LINES_PER_PAGE = 1000; -const MAX_WORKSPACE_READ_LINE_LENGTH = 2000; -const TRUNCATED_LINE_SUFFIX = `... (line truncated to ${MAX_WORKSPACE_READ_LINE_LENGTH} chars)`; - -export const readWorkspaceItemsFailureCodes = [ - "page_range_out_of_range", - "page_selection_too_large", - "path_is_folder", - "path_not_absolute", - "path_not_found", -] as const; - -type WorkspaceReadItemsFailureCode = (typeof readWorkspaceItemsFailureCodes)[number]; - -interface WorkspaceReadItemsFailure { - code: WorkspaceReadItemsFailureCode; - index: number; - path: string; + results: WorkspaceContentReadResult[]; } export async function readWorkspaceItemsOperation( accessContext: WorkspaceAccessContext, input: ReadWorkspaceItemsOperationInput, ): Promise { - const workspaceContext = await getWorkspaceOperationContext({ + const kernel = await getAuthorizedWorkspaceKernel({ access: "read", context: accessContext, }); - const result: WorkspaceReadItemsResult = { - items: [], - failed: [], - }; - const pathsByItemId = buildWorkspaceKernelItemPathIndex(workspaceContext.pageItems); - - for (const [index, path] of input.paths.entries()) { - const resolution = resolveWorkspaceOperationPath({ - path, - tree: workspaceContext.tree, - }); - - if (resolution.status === "invalid_path") { - result.failed.push({ - code: resolution.code, - index, - path: resolution.path, - }); - continue; - } - - if (resolution.status === "root") { - result.failed.push({ - code: "path_is_folder", - index, - path: resolution.path, - }); - continue; - } - - if (resolution.status === "not_found") { - result.failed.push({ - code: "path_not_found", - index, - path: resolution.path, - }); - continue; - } - - if (resolution.item.type === "folder") { - result.failed.push({ - code: "path_is_folder", - index, - path: resolution.path, - }); - continue; - } - - try { - const item = await readWorkspaceItem({ - item: resolution.item, - kernel: workspaceContext.kernel, - pages: input.pages, - path: resolution.path, - }); - const relations = serializeWorkspaceRelations({ - item: resolution.item, - pathsByItemId, - relations: await workspaceContext.kernel.listItemRelations({ - itemId: resolution.item.id, - }), - }); - - result.items.push({ - ...item, - ...(relations.length > 0 ? { relations } : {}), - }); - } catch (error) { - if (error instanceof WorkspacePageSelectionError) { - result.failed.push({ - code: error.code, - index, - path: resolution.path, - }); - continue; - } - - throw error; - } - } - - return result; -} - -async function readWorkspaceItem(input: { - item: WorkspaceItemSummary; - kernel: WorkspaceKernelClient; - pages?: string; - path: string; -}): Promise { - const { item } = input; - - if (item.type === "folder") { - throw new Error("Folder paths should be handled before item reads."); - } - - if (item.type === "document") { - const { content } = await input.kernel.readItem({ itemId: item.id }); - const markdown = serializeTiptapDocumentToMarkdown(parseTiptapDocumentJson(content)); - const page = readWorkspaceMarkdownPages(markdown, { pages: input.pages }); - - return { - content: page.content, - pages: page.pages, - path: input.path, - status: "ready", - type: "document", - }; - } - - if (item.type === "file") { - return await readWorkspaceFileItem(input); - } - - return { - path: input.path, - status: "unsupported", - type: item.type, - }; -} - -async function readWorkspaceFileItem(input: { - item: WorkspaceItemSummary; - kernel: WorkspaceKernelClient; - pages?: string; - path: string; -}): Promise { - const { item } = input; - const fileType = resolveWorkspaceFileTypeFromItem(item); - - if (!fileType) { - return createWorkspaceFileStatusItem(input.path, "unsupported"); - } - - if (fileType.aiReadStrategy !== "markdown_extraction") { - return createWorkspaceFileStatusItem(input.path, "unsupported"); - } - - const pagesProjection = await input.kernel.readFileProjection({ - itemId: item.id, - format: "pages", - }); - - if ( - !pagesProjection || - pagesProjection.status === "not_started" || - pagesProjection.status === "queued" || - pagesProjection.status === "processing" - ) { - return createWorkspaceFileStatusItem(input.path, "pending"); - } - - if (pagesProjection.status !== "ready" || pagesProjection.objectKey === null) { - return createWorkspaceFileStatusItem(input.path, "failed"); - } - - const pageRead = await readWorkspacePageProjection({ + const results = await readWorkspaceContent({ bucket: env.WORKSPACE_KERNEL_FILES, - manifestObjectKey: pagesProjection.objectKey, - pages: input.pages, + getDocumentSession: (itemId) => + getDocumentSessionFromEnv(env, { + itemId, + workspaceId: accessContext.workspaceId, + }), + kernel, + requests: input.requests, }); - return { - content: pageRead.content, - pages: pageRead.pages, - path: input.path, - status: "ready", - type: "file", - }; -} - -function createWorkspaceFileStatusItem( - path: string, - status: WorkspaceReadItem["status"], -): WorkspaceReadItem { - return { - path, - status, - type: "file", - }; -} - -function readWorkspaceMarkdownPages( - content: string, - input: { pages?: string }, -): { content: string; pages: WorkspaceReadPages } { - const lines = content === "" ? [] : content.split(/\r?\n/); - const totalPages = Math.max(1, Math.ceil(lines.length / WORKSPACE_READ_MARKDOWN_LINES_PER_PAGE)); - const requested = input.pages?.trim() || "1"; - const selectedPageNumbers = parseWorkspacePageRange(requested, totalPages); - - const selectedLines: string[] = []; - - for (const pageNumber of selectedPageNumbers) { - const startIndex = (pageNumber - 1) * WORKSPACE_READ_MARKDOWN_LINES_PER_PAGE; - const pageLines = lines.slice(startIndex, startIndex + WORKSPACE_READ_MARKDOWN_LINES_PER_PAGE); - - for (const rawLine of pageLines) { - const line = truncateWorkspaceMarkdownLine(rawLine); - selectedLines.push(line.value); - } - } - - return { - content: selectedLines.join("\n"), - pages: { - requested, - returned: selectedPageNumbers, - total: totalPages, - }, - }; -} - -function truncateWorkspaceMarkdownLine(line: string) { - if (line.length <= MAX_WORKSPACE_READ_LINE_LENGTH) { - return { truncated: false, value: line }; - } - - return { - truncated: true, - value: line.slice(0, MAX_WORKSPACE_READ_LINE_LENGTH) + TRUNCATED_LINE_SUFFIX, - }; + return { results }; } diff --git a/src/features/workspaces/operations/relations.ts b/src/features/workspaces/operations/relations.ts index a399f5a6e..0e36bfaa7 100644 --- a/src/features/workspaces/operations/relations.ts +++ b/src/features/workspaces/operations/relations.ts @@ -1,13 +1,8 @@ import type { WorkspaceItemSummary, WorkspaceRelationKind } from "#/features/workspaces/contracts"; -import { - normalizeWorkspacePath, - resolveWorkspaceKernelItemPath, - WorkspaceKernelPathError, - type WorkspaceKernelTree, -} from "#/features/workspaces/kernel/workspace-kernel-paths"; import type { CreateWorkspaceKernelRelationArgs, WorkspaceKernelItemRelation, + WorkspaceKernelPathResolution, } from "#/features/workspaces/kernel/workspace-kernel-types"; export interface WorkspaceRelationInput { @@ -38,11 +33,10 @@ export interface WorkspaceRelationOutput { } export function resolveWorkspaceRelations(input: { - createdItemsByPath?: ReadonlyMap; excludeItemId?: string; fromItemId: string; relations?: WorkspaceRelationInput[]; - tree: WorkspaceKernelTree; + targets: WorkspaceKernelPathResolution[]; }): | { relations: CreateWorkspaceKernelRelationArgs[]; @@ -54,12 +48,14 @@ export function resolveWorkspaceRelations(input: { } { const relations: CreateWorkspaceKernelRelationArgs[] = []; - for (const relation of input.relations ?? []) { + for (const [index, relation] of (input.relations ?? []).entries()) { + const resolution = input.targets[index]; + if (!resolution) { + throw new Error("Workspace relation target resolution did not match its input."); + } const target = resolveWorkspaceRelationTarget({ - createdItemsByPath: input.createdItemsByPath, excludeItemId: input.excludeItemId, - path: relation.path, - tree: input.tree, + resolution, }); if (target.status === "failed") { @@ -108,10 +104,8 @@ export function serializeWorkspaceRelations(input: { } function resolveWorkspaceRelationTarget(input: { - createdItemsByPath?: ReadonlyMap; excludeItemId?: string; - path: string; - tree: WorkspaceKernelTree; + resolution: WorkspaceKernelPathResolution; }): | { itemId: string; @@ -121,58 +115,50 @@ function resolveWorkspaceRelationTarget(input: { failure: WorkspaceRelationFailure; status: "failed"; } { - try { - const normalizedPath = normalizeWorkspacePath(input.path); - - if (normalizedPath === "/") { - return { - failure: { - code: "relation_path_is_root", - path: normalizedPath, - }, - status: "failed", - }; - } - - const itemId = - input.createdItemsByPath?.get(normalizedPath)?.id ?? - resolveWorkspaceKernelItemPath(normalizedPath, input.tree)?.id; - - if (!itemId) { - return { - failure: { - code: "relation_path_not_found", - path: normalizedPath, - }, - status: "failed", - }; - } + const { resolution } = input; + if (resolution.status === "invalid_path") { + return { + failure: { + code: "relation_path_not_absolute", + path: resolution.path, + }, + status: "failed", + }; + } - if (input.excludeItemId && itemId === input.excludeItemId) { - return { - failure: { - code: "relation_path_is_self", - path: normalizedPath, - }, - status: "failed", - }; - } + if (resolution.status === "root") { + return { + failure: { + code: "relation_path_is_root", + path: resolution.path, + }, + status: "failed", + }; + } + if (resolution.status === "not_found") { return { - itemId, - status: "ready", + failure: { + code: "relation_path_not_found", + path: resolution.path, + }, + status: "failed", }; - } catch (error) { - if (error instanceof WorkspaceKernelPathError && error.code === "path_not_absolute") { - return { - failure: { - code: "relation_path_not_absolute", - path: input.path, - }, - status: "failed", - }; - } + } + const itemId = resolution.item.id; - throw error; + if (input.excludeItemId && itemId === input.excludeItemId) { + return { + failure: { + code: "relation_path_is_self", + path: resolution.path, + }, + status: "failed", + }; } + + return { + itemId, + status: "ready", + }; } diff --git a/src/features/workspaces/operations/rename-item.ts b/src/features/workspaces/operations/rename-item.ts index 40e4d0e8b..ddffd801f 100644 --- a/src/features/workspaces/operations/rename-item.ts +++ b/src/features/workspaces/operations/rename-item.ts @@ -1,5 +1,5 @@ import { - getWorkspaceOperationContext, + getAuthorizedWorkspaceKernel, resolveWorkspaceExistingItemPath, } from "#/features/workspaces/operations/workspace-operation-context"; import type { WorkspaceAccessContext } from "#/features/workspaces/operations/workspace-access-context"; @@ -41,14 +41,17 @@ export async function renameWorkspaceItemOperation( accessContext: WorkspaceAccessContext, input: RenameWorkspaceItemOperationInput, ): Promise { - const workspaceContext = await getWorkspaceOperationContext({ + const kernel = await getAuthorizedWorkspaceKernel({ access: "mutate", context: accessContext, }); + const [pathResolution] = await kernel.resolvePaths({ paths: [input.path] }); + if (!pathResolution) { + throw new Error("Workspace kernel did not resolve the requested rename path."); + } const resolution = resolveWorkspaceExistingItemPath({ - path: input.path, + resolution: pathResolution, rootFailureCode: "cannot_rename_root", - tree: workspaceContext.tree, }); if (resolution.status === "failed") { @@ -62,7 +65,7 @@ export async function renameWorkspaceItemOperation( }; } - const outcome = await workspaceContext.kernel.renameItem({ + const outcome = await kernel.renameItem({ itemId: resolution.item.id, name: input.name, onNameConflict: "error", diff --git a/src/features/workspaces/operations/workspace-operation-context.ts b/src/features/workspaces/operations/workspace-operation-context.ts index b937e386b..4a3058b73 100644 --- a/src/features/workspaces/operations/workspace-operation-context.ts +++ b/src/features/workspaces/operations/workspace-operation-context.ts @@ -1,16 +1,9 @@ import { createDbContext } from "#/db/server"; -import type { WorkspaceItemFacts, WorkspaceItemSummary } from "#/features/workspaces/contracts"; import { getWorkspaceKernel, type WorkspaceKernelClient, } from "#/features/workspaces/kernel/workspace-kernel-access"; -import { - buildWorkspaceKernelTree, - normalizeWorkspacePath, - resolveWorkspaceKernelItemPath, - WorkspaceKernelPathError, - type WorkspaceKernelTree, -} from "#/features/workspaces/kernel/workspace-kernel-paths"; +import type { WorkspaceKernelPathResolution } from "#/features/workspaces/kernel/workspace-kernel-types"; import { assertCanMutateWorkspace, assertCanReadWorkspace, @@ -20,53 +13,12 @@ import { type WorkspaceAccessContext, } from "#/features/workspaces/operations/workspace-access-context"; -export type WorkspaceOperationAccessMode = "read" | "mutate"; - -export interface WorkspaceOperationContext { - kernel: WorkspaceKernelClient; - itemFactsById: ReadonlyMap; - pageItems: WorkspaceItemSummary[]; - tree: WorkspaceKernelTree; -} - -export type WorkspaceOperationPathResolution = - | { - code: "path_not_absolute"; - path: string; - status: "invalid_path"; - } - | { - path: string; - status: "not_found"; - } - | { - path: string; - status: "root"; - } - | { - item: WorkspaceItemSummary; - path: string; - status: "item"; - }; - -export type WorkspaceExistingItemResolution = - | { - failure: { - code: "path_not_absolute" | "path_not_found" | TRootCode; - path: string; - }; - status: "failed"; - } - | { - item: WorkspaceItemSummary; - path: string; - status: "item"; - }; +type WorkspaceOperationAccessMode = "read" | "mutate"; -export async function getWorkspaceOperationContext(input: { +export async function getAuthorizedWorkspaceKernel(input: { access: WorkspaceOperationAccessMode; context: WorkspaceAccessContext; -}): Promise { +}): Promise { const dbContext = await createDbContext(); const workspaceUser = { userId: input.context.actor.userId, @@ -82,67 +34,31 @@ export async function getWorkspaceOperationContext(input: { await assertCanMutateWorkspace(dbContext.db, workspaceUser); } - const kernel = await getWorkspaceKernel(input.context.workspaceId); - const page = await kernel.getPage(); - - return { - kernel, - itemFactsById: new Map(page.itemFacts.map((item) => [item.itemId, item])), - pageItems: page.items, - tree: buildWorkspaceKernelTree(page.items), - }; + return await getWorkspaceKernel(input.context.workspaceId); } finally { await dbContext.dispose(); } } -export function resolveWorkspaceOperationPath(input: { - path: string; - tree: WorkspaceKernelTree; -}): WorkspaceOperationPathResolution { - try { - const normalizedPath = normalizeWorkspacePath(input.path); - - if (normalizedPath === "/") { - return { - path: normalizedPath, - status: "root", - }; - } - - const item = resolveWorkspaceKernelItemPath(normalizedPath, input.tree); - - if (!item) { - return { - path: normalizedPath, - status: "not_found", - }; - } - - return { - item, - path: normalizedPath, - status: "item", - }; - } catch (error) { - if (error instanceof WorkspaceKernelPathError && error.code === "path_not_absolute") { - return { - code: error.code, - path: input.path, - status: "invalid_path", +export type WorkspaceExistingItemResolution = + | { + failure: { + code: "path_not_absolute" | "path_not_found" | TRootCode; + path: string; }; - } - - throw error; - } -} + status: "failed"; + } + | { + item: Extract["item"]; + path: string; + status: "item"; + }; export function resolveWorkspaceExistingItemPath(input: { - path: string; + resolution: WorkspaceKernelPathResolution; rootFailureCode: TRootCode; - tree: WorkspaceKernelTree; }): WorkspaceExistingItemResolution { - const resolution = resolveWorkspaceOperationPath(input); + const { resolution } = input; if (resolution.status === "invalid_path") { return { diff --git a/src/features/workspaces/operations/workspace-operation-observability.test.ts b/src/features/workspaces/operations/workspace-operation-observability.test.ts new file mode 100644 index 000000000..182ff9842 --- /dev/null +++ b/src/features/workspaces/operations/workspace-operation-observability.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("#/integrations/observability/operational-events", () => ({ + recordOperationalOutcome: vi.fn(), +})); + +import { summarizeWorkspaceReadResult } from "#/features/workspaces/operations/workspace-operation-observability"; + +describe("summarizeWorkspaceReadResult", () => { + it("tracks pending reads separately from successes and failures", () => { + expect( + summarizeWorkspaceReadResult({ + results: [ + { status: "ready" }, + { status: "pending" }, + { code: "projection_failed", status: "failed" }, + ], + }), + ).toEqual({ + failedCount: 1, + failureCodes: ["projection_failed"], + outcome: "partial", + pendingCount: 1, + succeededCount: 1, + }); + }); + + it("records pending-only reads without inflating succeeded count", () => { + expect(summarizeWorkspaceReadResult({ results: [{ status: "pending" }] })).toEqual({ + failedCount: 0, + failureCodes: [], + outcome: "success", + pendingCount: 1, + succeededCount: 0, + }); + }); +}); diff --git a/src/features/workspaces/operations/workspace-operation-observability.ts b/src/features/workspaces/operations/workspace-operation-observability.ts index f5d7de919..3b5a62db6 100644 --- a/src/features/workspaces/operations/workspace-operation-observability.ts +++ b/src/features/workspaces/operations/workspace-operation-observability.ts @@ -7,6 +7,7 @@ export interface WorkspaceOperationSummary { failureCodes: string[]; failedCount: number; outcome: WorkspaceOperationOutcome; + pendingCount: number; succeededCount: number; } @@ -54,6 +55,24 @@ export function summarizeWorkspaceCollectionResult(input: { return summarizeWorkspaceResult(input.items.length, input.failed); } +export function summarizeWorkspaceReadResult(input: { + results: ReadonlyArray<{ code?: string; status: "failed" | "pending" | "ready" }>; +}) { + const failures: Array<{ code: string }> = []; + let pendingCount = 0; + let succeededCount = 0; + for (const result of input.results) { + if (result.status === "failed") { + failures.push({ code: result.code ?? "unknown" }); + } else if (result.status === "pending") { + pendingCount += 1; + } else { + succeededCount += 1; + } + } + return summarizeWorkspaceResult(succeededCount, failures, pendingCount); +} + export function summarizeWorkspaceItemResult(input: { failed: ReadonlyArray<{ code: string }>; item?: unknown; @@ -83,7 +102,8 @@ function recordWorkspaceOperation( mutating: input.mutating, operation: input.operation, operation_id: input.context.operationId, - requested_count: summary.succeededCount + summary.failedCount, + pending_count: summary.pendingCount, + requested_count: summary.succeededCount + summary.pendingCount + summary.failedCount, succeeded_count: summary.succeededCount, user_id: input.context.actor.userId, workspace_id: input.context.workspaceId, @@ -95,11 +115,18 @@ function recordWorkspaceOperation( function summarizeWorkspaceResult( succeededCount: number, failures: ReadonlyArray<{ code: string }>, + pendingCount = 0, ): WorkspaceOperationSummary { return { failureCodes: Array.from(new Set(failures.map((failure) => failure.code))), failedCount: failures.length, - outcome: failures.length === 0 ? "success" : succeededCount === 0 ? "error" : "partial", + outcome: + failures.length === 0 + ? "success" + : succeededCount === 0 && pendingCount === 0 + ? "error" + : "partial", + pendingCount, succeededCount, }; } diff --git a/src/features/workspaces/operations/workspace-tool-definitions.ts b/src/features/workspaces/operations/workspace-tool-definitions.ts index f0306d850..9927caf44 100644 --- a/src/features/workspaces/operations/workspace-tool-definitions.ts +++ b/src/features/workspaces/operations/workspace-tool-definitions.ts @@ -45,6 +45,7 @@ import { summarizeWorkspaceAppliedResult, summarizeWorkspaceCollectionResult, summarizeWorkspaceItemResult, + summarizeWorkspaceReadResult, type WorkspaceOperationSummary, } from "#/features/workspaces/operations/workspace-operation-observability"; @@ -145,17 +146,14 @@ export const workspaceToolDefinitions = [ name: "workspace_read_items", access: "read", description: - "Read ThinkEx documents and files by absolute path. Use pages for continuation: PDF pages for PDFs, 1000-line Markdown pages for documents and extracted files. Defaults to page 1; read at most 20 pages per call and check pages.total before continuing.", + "Read ThinkEx documents and extracted files by absolute path. Documents return bounded line chunks; files support explicit physical-page selections. Continue either kind with the returned nextCursor.", inputSchema: workspaceReadItemsInputSchema, inputExamples: workspaceReadItemsInputExamples, outputSchema: workspaceReadItemsOutputSchema, - summarizeResult: summarizeWorkspaceCollectionResult, + summarizeResult: summarizeWorkspaceReadResult, effects: { destructive: false, idempotent: true }, - execute: async ({ pages, paths }, context) => { - return await readWorkspaceItemsOperation(context, { - pages, - paths, - }); + execute: async ({ requests }, context) => { + return await readWorkspaceItemsOperation(context, { requests }); }, }), defineWorkspaceTool({ @@ -225,17 +223,16 @@ export const workspaceToolDefinitions = [ defineWorkspaceTool({ name: "workspace_edit_item", access: "write", - description: `Edit one actual ThinkEx workspace document by absolute path, or add relationships from any workspace item. Read before editing unless the user requested a simple append or prepend. ${workspaceDocumentMarkdownMathInstruction}`, + description: `Edit one actual ThinkEx workspace document by absolute path. Use workspace_link_items to add relationships. Read before editing unless the user requested a simple append or prepend. ${workspaceDocumentMarkdownMathInstruction}`, inputSchema: workspaceEditItemInputSchema, inputExamples: workspaceEditItemInputExamples, outputSchema: workspaceEditItemOutputSchema, summarizeResult: summarizeWorkspaceAppliedResult, effects: { destructive: true, idempotent: false }, - execute: async ({ path, edits, relations }, context) => { + execute: async ({ path, edits }, context) => { return await editWorkspaceItemOperation(context, { path, edits, - relations, }); }, }), @@ -243,7 +240,7 @@ export const workspaceToolDefinitions = [ name: "workspace_link_items", access: "write", description: - "Create relationships from one actual ThinkEx workspace item to other workspace items by absolute path.", + "Maintain internal navigation and provenance relationships between actual ThinkEx workspace items by absolute path. Use routine relationships silently as workspace context; do not announce them as separate work unless the user asked about relationships or one materially affects the answer.", inputSchema: workspaceLinkItemsInputSchema, inputExamples: workspaceLinkItemsInputExamples, outputSchema: workspaceLinkItemsOutputSchema, diff --git a/src/features/workspaces/operations/workspace-tool-schemas.ts b/src/features/workspaces/operations/workspace-tool-schemas.ts index 5a60e0343..27991c3c6 100644 --- a/src/features/workspaces/operations/workspace-tool-schemas.ts +++ b/src/features/workspaces/operations/workspace-tool-schemas.ts @@ -1,17 +1,23 @@ import { z } from "zod"; +import { + workspaceReadItemsInputSchema, + workspaceReadItemsOutputSchema, +} from "#/features/workspaces/content/workspace-content-contract"; import { createWorkspaceItemsFailureCodes } from "#/features/workspaces/operations/create-items"; import { deleteWorkspaceItemsFailureCodes } from "#/features/workspaces/operations/delete-items"; import { editWorkspaceItemFailureCodes } from "#/features/workspaces/operations/edit-item"; import { linkWorkspaceItemsFailureCodes } from "#/features/workspaces/operations/link-items"; import { moveWorkspaceItemsFailureCodes } from "#/features/workspaces/operations/move-items"; -import { readWorkspaceItemsFailureCodes } from "#/features/workspaces/operations/read-items"; import { renameWorkspaceItemFailureCodes } from "#/features/workspaces/operations/rename-item"; import { workspaceItemTypeSchema, workspaceRelationKindSchema, } from "#/features/workspaces/contracts"; import { documentMarkdownEditSchema } from "#/features/workspaces/documents/document-markdown-edits"; +import { workspaceFileAssetKindSchema } from "#/features/workspaces/model/workspace-file"; + +export { workspaceReadItemsInputSchema, workspaceReadItemsOutputSchema }; export const workspaceDocumentMarkdownMathInstruction = "For document Markdown math, use `$...$` for inline math and `$$...$$` on separate lines for block math. Escape literal currency dollar signs as `\\$`."; @@ -48,7 +54,7 @@ const workspaceListItemSchema = z.object({ pageCount: z.number().int().positive().optional(), path: workspacePathSchema, relationshipCount: z.number().int().nonnegative(), - type: z.enum(["folder", "document", "pdf", "image", "file", "flashcard", "quiz"]), + type: z.union([workspaceItemTypeSchema, workspaceFileAssetKindSchema]), }); const workspacePreviousPathItemSchema = workspacePathItemSchema.extend({ @@ -65,12 +71,6 @@ function createWorkspaceItemsResultSchema< }); } -export const workspaceReadPagesSchema = z.object({ - requested: z.string().describe("Requested page range."), - returned: z.array(z.number().int().min(1)).describe("Page numbers included in content."), - total: z.number().int().min(1).describe("Total pages available."), -}); - const workspaceRelationInputSchema = z.object({ kind: workspaceRelationKindSchema.describe( "`derived_from` means this item was created or materially changed from the linked item. `references` means this item cites or points to the linked item.", @@ -84,15 +84,6 @@ const workspaceRelationInputSchema = z.object({ path: z.string().min(1).describe("Absolute path of the related ThinkEx workspace item."), }); -export const workspacePageRangeSchema = z - .string() - .trim() - .min(1) - .regex(/^\d+(?:\s*-\s*\d+)?(?:\s*,\s*\d+(?:\s*-\s*\d+)?)*$/) - .describe( - "Up to 20 1-based pages to read, like 1, 3, 5-7, or 1,4-6. For PDFs, pages are PDF pages. For Markdown-backed items, each page is 1000 Markdown lines. Defaults to 1.", - ); - export const workspaceListItemsInputSchema = z.object({ offset: z .number() @@ -118,44 +109,17 @@ export const workspaceListItemsInputSchema = z.object({ .describe("Include nested descendants. Defaults to false for immediate children only."), }); -export const workspaceReadItemsInputSchema = z.object({ - pages: workspacePageRangeSchema.optional(), - paths: z - .array(z.string().min(1)) +export const workspaceEditItemInputSchema = z.object({ + path: z.string().min(1).describe("Absolute path of one actual ThinkEx workspace item to edit."), + edits: z + .array(documentMarkdownEditSchema) .min(1) - .max(20) - .describe("Absolute paths in the actual ThinkEx workspace to read."), + .max(40) + .describe( + `Ordered text edits to apply to a document projection. ${workspaceDocumentMarkdownMathInstruction}`, + ), }); -export const workspaceEditItemInputSchema = z - .object({ - path: z.string().min(1).describe("Absolute path of one actual ThinkEx workspace item to edit."), - relations: z - .array(workspaceRelationInputSchema) - .max(20) - .optional() - .describe("Optional relationships from this item to other workspace items."), - edits: z - .array(documentMarkdownEditSchema) - .min(1) - .max(40) - .optional() - .describe( - `Ordered text edits to apply to a document projection. ${workspaceDocumentMarkdownMathInstruction}`, - ), - }) - .superRefine((input, ctx) => { - if ((input.edits?.length ?? 0) > 0 || (input.relations?.length ?? 0) > 0) { - return; - } - - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Provide edits or relations.", - path: ["edits"], - }); - }); - export const workspaceLinkItemsInputSchema = z.object({ path: z.string().min(1).describe("Absolute path of the workspace item to link from."), relations: z @@ -239,12 +203,16 @@ export const workspaceReadItemsInputExamples = createInputExamples< z.input >( { - paths: ["/Demo Folder/Demo Document"], - pages: "1", + requests: [{ mode: "start", path: "/Demo Folder/Demo Document" }], }, { - paths: ["/Demo Folder/Demo PDF.pdf"], - pages: "1-3", + requests: [ + { + mode: "pages", + path: "/Demo Folder/Demo PDF.pdf", + range: "1-3", + }, + ], }, ); @@ -295,13 +263,6 @@ export const workspaceEditItemInputExamples = createInputExamples< z.input >({ path: "/Demo Folder/Demo Document", - relations: [ - { - kind: "references", - path: "/Demo Folder/Demo PDF.pdf", - note: "Source section used for the update.", - }, - ], edits: [ { type: "overwrite", @@ -335,27 +296,6 @@ export const workspaceListItemsOutputSchema = z.object({ ), }); -export const workspaceReadItemsOutputSchema = createWorkspaceItemsResultSchema({ - itemSchema: z.object({ - path: workspacePathSchema, - type: z.enum(["document", "file", "flashcard", "quiz"]), - status: z.enum(["failed", "pending", "ready", "unsupported"]), - content: z.string().optional(), - pages: workspaceReadPagesSchema.optional(), - relations: z - .array( - z.object({ - direction: z.enum(["incoming", "outgoing"]), - kind: workspaceRelationKindSchema, - note: z.string().optional(), - path: workspacePathSchema, - }), - ) - .optional(), - }), - failureSchema: createFailureSchema(readWorkspaceItemsFailureCodes), -}); - export const workspaceCreateItemsOutputSchema = createWorkspaceItemsResultSchema({ itemSchema: z.object({ path: workspacePathSchema, diff --git a/src/features/workspaces/realtime/messages.ts b/src/features/workspaces/realtime/messages.ts index cd60c7041..8e6553db2 100644 --- a/src/features/workspaces/realtime/messages.ts +++ b/src/features/workspaces/realtime/messages.ts @@ -1,56 +1,92 @@ -import type { WorkspaceItemSummary } from "#/features/workspaces/contracts"; +import { z } from "zod"; +import { + workspaceItemFactsSchema, + workspaceItemSummarySchema, +} from "#/features/workspaces/contracts"; -export interface WorkspacePresenceUser { - id: string; - connectionId: string; - name: string; - image: string | null; -} +const workspacePresenceUserSchema = z.object({ + id: z.string(), + connectionId: z.string(), + name: z.string(), + image: z.string().nullable(), +}); -interface WorkspaceRealtimeEventBase { - id: string; - revision: number; - workspaceId: string; - createdAt: string; - actorUserId: string | null; - clientMutationId: string | null; -} +const workspaceRealtimeEventBase = { + id: z.string(), + revision: z.number().int().nonnegative(), + workspaceId: z.string(), + createdAt: z.string(), + actorUserId: z.string().nullable(), + clientMutationId: z.string().nullable(), +}; + +const workspaceRealtimeEventSchema = z.discriminatedUnion("type", [ + z.object({ + ...workspaceRealtimeEventBase, + type: z.literal("workspace.item.created"), + payload: z.object({ + item: workspaceItemSummarySchema, + itemFacts: z.array(workspaceItemFactsSchema), + }), + }), + z.object({ + ...workspaceRealtimeEventBase, + type: z.enum([ + "workspace.item.renamed", + "workspace.item.moved", + "workspace.item.color.updated", + "workspace.item.content.updated", + ]), + payload: z.object({ item: workspaceItemSummarySchema }), + }), + z.object({ + ...workspaceRealtimeEventBase, + type: z.literal("workspace.items.moved"), + payload: z.object({ items: z.array(workspaceItemSummarySchema) }), + }), + z.object({ + ...workspaceRealtimeEventBase, + type: z.literal("workspace.item.deleted"), + payload: z.object({ + itemIds: z.array(z.string()), + deletedItemIds: z.array(z.string()), + itemFacts: z.array(workspaceItemFactsSchema), + }), + }), + z.object({ + ...workspaceRealtimeEventBase, + type: z.enum(["workspace.relations.updated", "workspace.item.projection.updated"]), + payload: z.object({ itemFacts: z.array(workspaceItemFactsSchema) }), + }), +]); -export type WorkspaceRealtimeEvent = - | (WorkspaceRealtimeEventBase & { - type: - | "workspace.item.created" - | "workspace.item.renamed" - | "workspace.item.moved" - | "workspace.item.color.updated" - | "workspace.item.content.updated"; - payload: { item: WorkspaceItemSummary }; - }) - | (WorkspaceRealtimeEventBase & { - type: "workspace.items.moved"; - payload: { items: WorkspaceItemSummary[] }; - }) - | (WorkspaceRealtimeEventBase & { - type: "workspace.item.deleted"; - payload: { itemIds: string[]; deletedItemIds: string[] }; - }); +const workspaceRealtimeServerMessageSchema = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("presence.snapshot"), + workspaceId: z.string(), + users: z.array(workspacePresenceUserSchema), + }), + z.object({ + type: z.literal("workspace.event"), + workspaceId: z.string(), + event: workspaceRealtimeEventSchema, + }), +]); + +export type WorkspacePresenceUser = z.infer; +export type WorkspaceRealtimeEvent = z.infer; export interface WorkspaceCommandResult { result: T; event: WorkspaceRealtimeEvent; } -export type WorkspaceRealtimeServerMessage = - | { - type: "presence.snapshot"; - workspaceId: string; - users: WorkspacePresenceUser[]; - } - | { - type: "workspace.event"; - workspaceId: string; - event: WorkspaceRealtimeEvent; - }; +export type WorkspaceRealtimeServerMessage = z.infer; + +export function parseWorkspaceRealtimeServerMessage(value: unknown) { + const parsed = workspaceRealtimeServerMessageSchema.safeParse(value); + return parsed.success ? parsed.data : null; +} export interface WorkspaceConnectionState { user: Omit; diff --git a/src/features/workspaces/realtime/use-workspace-presence.ts b/src/features/workspaces/realtime/use-workspace-presence.ts index da1a2d678..a4f95402b 100644 --- a/src/features/workspaces/realtime/use-workspace-presence.ts +++ b/src/features/workspaces/realtime/use-workspace-presence.ts @@ -6,10 +6,10 @@ import { workspaceKernelAgentName, workspaceKernelBasePath, } from "#/features/workspaces/agent-routes"; -import type { - WorkspacePresenceUser, - WorkspaceRealtimeEvent, - WorkspaceRealtimeServerMessage, +import { + parseWorkspaceRealtimeServerMessage, + type WorkspacePresenceUser, + type WorkspaceRealtimeEvent, } from "./messages"; type ConnectionStatus = "connecting" | "connected" | "disconnected"; @@ -24,8 +24,7 @@ interface UseWorkspaceRealtimeInput { workspaceId: string; lastSeenRevision?: number; onEvent?: (event: WorkspaceRealtimeEvent) => void; - onReconnect?: () => void; - onRevisionGap?: (event: WorkspaceRealtimeEvent) => void; + onDesync?: () => void; } function parseServerMessage(data: unknown) { @@ -34,7 +33,7 @@ function parseServerMessage(data: unknown) { } try { - return JSON.parse(data) as WorkspaceRealtimeServerMessage; + return parseWorkspaceRealtimeServerMessage(JSON.parse(data)); } catch { return null; } @@ -52,8 +51,7 @@ export function useWorkspaceRealtime({ workspaceId, lastSeenRevision, onEvent, - onReconnect, - onRevisionGap, + onDesync, }: UseWorkspaceRealtimeInput) { const [presence, setPresence] = useState(() => getInitialPresenceState(workspaceId)); const hasConnectedRef = useRef(false); @@ -62,13 +60,11 @@ export function useWorkspaceRealtime({ const latestRevisionInputRef = useRef(lastSeenRevision ?? 0); const revisionWorkspaceRef = useRef(workspaceId); const onEventRef = useRef(onEvent); - const onReconnectRef = useRef(onReconnect); - const onRevisionGapRef = useRef(onRevisionGap); + const onDesyncRef = useRef(onDesync); useEffect(() => { onEventRef.current = onEvent; - onReconnectRef.current = onReconnect; - onRevisionGapRef.current = onRevisionGap; + onDesyncRef.current = onDesync; }); let currentPresence = presence; @@ -106,7 +102,7 @@ export function useWorkspaceRealtime({ })); if (hasConnectedRef.current) { - onReconnectRef.current?.(); + onDesyncRef.current?.(); } hasConnectedRef.current = true; @@ -131,6 +127,9 @@ export function useWorkspaceRealtime({ const handleMessage = useCallback( (event: MessageEvent) => { const message = parseServerMessage(event.data); + if (!message) { + return; + } if (message?.type === "presence.snapshot" && message.workspaceId === workspaceId) { setPresence((current) => ({ @@ -144,7 +143,7 @@ export function useWorkspaceRealtime({ const lastSeenRevision = lastSeenRevisionRef.current; if (lastSeenRevision > 0 && message.event.revision > lastSeenRevision + 1) { - onRevisionGapRef.current?.(message.event); + onDesyncRef.current?.(); lastSeenRevisionRef.current = message.event.revision; return; } diff --git a/src/features/workspaces/upload/workspace-file-direct-upload.ts b/src/features/workspaces/upload/workspace-file-direct-upload.ts index 9586dcbca..fd252a9a8 100644 --- a/src/features/workspaces/upload/workspace-file-direct-upload.ts +++ b/src/features/workspaces/upload/workspace-file-direct-upload.ts @@ -7,11 +7,11 @@ import { getWorkspaceFileUploadObjectKey, } from "#/features/workspaces/files/workspace-file-object-keys"; import type { WorkspaceDirectUploadTarget } from "#/features/workspaces/upload/workspace-upload-intake"; +import { decodeBase64Url, decodeBase64UrlText, encodeBase64Url } from "#/lib/binary"; const uploadUrlLifetimeSeconds = 30 * 60; const uploadTokenVersion = 2; const encoder = new TextEncoder(); -const decoder = new TextDecoder(); const uploadClaimsSchema = z.object({ clientMutationId: z.string().min(1), contentType: z.string().min(1), @@ -145,24 +145,3 @@ function createSigningKey(secret: string, usages: KeyUsage[]) { usages, ); } - -function encodeBase64Url(bytes: Uint8Array) { - let binary = ""; - - for (const byte of bytes) { - binary += String.fromCharCode(byte); - } - - return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, ""); -} - -function decodeBase64Url(value: string) { - const base64 = value.replaceAll("-", "+").replaceAll("_", "/"); - const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "="); - const binary = atob(padded); - return Uint8Array.from(binary, (character) => character.charCodeAt(0)); -} - -function decodeBase64UrlText(value: string) { - return decoder.decode(decodeBase64Url(value)); -} diff --git a/src/features/workspaces/use-create-workspace.ts b/src/features/workspaces/use-create-workspace.ts index 065820e38..464aa6fca 100644 --- a/src/features/workspaces/use-create-workspace.ts +++ b/src/features/workspaces/use-create-workspace.ts @@ -56,7 +56,12 @@ export function useCreateWorkspaceMutation() { }; upsertWorkspaceInList(queryClient, optimisticWorkspace); - setWorkspacePageCache(queryClient, { workspace: optimisticWorkspace, items: [] }); + setWorkspacePageCache(queryClient, { + workspace: optimisticWorkspace, + items: [], + itemFacts: [], + revision: 0, + }); markWorkspaceCreatedThisSession(id); void navigate({ diff --git a/src/features/workspaces/workspace-item-registry.ts b/src/features/workspaces/workspace-item-registry.ts new file mode 100644 index 000000000..9227211a8 --- /dev/null +++ b/src/features/workspaces/workspace-item-registry.ts @@ -0,0 +1,67 @@ +import { z } from "zod"; + +const workspaceItemTypes = ["folder", "document", "file", "flashcard", "quiz"] as const; +export const workspaceItemTypeSchema = z.enum(workspaceItemTypes); +export type WorkspaceItemType = z.infer; + +interface WorkspaceItemRegistryEntry { + color: "amber" | "emerald" | "rose" | "sky" | "violet"; + contentKind: "document" | "empty" | "flashcard" | "quiz"; + defaultName: string; + extension: "json" | "txt" | null; + label: string; + menuLabel: string; + mimeType: "application/json" | "inode/directory" | "text/plain"; +} + +const workspaceItemRegistry = { + folder: { + color: "amber", + contentKind: "empty", + defaultName: "New folder", + extension: null, + label: "Folder", + menuLabel: "Folder", + mimeType: "inode/directory", + }, + document: { + color: "sky", + contentKind: "document", + defaultName: "New document", + extension: "json", + label: "Document", + menuLabel: "Document", + mimeType: "application/json", + }, + file: { + color: "rose", + contentKind: "empty", + defaultName: "New file", + extension: "txt", + label: "File", + menuLabel: "Upload file", + mimeType: "text/plain", + }, + flashcard: { + color: "violet", + contentKind: "flashcard", + defaultName: "New flashcards", + extension: "json", + label: "Flashcard deck", + menuLabel: "Flashcards", + mimeType: "application/json", + }, + quiz: { + color: "emerald", + contentKind: "quiz", + defaultName: "New quiz", + extension: "json", + label: "Quiz", + menuLabel: "Quiz", + mimeType: "application/json", + }, +} satisfies Record; + +export function getWorkspaceItemRegistryEntry(type: WorkspaceItemType) { + return workspaceItemRegistry[type]; +} diff --git a/src/lib/binary.ts b/src/lib/binary.ts new file mode 100644 index 000000000..f04ffe7b9 --- /dev/null +++ b/src/lib/binary.ts @@ -0,0 +1,39 @@ +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +export function toArrayBuffer(bytes: Uint8Array) { + const copy = new Uint8Array(bytes.byteLength); + copy.set(bytes); + return copy.buffer; +} + +export function encodeBase64Url(bytes: Uint8Array) { + let binary = ""; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, ""); +} + +export function decodeBase64Url(value: string) { + const base64 = value.replaceAll("-", "+").replaceAll("_", "/"); + const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "="); + return Uint8Array.from(atob(padded), (character) => character.charCodeAt(0)); +} + +export function encodeBase64UrlText(value: string) { + return encodeBase64Url(textEncoder.encode(value)); +} + +export function decodeBase64UrlText(value: string) { + return textDecoder.decode(decodeBase64Url(value)); +} + +export async function sha256Base64Url(bytes: Uint8Array) { + const digest = await crypto.subtle.digest("SHA-256", toArrayBuffer(bytes)); + return encodeBase64Url(new Uint8Array(digest)); +} + +export async function sha256Base64UrlText(value: string) { + return sha256Base64Url(textEncoder.encode(value)); +}