From 075f09cd718b40b3f7b899473fa330c142b5c102 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Fri, 17 Jul 2026 21:55:23 -0400 Subject: [PATCH 01/16] refactor(workspaces): make kernel the canonical item boundary --- src/features/workspaces/cache-workspace.ts | 12 +- .../components/WorkspaceCreateMenu.tsx | 35 ++--- src/features/workspaces/contracts.ts | 2 +- src/features/workspaces/defaults.ts | 8 -- .../workspaces/document-session-access.ts | 1 + .../documents/document-item-content.ts | 4 +- .../workspaces/documents/document-session.ts | 25 ++-- .../kernel/workspace-kernel-access.ts | 24 +++- .../kernel/workspace-kernel-events.test.ts | 2 +- .../kernel/workspace-kernel-file-commands.ts | 21 ++- .../kernel/workspace-kernel-files.ts | 12 +- .../kernel/workspace-kernel-item-commands.ts | 47 ++++--- .../kernel/workspace-kernel-relations.ts | 21 +++ .../kernel/workspace-kernel-store.ts | 17 --- .../kernel/workspace-kernel-types.ts | 47 ++++++- .../workspaces/kernel/workspace-kernel.ts | 95 ++++++++++++-- src/features/workspaces/model/item-display.ts | 65 +++------- .../workspaces/model/object-registry.ts | 37 +----- .../workspaces/model/workspace-item-colors.ts | 2 - .../workspaces/model/workspace-page.test.ts | 36 ++++++ .../workspaces/model/workspace-page.ts | 33 ++++- .../workspaces/operations/create-items.ts | 78 +++--------- .../workspaces/operations/delete-items.ts | 12 +- .../workspaces/operations/edit-item.ts | 64 ++-------- .../workspaces/operations/link-items.ts | 21 ++- .../workspaces/operations/list-items.ts | 13 +- .../workspaces/operations/move-items.ts | 27 ++-- .../workspaces/operations/relations.ts | 112 +++++++--------- .../workspaces/operations/rename-item.ts | 13 +- .../operations/workspace-operation-context.ts | 120 +++--------------- src/features/workspaces/realtime/messages.ts | 17 ++- .../workspaces/use-create-workspace.ts | 7 +- 32 files changed, 500 insertions(+), 530 deletions(-) create mode 100644 src/features/workspaces/model/workspace-page.test.ts 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/WorkspaceCreateMenu.tsx b/src/features/workspaces/components/WorkspaceCreateMenu.tsx index d86a9ea10..86aefb0e4 100644 --- a/src/features/workspaces/components/WorkspaceCreateMenu.tsx +++ b/src/features/workspaces/components/WorkspaceCreateMenu.tsx @@ -23,8 +23,7 @@ import { } from "#/features/workspaces/components/workspace-viewer-ui"; import type { WorkspaceItemType } from "#/features/workspaces/contracts"; import { - workspaceItemAcquisitionActions, - workspaceItemLearnCreateActions, + workspaceFileUploadAction, workspaceItemPrimaryCreateActions, } from "#/features/workspaces/model/item-display"; @@ -97,6 +96,13 @@ function getWorkspaceCreateMenuActions({ }: WorkspaceCreateMenuProps & { onUploadFile: (parentId: string | null) => void; }) { + const { + id: uploadActionId, + label: uploadActionLabel, + Icon: UploadIcon, + iconClassName: uploadIconClassName, + } = workspaceFileUploadAction; + return [ ...workspaceItemPrimaryCreateActions.map(({ type, label, Icon, iconClassName }) => ({ kind: "item" as const, @@ -105,25 +111,12 @@ function getWorkspaceCreateMenuActions({ leading: , onSelect: () => onCreateItem({ type, parentId }), })), - ...workspaceItemAcquisitionActions.map( - ({ id, label, description, Icon, iconClassName, disabled }) => ({ - kind: "item" as const, - id, - label, - trailing: description, - disabled, - leading: , - onSelect: id === "upload-file" ? () => onUploadFile(parentId) : undefined, - }), - ), - ...workspaceItemLearnCreateActions.map(({ type, label, Icon, iconClassName }) => ({ + { kind: "item" as const, - id: type, - label, - trailing: "Soon", - disabled: true, - leading: , - onSelect: () => onCreateItem({ type, parentId }), - })), + id: uploadActionId, + label: uploadActionLabel, + leading: , + onSelect: () => onUploadFile(parentId), + }, ]; } diff --git a/src/features/workspaces/contracts.ts b/src/features/workspaces/contracts.ts index 901955304..d6bda7afe 100644 --- a/src/features/workspaces/contracts.ts +++ b/src/features/workspaces/contracts.ts @@ -190,7 +190,7 @@ export const workspaceSummarySchema = z.object({ membershipRole: workspaceMembershipRoleSchema, }); -export const workspaceItemTypeSchema = z.enum(["folder", "document", "file", "flashcard", "quiz"]); +export const workspaceItemTypeSchema = z.enum(["folder", "document", "file"]); export const workspaceItemFactsSchema = z.object({ itemId: z.string(), diff --git a/src/features/workspaces/defaults.ts b/src/features/workspaces/defaults.ts index f2b760227..0ce98222e 100644 --- a/src/features/workspaces/defaults.ts +++ b/src/features/workspaces/defaults.ts @@ -17,10 +17,6 @@ export function getDefaultWorkspaceItemName(type: WorkspaceItemType) { return "New document"; case "file": return "New file"; - case "flashcard": - return "New flashcards"; - case "quiz": - return "New quiz"; } } @@ -32,10 +28,6 @@ export function getWorkspaceItemTypeMeta(type: WorkspaceItemType) { return "Document"; case "file": return "File"; - case "flashcard": - return "Flashcards"; - case "quiz": - return "Quiz"; } } diff --git a/src/features/workspaces/document-session-access.ts b/src/features/workspaces/document-session-access.ts index d4561ebf6..d3524166e 100644 --- a/src/features/workspaces/document-session-access.ts +++ b/src/features/workspaces/document-session-access.ts @@ -6,6 +6,7 @@ export interface DocumentSessionClient { applyMarkdownEdits(input: { edits: DocumentMarkdownEdit[]; }): Promise; + readMarkdown(): Promise<{ markdown: string; revision: string }>; purgeForDeletion(): Promise; } 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-session.ts b/src/features/workspaces/documents/document-session.ts index bd0a76c60..1ed741344 100644 --- a/src/features/workspaces/documents/document-session.ts +++ b/src/features/workspaces/documents/document-session.ts @@ -105,21 +105,15 @@ 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); - if (persistedUpdate) { Y.applyUpdate(this.document, persistedUpdate, this); return; } + const room = getDocumentSessionRoomNameParts(this.name); + const kernel = await this.getWorkspaceKernel(room.workspaceId); + const { content } = await kernel.readDocumentCheckpoint({ itemId: room.itemId }); const snapshot = parseTiptapDocumentJson(content); const seededDoc = prosemirrorJSONToYDoc( getTiptapDocumentSchema(), @@ -182,6 +176,17 @@ export class DocumentSession extends YServer { }; } + async readMarkdown() { + const stateVector = Uint8Array.from(Y.encodeStateVector(this.document)); + const revisionBytes = await crypto.subtle.digest("SHA-256", stateVector.buffer); + return { + markdown: serializeTiptapDocumentToMarkdown(this.getCurrentTiptapDocument()), + revision: Array.from(new Uint8Array(revisionBytes), (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""), + }; + } + async purgeForDeletion(): Promise { await this.ctx.storage.deleteAll(); } @@ -193,7 +198,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, diff --git a/src/features/workspaces/kernel/workspace-kernel-access.ts b/src/features/workspaces/kernel/workspace-kernel-access.ts index 739497d38..527f4c48d 100644 --- a/src/features/workspaces/kernel/workspace-kernel-access.ts +++ b/src/features/workspaces/kernel/workspace-kernel-access.ts @@ -17,17 +17,24 @@ import { type CreateWorkspaceKernelFileFromUploadArgs, type CreateWorkspaceKernelRelationArgs, 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,7 +55,12 @@ 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; @@ -96,16 +108,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 index d86a442fd..43dc58078 100644 --- a/src/features/workspaces/kernel/workspace-kernel-events.test.ts +++ b/src/features/workspaces/kernel/workspace-kernel-events.test.ts @@ -48,7 +48,7 @@ function createItemEventRow(itemId: string): KernelEventRow { client_mutation_id: "mutation-1", created_at: Date.parse("2026-07-15T00:00:00Z"), id: "event-1", - payload_json: JSON.stringify({ item: { id: itemId } }), + payload_json: JSON.stringify({ item: { id: itemId }, itemFacts: [] }), revision: 1, type: "workspace.item.created", }; diff --git a/src/features/workspaces/kernel/workspace-kernel-file-commands.ts b/src/features/workspaces/kernel/workspace-kernel-file-commands.ts index 87b03a016..f04957684 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"; @@ -194,11 +198,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 +269,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 +300,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..4c815eebe 100644 --- a/src/features/workspaces/kernel/workspace-kernel-files.ts +++ b/src/features/workspaces/kernel/workspace-kernel-files.ts @@ -20,9 +20,6 @@ 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": @@ -30,14 +27,10 @@ export function getWorkspaceKernelContentMimeType(type: WorkspaceItemType) { } } -export function getInitialWorkspaceKernelContent(type: WorkspaceItemType, _name: string) { +export function getInitialWorkspaceKernelContent(type: WorkspaceItemType) { switch (type) { 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 ""; @@ -48,9 +41,6 @@ function getContentExtension(type: WorkspaceItemType) { switch (type) { case "document": return "json"; - case "flashcard": - case "quiz": - return "json"; case "file": return "txt"; case "folder": diff --git a/src/features/workspaces/kernel/workspace-kernel-item-commands.ts b/src/features/workspaces/kernel/workspace-kernel-item-commands.ts index 5d531c0bd..96e298c44 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 { @@ -127,14 +127,12 @@ 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, }); @@ -177,11 +175,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 +309,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 +347,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 +403,6 @@ export class WorkspaceKernelItemCommands { private async createWorkspaceFile(input: { type: WorkspaceItemSummary["type"]; - name: string; shellPath: string; initialContent?: string; }) { @@ -406,7 +413,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-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-store.ts b/src/features/workspaces/kernel/workspace-kernel-store.ts index 690f1a024..09c1cb491 100644 --- a/src/features/workspaces/kernel/workspace-kernel-store.ts +++ b/src/features/workspaces/kernel/workspace-kernel-store.ts @@ -84,23 +84,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..ee3ecbada 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"; @@ -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; diff --git a/src/features/workspaces/kernel/workspace-kernel.ts b/src/features/workspaces/kernel/workspace-kernel.ts index 5c9a52dae..e4ba5f160 100644 --- a/src/features/workspaces/kernel/workspace-kernel.ts +++ b/src/features/workspaces/kernel/workspace-kernel.ts @@ -17,27 +17,41 @@ 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, + GetWorkspaceKernelItemPathsArgs, ListWorkspaceKernelEventsArgs, 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 { @@ -126,17 +140,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({ + tree: buildWorkspaceKernelTree(items), + itemFactsById: new Map(this.store.getItemFacts(items).map((facts) => [facts.itemId, facts])), + ...input, + }); } - async createRelations(input: { relations: CreateWorkspaceKernelRelationArgs[] }) { + 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 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 +232,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 +273,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( diff --git a/src/features/workspaces/model/item-display.ts b/src/features/workspaces/model/item-display.ts index fa61a3be6..25db07ecd 100644 --- a/src/features/workspaces/model/item-display.ts +++ b/src/features/workspaces/model/item-display.ts @@ -1,9 +1,6 @@ -import { type LucideIcon, Mic, Upload } from "lucide-react"; +import { 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"; @@ -26,55 +23,23 @@ 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; 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; + const display = getWorkspaceObjectRegistryEntry(type); + return { + type, + label: display.menuLabel, + Icon: display.icon, + iconClassName: workspaceColors[workspaceItemTypeColors[type]].iconClassName, + }; }, ); -export const workspaceItemLearnCreateActions = workspaceItemCreateActions.filter( - (action) => action.group === "learn", -); - -export interface WorkspaceItemAcquisitionAction { - id: "upload-file" | "record-audio"; - label: string; - description?: string; - Icon: LucideIcon; - iconClassName: string; - disabled: boolean; -} - -export const workspaceItemAcquisitionActions: WorkspaceItemAcquisitionAction[] = [ - { - id: "upload-file", - label: "Upload", - Icon: Upload, - iconClassName: workspaceColors[workspaceItemTypeColors.file].iconClassName, - disabled: false, - }, - { - id: "record-audio", - label: "Record", - description: "Soon", - Icon: Mic, - iconClassName: workspaceColors.orange.iconClassName, - disabled: true, - }, -]; +export const workspaceFileUploadAction = { + id: "upload-file", + label: "Upload", + Icon: Upload, + iconClassName: workspaceColors[workspaceItemTypeColors.file].iconClassName, +}; diff --git a/src/features/workspaces/model/object-registry.ts b/src/features/workspaces/model/object-registry.ts index 2daf4021d..11b8b7c9d 100644 --- a/src/features/workspaces/model/object-registry.ts +++ b/src/features/workspaces/model/object-registry.ts @@ -1,14 +1,10 @@ -import { FilePen, Folder, Layers3, ListChecks, type LucideIcon, Paperclip } from "lucide-react"; +import { FilePen, Folder, type LucideIcon, Paperclip } from "lucide-react"; import type { WorkspaceItemType } from "#/features/workspaces/contracts"; -export type WorkspaceItemCreateGroup = "primary" | "learn"; - -export interface WorkspaceItemRegistryEntry { +interface WorkspaceItemRegistryEntry { type: WorkspaceItemType; label: string; menuLabel: string; - menuGroup: WorkspaceItemCreateGroup; - creatable: boolean; icon: LucideIcon; } @@ -17,51 +13,22 @@ export const workspaceObjectRegistry = { 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; export function getWorkspaceObjectRegistryEntry(type: WorkspaceItemType) { return workspaceObjectRegistry[type]; } - -export const workspaceObjectRegistryEntries: WorkspaceItemRegistryEntry[] = - Object.values(workspaceObjectRegistry); - -export const creatableWorkspaceObjectEntries = workspaceObjectRegistryEntries.filter( - (entry) => entry.creatable, -); diff --git a/src/features/workspaces/model/workspace-item-colors.ts b/src/features/workspaces/model/workspace-item-colors.ts index 7b2a1d015..427b66a77 100644 --- a/src/features/workspaces/model/workspace-item-colors.ts +++ b/src/features/workspaces/model/workspace-item-colors.ts @@ -12,9 +12,7 @@ import { export const workspaceItemTypeColors = { document: "sky", file: "rose", - flashcard: "violet", folder: "amber", - quiz: "emerald", } as const satisfies Record; export const workspaceItemColorOptions = workspaceColorOptions; 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..297264cc7 100644 --- a/src/features/workspaces/model/workspace-page.ts +++ b/src/features/workspaces/model/workspace-page.ts @@ -20,6 +20,11 @@ export function applyWorkspaceEventToPage( ): WorkspacePage { 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 +33,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 +76,6 @@ export function createWorkspaceItemInPage( const { metadataJson } = buildWorkspaceItemCreateBootstrap({ type: input.type, - name, initialContent: input.initialContent, }); @@ -196,6 +224,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..f0c7be060 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,13 +74,12 @@ 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(); for (const [index, itemInput] of input.items.entries()) { const id = crypto.randomUUID(); @@ -99,11 +94,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 +123,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 +137,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 +173,6 @@ export async function createWorkspaceItemsOperation( ? { warnings: initialContent.warnings } : {}), }); - createdItemsByPath.set(createdPath, { - id: command.result.id, - type: command.result.type, - }); } return { @@ -189,11 +181,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 +191,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 +218,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/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..cbeb387a6 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, @@ -22,51 +15,10 @@ import { 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"; - }; - -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/realtime/messages.ts b/src/features/workspaces/realtime/messages.ts index cd60c7041..de507ae8f 100644 --- a/src/features/workspaces/realtime/messages.ts +++ b/src/features/workspaces/realtime/messages.ts @@ -1,4 +1,4 @@ -import type { WorkspaceItemSummary } from "#/features/workspaces/contracts"; +import type { WorkspaceItemFacts, WorkspaceItemSummary } from "#/features/workspaces/contracts"; export interface WorkspacePresenceUser { id: string; @@ -17,9 +17,12 @@ interface WorkspaceRealtimeEventBase { } export type WorkspaceRealtimeEvent = + | (WorkspaceRealtimeEventBase & { + type: "workspace.item.created"; + payload: { item: WorkspaceItemSummary; itemFacts: WorkspaceItemFacts[] }; + }) | (WorkspaceRealtimeEventBase & { type: - | "workspace.item.created" | "workspace.item.renamed" | "workspace.item.moved" | "workspace.item.color.updated" @@ -32,7 +35,15 @@ export type WorkspaceRealtimeEvent = }) | (WorkspaceRealtimeEventBase & { type: "workspace.item.deleted"; - payload: { itemIds: string[]; deletedItemIds: string[] }; + payload: { + itemIds: string[]; + deletedItemIds: string[]; + itemFacts: WorkspaceItemFacts[]; + }; + }) + | (WorkspaceRealtimeEventBase & { + type: "workspace.relations.updated" | "workspace.item.projection.updated"; + payload: { itemFacts: WorkspaceItemFacts[] }; }); export interface WorkspaceCommandResult { 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({ From eca0329c5dda4b2f061d9213b650a624122cd676 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Fri, 17 Jul 2026 21:55:28 -0400 Subject: [PATCH 02/16] fix(workspaces): bound projection page reads --- .../workspace-page-projection.test.ts | 118 +++++++++++++++++- .../extraction/workspace-page-projection.ts | 108 +++++++++++++--- 2 files changed, 203 insertions(+), 23 deletions(-) diff --git a/src/features/workspaces/extraction/workspace-page-projection.test.ts b/src/features/workspaces/extraction/workspace-page-projection.test.ts index d3edae9b1..29195747f 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,98 @@ 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 v2 selections before opening page objects", 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("rejects obsolete projection manifests", 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, + }), + ).rejects.toThrow("manifest is invalid"); + }); + it("removes partial artifacts when publication fails", async () => { const storage = createObjectStorage(); @@ -125,6 +213,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 +227,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 +262,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..243fb8a7f 100644 --- a/src/features/workspaces/extraction/workspace-page-projection.ts +++ b/src/features/workspaces/extraction/workspace-page-projection.ts @@ -8,7 +8,7 @@ import { } from "#/features/workspaces/read-page-selection"; import { deleteR2Prefix } from "#/lib/r2"; -const projectionSchemaVersion = 1; +const projectionSchemaVersion = 2; const pageNumberWidth = 6; const pageWriteConcurrency = 8; const maxPageMarkdownBytes = 1024 * 1024; @@ -21,6 +21,7 @@ export interface WorkspacePageProjectionManifest { markdownLength: number; metadata: Record; pageCount: number; + pages: WorkspacePageProjectionManifestPage[]; provider: string; providerMode: string; runId: string; @@ -29,6 +30,11 @@ export interface WorkspacePageProjectionManifest { workspaceId: string; } +interface WorkspacePageProjectionManifestPage { + markdownBytes: number; + pageNumber: number; +} + export interface WorkspacePageProjectionReference { manifestObjectKey: string; manifest: WorkspacePageProjectionManifest; @@ -52,6 +58,7 @@ export async function writeWorkspacePageProjection(input: { let lastPageNumber = 0; let markdownBytes = 0; let markdownLength = 0; + const pages: WorkspacePageProjectionManifestPage[] = []; let usablePageCount = 0; try { @@ -63,6 +70,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 +79,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 +100,7 @@ export async function writeWorkspacePageProjection(input: { markdownLength, metadata: input.metadata ?? {}, pageCount: lastPageNumber, + pages, provider: input.provider, providerMode: input.providerMode, runId: input.runId, @@ -132,34 +142,55 @@ 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 pageMetadataByNumber = new Map( + manifest.pages.map((page) => [page.pageNumber, page] as const), ); - const totalBytes = objects.reduce((total, entry) => total + entry.object.size, 0); - if (totalBytes > maxPageReadBytes) { + const selectedManifestBytes = selectedPageNumbers.reduce( + (total, pageNumber) => + total + requireManifestPage(pageMetadataByNumber, pageNumber).markdownBytes, + 0, + ); + if (selectedManifestBytes > maxPageReadBytes) { throw new WorkspacePageSelectionError("page_selection_too_large"); } - const pages = await Promise.all( - objects.map(async ({ object, pageNumber }) => ({ + const prefix = getManifestPrefix(input.manifestObjectKey); + const pages: Array<{ markdown: string; pageNumber: number }> = []; + let totalBytes = 0; + + 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 = requireManifestPage(pageMetadataByNumber, pageNumber); + if (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()).trim(), pageNumber, - })), - ); + }); + } return { content: pages @@ -173,7 +204,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 +227,7 @@ export async function readWorkspacePageProjectionManifest( return parseWorkspacePageProjectionManifest(await object.json()); } -export function getWorkspacePageProjectionPrefix(input: { +function getWorkspacePageProjectionPrefix(input: { itemId: string; runId: string; tier: "enhanced" | "fast"; @@ -226,6 +268,8 @@ function parseWorkspacePageProjectionManifest(value: unknown): WorkspacePageProj throw new Error("Workspace page projection manifest is invalid."); } + const pages = parseManifestPages(value); + return { createdAt: value.createdAt, itemId: value.itemId, @@ -233,15 +277,39 @@ function parseWorkspacePageProjectionManifest(value: unknown): WorkspacePageProj markdownLength: value.markdownLength, metadata, pageCount: value.pageCount, + pages, provider: value.provider, providerMode: value.providerMode, runId: value.runId, - schemaVersion: projectionSchemaVersion, + schemaVersion: value.schemaVersion, sourceHash: value.sourceHash, workspaceId: value.workspaceId, }; } +function parseManifestPages(value: Record): WorkspacePageProjectionManifestPage[] { + if (!Array.isArray(value.pages) || value.pages.length !== value.pageCount) { + throw new Error("Workspace page projection manifest is invalid."); + } + + return value.pages.map((page, index) => { + if ( + !isRecord(page) || + page.pageNumber !== index + 1 || + typeof page.markdownBytes !== "number" || + !Number.isInteger(page.markdownBytes) || + page.markdownBytes < 0 + ) { + throw new Error("Workspace page projection manifest is invalid."); + } + + return { + markdownBytes: page.markdownBytes, + pageNumber: page.pageNumber, + }; + }); +} + async function schedulePageWrite( bucket: R2Bucket, writes: Promise[], From 70a2e499eec0d996e3ac15b5cd7da4c92a207176 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Fri, 17 Jul 2026 21:55:33 -0400 Subject: [PATCH 03/16] feat(workspaces): unify AI content reads --- .../workspaces/ai/ai-thread-runtime.ts | 1 + .../ai-chat/ai-chat-tool-receipts.ts | 16 +- .../workspace-content-contract.test.ts | 89 ++++++ .../content/workspace-content-contract.ts | 117 +++++++ .../content/workspace-content-cursor.ts | 37 +++ .../content/workspace-content-reader.test.ts | 132 ++++++++ .../content/workspace-content-reader.ts | 301 ++++++++++++++++++ .../workspaces/operations/read-items.ts | 289 +---------------- .../workspace-operation-observability.ts | 15 + .../operations/workspace-tool-definitions.ts | 19 +- .../operations/workspace-tool-schemas.ts | 107 ++----- 11 files changed, 750 insertions(+), 373 deletions(-) create mode 100644 src/features/workspaces/content/workspace-content-contract.test.ts create mode 100644 src/features/workspaces/content/workspace-content-contract.ts create mode 100644 src/features/workspaces/content/workspace-content-cursor.ts create mode 100644 src/features/workspaces/content/workspace-content-reader.test.ts create mode 100644 src/features/workspaces/content/workspace-content-reader.ts 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/components/ai-chat/ai-chat-tool-receipts.ts b/src/features/workspaces/components/ai-chat/ai-chat-tool-receipts.ts index 4ee8533ba..67680c9cd 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,11 +253,11 @@ 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"); if (readyItems.length === 0 && failedCount > 0) { return failed(`Couldn’t read ${formatCount(failedCount, "item")}`); 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..ded0056e8 --- /dev/null +++ b/src/features/workspaces/content/workspace-content-contract.ts @@ -0,0 +1,117 @@ +import { z } from "zod"; + +import { workspaceRelationKindSchema } from "#/features/workspaces/contracts"; + +const workspacePathSchema = z.string().min(1); + +export const readWorkspaceItemsFailureCodes = [ + "content_changed", + "invalid_cursor", + "invalid_selection", + "page_range_out_of_range", + "page_selection_too_large", + "path_is_folder", + "path_not_absolute", + "path_not_found", + "projection_failed", + "unsupported_item_type", +] as const; + +export 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..dfc580a38 --- /dev/null +++ b/src/features/workspaces/content/workspace-content-cursor.ts @@ -0,0 +1,37 @@ +import { z } from "zod"; + +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), + }), +]); + +export type WorkspaceContentCursor = z.infer; + +export function encodeWorkspaceContentCursor(cursor: WorkspaceContentCursor) { + return btoa(JSON.stringify(cursor)).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, ""); +} + +export function decodeWorkspaceContentCursor(value: string): WorkspaceContentCursor | null { + if (value.length > 4_096) { + return null; + } + try { + const base64 = value.replaceAll("-", "+").replaceAll("_", "/"); + const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "="); + return workspaceContentCursorSchema.parse(JSON.parse(atob(padded))); + } 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..05157be52 --- /dev/null +++ b/src/features/workspaces/content/workspace-content-reader.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { WorkspaceItemSummary } from "#/features/workspaces/contracts"; +import type { DocumentSessionClient } from "#/features/workspaces/document-session-access"; +import type { WorkspaceKernelClient } from "#/features/workspaces/kernel/workspace-kernel-access"; +import type { WorkspaceKernelPathResolution } from "#/features/workspaces/kernel/workspace-kernel-types"; +import { createWorkspaceContentReader } from "#/features/workspaces/content/workspace-content-reader"; + +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 reader = createWorkspaceContentReader({ + bucket: {} as R2Bucket, + getDocumentSession: () => session, + kernel: createKernel(), + }); + + const [first] = await reader.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 reader.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 reader = createWorkspaceContentReader({ + bucket: {} as R2Bucket, + getDocumentSession: () => session, + kernel: createKernel(), + }); + const [first] = await reader.read([{ mode: "start", path: "/Notes" }]); + if (!first || first.status !== "ready" || !first.nextCursor) { + throw new Error("Expected a continuation cursor."); + } + + session.readMarkdown = vi.fn(async () => ({ markdown: "changed", revision: "revision-2" })); + await expect( + reader.read([{ cursor: first.nextCursor, mode: "continue", path: "/Notes" }]), + ).resolves.toEqual([{ code: "content_changed", path: "/Notes", 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 reader = createWorkspaceContentReader({ + bucket: {} as R2Bucket, + getDocumentSession: () => createDocumentSession({ markdown: "", revision: "revision-1" }), + kernel, + }); + + await expect( + reader.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 { + readMarkdown: vi.fn(async () => snapshot), + } as unknown as DocumentSessionClient; +} + +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; +} 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..269925ee4 --- /dev/null +++ b/src/features/workspaces/content/workspace-content-reader.ts @@ -0,0 +1,301 @@ +import type { WorkspaceItemSummary } from "#/features/workspaces/contracts"; +import type { + WorkspaceContentReadRequest, + WorkspaceContentReadResult, +} from "#/features/workspaces/content/workspace-content-contract"; +import type { DocumentSessionClient } from "#/features/workspaces/document-session-access"; +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 maxDocumentChunkCharacters = 64_000; + +interface PendingReadyResult { + item: WorkspaceItemSummary; + read: Extract; + relations: Awaited>; +} + +export interface WorkspaceContentReader { + read(requests: WorkspaceContentReadRequest[]): Promise; +} + +export function createWorkspaceContentReader(input: { + bucket: R2Bucket; + getDocumentSession: (itemId: string) => DocumentSessionClient; + kernel: WorkspaceKernelClient; +}): WorkspaceContentReader { + return { + async read(requests) { + const resolutions = await input.kernel.resolvePaths({ + paths: requests.map((request) => request.path), + }); + const results: WorkspaceContentReadResult[] = []; + const readyResults: PendingReadyResult[] = []; + + 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 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) => DocumentSessionClient; + item: WorkspaceItemSummary; + kernel: WorkspaceKernelClient; + path: string; + request: WorkspaceContentReadRequest; +}): Promise { + if (input.item.type === "document") { + return await readDocument(input); + } + if (input.item.type === "file") { + return await readFile(input); + } + return { code: "unsupported_item_type", path: input.path, status: "failed" }; +} + +async function readDocument(input: { + getDocumentSession: (itemId: string) => DocumentSessionClient; + 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 snapshot = await input.getDocumentSession(input.item.id).readMarkdown(); + if (cursor?.kind === "document" && cursor.revision !== snapshot.revision) { + return { code: "content_changed", path: input.path, status: "failed" }; + } + if ( + cursor?.kind === "document" && + cursor.offset >= snapshot.markdown.length && + snapshot.markdown.length > 0 + ) { + return { code: "invalid_cursor", path: input.path, status: "failed" }; + } + + const chunk = createDocumentChunk( + snapshot.markdown, + cursor?.kind === "document" ? cursor.offset : 0, + ); + return { + content: chunk.content, + format: "markdown", + location: chunk.location, + ...(chunk.nextOffset === undefined + ? {} + : { + nextCursor: encodeWorkspaceContentCursor({ + itemId: input.item.id, + kind: "document", + offset: chunk.nextOffset, + revision: snapshot.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", + }; +} + +function createDocumentChunk(markdown: string, offset: number) { + const hardEnd = Math.min(markdown.length, offset + maxDocumentChunkCharacters); + const newlineEnd = markdown.lastIndexOf("\n", hardEnd); + const end = hardEnd < markdown.length && newlineEnd > offset ? newlineEnd + 1 : hardEnd; + const content = markdown.slice(offset, end).trimEnd(); + const startLine = content ? countLineBreaks(markdown.slice(0, offset)) + 1 : 0; + const endLine = content ? startLine + countLineBreaks(content) : 0; + + return { + content, + location: { + endLine, + kind: "lines" as const, + startLine, + totalLines: markdown ? countLineBreaks(markdown) + 1 : 0, + }, + ...(end < markdown.length ? { nextOffset: end } : {}), + }; +} + +function countLineBreaks(value: string) { + return value.match(/\n/g)?.length ?? 0; +} + +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/operations/read-items.ts b/src/features/workspaces/operations/read-items.ts index c473fe17a..1909e769d 100644 --- a/src/features/workspaces/operations/read-items.ts +++ b/src/features/workspaces/operations/read-items.ts @@ -1,294 +1,39 @@ 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 { createWorkspaceContentReader } 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 reader = createWorkspaceContentReader({ bucket: env.WORKSPACE_KERNEL_FILES, - manifestObjectKey: pagesProjection.objectKey, - pages: input.pages, + getDocumentSession: (itemId) => + getDocumentSessionFromEnv(env, { + itemId, + workspaceId: accessContext.workspaceId, + }), + kernel, }); - 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: await reader.read(input.requests) }; } diff --git a/src/features/workspaces/operations/workspace-operation-observability.ts b/src/features/workspaces/operations/workspace-operation-observability.ts index f5d7de919..3b906a195 100644 --- a/src/features/workspaces/operations/workspace-operation-observability.ts +++ b/src/features/workspaces/operations/workspace-operation-observability.ts @@ -54,6 +54,21 @@ 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 succeededCount = 0; + for (const result of input.results) { + if (result.status === "failed") { + failures.push({ code: result.code ?? "unknown" }); + } else { + succeededCount += 1; + } + } + return summarizeWorkspaceResult(succeededCount, failures); +} + export function summarizeWorkspaceItemResult(input: { failed: ReadonlyArray<{ code: string }>; item?: unknown; 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..1e6205051 100644 --- a/src/features/workspaces/operations/workspace-tool-schemas.ts +++ b/src/features/workspaces/operations/workspace-tool-schemas.ts @@ -1,11 +1,14 @@ 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, @@ -13,6 +16,8 @@ import { } from "#/features/workspaces/contracts"; import { documentMarkdownEditSchema } from "#/features/workspaces/documents/document-markdown-edits"; +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 +53,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.enum(["folder", "document", "pdf", "image", "file"]), }); const workspacePreviousPathItemSchema = workspacePathItemSchema.extend({ @@ -65,12 +70,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 +83,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 +108,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 +202,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 +262,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 +295,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, From f25f4afcd3f0b57ab6450f6bab3fddf93c8067e7 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Fri, 17 Jul 2026 23:44:06 -0400 Subject: [PATCH 04/16] fix(workspaces): bound content projection reads Consume page bodies without retaining live R2 responses. Preserve exact Markdown and keep old manifests readable under the runtime byte budget. Move document chunking behind DocumentSession with a revision-keyed snapshot cache. Make projection updates idempotent across workflow retries. Tests: pnpm test; pnpm test:workers; pnpm exec vp check --- .../content/workspace-content-contract.ts | 1 + .../content/workspace-content-reader.test.ts | 86 +++++++++++++- .../content/workspace-content-reader.ts | 61 ++++------ .../workspaces/document-session-access.ts | 8 +- .../documents/document-markdown-chunk.test.ts | 27 +++++ .../documents/document-markdown-chunk.ts | 86 ++++++++++++++ .../workspaces/documents/document-session.ts | 58 ++++++++-- .../extraction/liteparse-projection.ts | 1 + .../request-workspace-file-extraction.ts | 1 + .../workspace-file-extraction-workflow.ts | 3 + .../workspace-page-projection.test.ts | 36 +++++- .../extraction/workspace-page-projection.ts | 47 ++++---- .../kernel/workspace-kernel-events.test.ts | 80 ++++++++++++- .../kernel/workspace-kernel-events.ts | 105 +++++++++++++----- .../kernel/workspace-kernel-file-commands.ts | 29 ++++- .../kernel/workspace-kernel-item-commands.ts | 18 ++- .../kernel/workspace-kernel-rows.ts | 23 +--- .../kernel/workspace-kernel-types.ts | 5 - .../workspaces/kernel/workspace-kernel.ts | 19 ++-- .../workspaces/operations/create-items.ts | 1 + 20 files changed, 537 insertions(+), 158 deletions(-) create mode 100644 src/features/workspaces/documents/document-markdown-chunk.test.ts create mode 100644 src/features/workspaces/documents/document-markdown-chunk.ts diff --git a/src/features/workspaces/content/workspace-content-contract.ts b/src/features/workspaces/content/workspace-content-contract.ts index ded0056e8..5b43a37e5 100644 --- a/src/features/workspaces/content/workspace-content-contract.ts +++ b/src/features/workspaces/content/workspace-content-contract.ts @@ -10,6 +10,7 @@ export const readWorkspaceItemsFailureCodes = [ "invalid_selection", "page_range_out_of_range", "page_selection_too_large", + "read_budget_exceeded", "path_is_folder", "path_not_absolute", "path_not_found", diff --git a/src/features/workspaces/content/workspace-content-reader.test.ts b/src/features/workspaces/content/workspace-content-reader.test.ts index 05157be52..9e5c2595a 100644 --- a/src/features/workspaces/content/workspace-content-reader.test.ts +++ b/src/features/workspaces/content/workspace-content-reader.test.ts @@ -1,10 +1,13 @@ 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 type { DocumentSessionClient } from "#/features/workspaces/document-session-access"; +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 { createWorkspaceContentReader } from "#/features/workspaces/content/workspace-content-reader"; +import { encodeWorkspaceContentCursor } from "#/features/workspaces/content/workspace-content-cursor"; const documentItem: WorkspaceItemSummary = { id: "document-1", @@ -79,12 +82,79 @@ describe("WorkspaceContentReader", () => { throw new Error("Expected a continuation cursor."); } - session.readMarkdown = vi.fn(async () => ({ markdown: "changed", revision: "revision-2" })); + session.readMarkdownChunk = vi.fn(async () => ({ status: "content_changed" })); await expect( reader.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 reader = createWorkspaceContentReader({ + 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 reader.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 reader = createWorkspaceContentReader({ + 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(reader.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 reader = createWorkspaceContentReader({ + 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 reader.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( @@ -117,8 +187,18 @@ describe("WorkspaceContentReader", () => { function createDocumentSession(snapshot: { markdown: string; revision: string }) { return { - readMarkdown: vi.fn(async () => snapshot), - } as unknown as DocumentSessionClient; + applyMarkdownEdits: vi.fn(), + purgeForDeletion: vi.fn(), + 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 }; + }), + } satisfies DocumentSessionClient; } function createKernel() { diff --git a/src/features/workspaces/content/workspace-content-reader.ts b/src/features/workspaces/content/workspace-content-reader.ts index 269925ee4..d86870ce6 100644 --- a/src/features/workspaces/content/workspace-content-reader.ts +++ b/src/features/workspaces/content/workspace-content-reader.ts @@ -14,7 +14,7 @@ import { encodeWorkspaceContentCursor, } from "#/features/workspaces/content/workspace-content-cursor"; -const maxDocumentChunkCharacters = 64_000; +const maxWorkspaceContentBatchBytes = 2 * 1024 * 1024 + 64 * 1024; interface PendingReadyResult { item: WorkspaceItemSummary; @@ -33,12 +33,15 @@ export function createWorkspaceContentReader(input: { }): WorkspaceContentReader { return { async read(requests) { + 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) { @@ -72,6 +75,17 @@ export function createWorkspaceContentReader(input: { 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, @@ -128,26 +142,21 @@ async function readDocument(input: { return { code: "invalid_cursor", path: input.path, status: "failed" }; } - const snapshot = await input.getDocumentSession(input.item.id).readMarkdown(); - if (cursor?.kind === "document" && cursor.revision !== snapshot.revision) { + 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 ( - cursor?.kind === "document" && - cursor.offset >= snapshot.markdown.length && - snapshot.markdown.length > 0 - ) { + if (chunk.status === "invalid_offset") { return { code: "invalid_cursor", path: input.path, status: "failed" }; } - const chunk = createDocumentChunk( - snapshot.markdown, - cursor?.kind === "document" ? cursor.offset : 0, - ); return { content: chunk.content, format: "markdown", - location: chunk.location, + location: { kind: "lines", ...chunk.location }, ...(chunk.nextOffset === undefined ? {} : { @@ -155,7 +164,7 @@ async function readDocument(input: { itemId: input.item.id, kind: "document", offset: chunk.nextOffset, - revision: snapshot.revision, + revision: chunk.revision, version: 1, }), }), @@ -246,30 +255,6 @@ async function readFile(input: { }; } -function createDocumentChunk(markdown: string, offset: number) { - const hardEnd = Math.min(markdown.length, offset + maxDocumentChunkCharacters); - const newlineEnd = markdown.lastIndexOf("\n", hardEnd); - const end = hardEnd < markdown.length && newlineEnd > offset ? newlineEnd + 1 : hardEnd; - const content = markdown.slice(offset, end).trimEnd(); - const startLine = content ? countLineBreaks(markdown.slice(0, offset)) + 1 : 0; - const endLine = content ? startLine + countLineBreaks(content) : 0; - - return { - content, - location: { - endLine, - kind: "lines" as const, - startLine, - totalLines: markdown ? countLineBreaks(markdown) + 1 : 0, - }, - ...(end < markdown.length ? { nextOffset: end } : {}), - }; -} - -function countLineBreaks(value: string) { - return value.match(/\n/g)?.length ?? 0; -} - async function attachRelationPaths( kernel: WorkspaceKernelClient, readyResults: PendingReadyResult[], diff --git a/src/features/workspaces/document-session-access.ts b/src/features/workspaces/document-session-access.ts index d3524166e..6426da432 100644 --- a/src/features/workspaces/document-session-access.ts +++ b/src/features/workspaces/document-session-access.ts @@ -1,4 +1,8 @@ import { getDocumentSessionRoomName } from "#/features/workspaces/agent-routes"; +import type { + DocumentMarkdownChunkReadInput, + DocumentMarkdownChunkReadResult, +} from "#/features/workspaces/documents/document-markdown-chunk"; import type { DocumentSessionApplyMarkdownEditsResult } from "#/features/workspaces/documents/document-session"; import type { DocumentMarkdownEdit } from "#/features/workspaces/documents/document-markdown-edits"; @@ -6,7 +10,9 @@ export interface DocumentSessionClient { applyMarkdownEdits(input: { edits: DocumentMarkdownEdit[]; }): Promise; - readMarkdown(): Promise<{ markdown: string; revision: string }>; + readMarkdownChunk( + input: DocumentMarkdownChunkReadInput, + ): Promise; purgeForDeletion(): Promise; } 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..132a49ad5 --- /dev/null +++ b/src/features/workspaces/documents/document-markdown-chunk.test.ts @@ -0,0 +1,27 @@ +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(); + }); +}); 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..4098699c8 --- /dev/null +++ b/src/features/workspaces/documents/document-markdown-chunk.ts @@ -0,0 +1,86 @@ +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 hardEnd = Math.min(markdown.length, offset + maxDocumentChunkCharacters); + 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 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 1ed741344..b6f03572c 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, @@ -61,6 +67,12 @@ export class DocumentSession extends YServer { hibernate: true, }; + private markdownSnapshot?: { + revision: string; + snapshot: DocumentMarkdownSnapshot; + stateVector: Uint8Array; + }; + static override callbackOptions = { debounceWait: checkpointDelayMs, debounceMaxWait: checkpointMaxWaitMs, @@ -105,15 +117,17 @@ export class DocumentSession extends YServer { } override async onLoad() { - const persistedUpdate = await this.ctx.storage.get(persistedYDocUpdateKey); + const room = getDocumentSessionRoomNameParts(this.name); + const kernel = await this.getWorkspaceKernel(room.workspaceId); + 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; } - const room = getDocumentSessionRoomNameParts(this.name); - const kernel = await this.getWorkspaceKernel(room.workspaceId); - const { content } = await kernel.readDocumentCheckpoint({ itemId: room.itemId }); const snapshot = parseTiptapDocumentJson(content); const seededDoc = prosemirrorJSONToYDoc( getTiptapDocumentSchema(), @@ -176,15 +190,31 @@ export class DocumentSession extends YServer { }; } - async readMarkdown() { + async readMarkdownChunk( + input: DocumentMarkdownChunkReadInput, + ): Promise { const stateVector = Uint8Array.from(Y.encodeStateVector(this.document)); - const revisionBytes = await crypto.subtle.digest("SHA-256", stateVector.buffer); - return { - markdown: serializeTiptapDocumentToMarkdown(this.getCurrentTiptapDocument()), - revision: Array.from(new Uint8Array(revisionBytes), (byte) => - byte.toString(16).padStart(2, "0"), - ).join(""), - }; + let currentSnapshot = this.markdownSnapshot; + if (!currentSnapshot || !uint8ArraysEqual(currentSnapshot.stateVector, stateVector)) { + const markdown = serializeTiptapDocumentToMarkdown(this.getCurrentTiptapDocument()); + const revisionBytes = await crypto.subtle.digest("SHA-256", stateVector.buffer); + currentSnapshot = { + revision: Array.from(new Uint8Array(revisionBytes), (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""), + 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 { @@ -228,6 +258,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/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..810466c07 100644 --- a/src/features/workspaces/extraction/request-workspace-file-extraction.ts +++ b/src/features/workspaces/extraction/request-workspace-file-extraction.ts @@ -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 29195747f..61139a0fd 100644 --- a/src/features/workspaces/extraction/workspace-page-projection.test.ts +++ b/src/features/workspaces/extraction/workspace-page-projection.test.ts @@ -130,7 +130,7 @@ describe("workspace page projections", () => { expect(storage.openBodies()).toBe(0); }); - it("rejects oversized v2 selections before opening page objects", async () => { + it("rejects oversized selections before opening page objects when sizes are published", async () => { const storage = createObjectStorage(); const reference = await writeWorkspacePageProjection({ bucket: storage.bucket, @@ -159,7 +159,7 @@ describe("workspace page projections", () => { expect(storage.readKeys).toEqual([reference.manifestObjectKey]); }); - it("rejects obsolete projection manifests", async () => { + it("reads projections published before per-page sizes were added", async () => { const storage = createObjectStorage(); const reference = await writeWorkspacePageProjection({ bucket: storage.bucket, @@ -184,7 +184,37 @@ describe("workspace page projections", () => { expectedSourceHash: "etag-1", manifestObjectKey: reference.manifestObjectKey, }), - ).rejects.toThrow("manifest is invalid"); + ).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 () => { diff --git a/src/features/workspaces/extraction/workspace-page-projection.ts b/src/features/workspaces/extraction/workspace-page-projection.ts index 243fb8a7f..be9d29c43 100644 --- a/src/features/workspaces/extraction/workspace-page-projection.ts +++ b/src/features/workspaces/extraction/workspace-page-projection.ts @@ -8,7 +8,7 @@ import { } from "#/features/workspaces/read-page-selection"; import { deleteR2Prefix } from "#/lib/r2"; -const projectionSchemaVersion = 2; +const projectionSchemaVersion = 1; const pageNumberWidth = 6; const pageWriteConcurrency = 8; const maxPageMarkdownBytes = 1024 * 1024; @@ -21,7 +21,7 @@ export interface WorkspacePageProjectionManifest { markdownLength: number; metadata: Record; pageCount: number; - pages: WorkspacePageProjectionManifestPage[]; + pages?: WorkspacePageProjectionManifestPage[]; provider: string; providerMode: string; runId: string; @@ -152,15 +152,17 @@ export async function readWorkspacePageProjection(input: { } const requested = input.pages?.trim() || "1"; const selectedPageNumbers = parseWorkspacePageRange(requested, manifest.pageCount); - const pageMetadataByNumber = new Map( - manifest.pages.map((page) => [page.pageNumber, page] as const), - ); - const selectedManifestBytes = selectedPageNumbers.reduce( - (total, pageNumber) => - total + requireManifestPage(pageMetadataByNumber, pageNumber).markdownBytes, - 0, - ); - if (selectedManifestBytes > 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"); } @@ -168,6 +170,7 @@ export async function readWorkspacePageProjection(input: { 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) { @@ -180,22 +183,20 @@ export async function readWorkspacePageProjection(input: { throw new WorkspacePageSelectionError("page_selection_too_large"); } - const manifestPage = requireManifestPage(pageMetadataByNumber, pageNumber); - if (manifestPage.markdownBytes !== object.size) { + 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()).trim(), + 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, @@ -268,7 +269,7 @@ function parseWorkspacePageProjectionManifest(value: unknown): WorkspacePageProj throw new Error("Workspace page projection manifest is invalid."); } - const pages = parseManifestPages(value); + const pages = value.pages === undefined ? undefined : parseManifestPages(value); return { createdAt: value.createdAt, @@ -277,7 +278,7 @@ function parseWorkspacePageProjectionManifest(value: unknown): WorkspacePageProj markdownLength: value.markdownLength, metadata, pageCount: value.pageCount, - pages, + ...(pages ? { pages } : {}), provider: value.provider, providerMode: value.providerMode, runId: value.runId, @@ -345,7 +346,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) { diff --git a/src/features/workspaces/kernel/workspace-kernel-events.test.ts b/src/features/workspaces/kernel/workspace-kernel-events.test.ts index 43dc58078..bda366186 100644 --- a/src/features/workspaces/kernel/workspace-kernel-events.test.ts +++ b/src/features/workspaces/kernel/workspace-kernel-events.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it, vi } from "vitest"; -import { WorkspaceKernelEventBus } from "#/features/workspaces/kernel/workspace-kernel-events"; +import { + hydrateCreatedItemEvent, + hydrateProjectionEvent, + 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"; @@ -9,13 +13,44 @@ describe("workspace kernel event idempotency", () => { const events = createEventBus(createItemEventRow("item-1")); expect( - events.getCreatedItemEvent({ + events.findCreatedItemEvent({ clientMutationId: "mutation-1", itemId: "item-1", }), ).toMatchObject({ clientMutationId: "mutation-1", - payload: { item: { id: "item-1" } }, + id: "event-1", + revision: 1, + }); + }); + + it("hydrates historical creation events with the current payload shape", () => { + const events = createEventBus(createItemEventRow("item-1", false)); + const storedEvent = events.findCreatedItemEvent({ + clientMutationId: "mutation-1", + itemId: "item-1", + }); + if (!storedEvent) { + throw new Error("Expected a stored creation event."); + } + const item = { + color: null, + createdAt: "2026-07-15T00:00:00.000Z", + deletedAt: null, + id: "item-1", + meta: "Document", + metadataJson: {}, + name: "Notes", + parentId: null, + sortOrder: 1, + title: "Notes", + type: "document" as const, + updatedAt: "2026-07-15T00:00:00.000Z", + workspaceId: "workspace-1", + }; + + expect(hydrateCreatedItemEvent(storedEvent, item, [])).toMatchObject({ + payload: { item, itemFacts: [] }, type: "workspace.item.created", }); }); @@ -24,12 +59,30 @@ describe("workspace kernel event idempotency", () => { const events = createEventBus(createItemEventRow("item-1")); expect(() => - events.getCreatedItemEvent({ + events.findCreatedItemEvent({ clientMutationId: "mutation-1", itemId: "item-2", }), ).toThrow("client mutation id was already used"); }); + + it("recovers and hydrates a committed projection update", () => { + const events = createEventBus(createProjectionEventRow("item-1")); + const storedEvent = events.findProjectionEvent({ + clientMutationId: "mutation-1", + itemId: "item-1", + }); + if (!storedEvent) { + throw new Error("Expected a stored projection event."); + } + const itemFacts = [{ itemId: "item-1", pageCount: 3, relationshipCount: 0 }]; + + expect(hydrateProjectionEvent(storedEvent, itemFacts)).toMatchObject({ + clientMutationId: "mutation-1", + payload: { itemFacts }, + type: "workspace.item.projection.updated", + }); + }); }); function createEventBus(row: KernelEventRow) { @@ -42,13 +95,28 @@ function createEventBus(row: KernelEventRow) { }); } -function createItemEventRow(itemId: string): KernelEventRow { +function createProjectionEventRow(itemId: string): KernelEventRow { + return { + actor_user_id: null, + client_mutation_id: "mutation-1", + created_at: Date.parse("2026-07-15T00:00:00Z"), + id: "event-1", + payload_json: JSON.stringify({ itemFacts: [{ itemId }] }), + revision: 1, + type: "workspace.item.projection.updated", + }; +} + +function createItemEventRow(itemId: string, includeFacts = true): 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 }, itemFacts: [] }), + payload_json: JSON.stringify({ + item: { id: itemId }, + ...(includeFacts ? { itemFacts: [] } : {}), + }), 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..a94942477 100644 --- a/src/features/workspaces/kernel/workspace-kernel-events.ts +++ b/src/features/workspaces/kernel/workspace-kernel-events.ts @@ -1,12 +1,25 @@ +import { z } from "zod"; 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 { WorkspaceItemFacts, WorkspaceItemSummary } from "#/features/workspaces/contracts"; import type { WorkspaceRealtimeEvent, WorkspaceRealtimeServerMessage, } from "#/features/workspaces/realtime/messages"; +type WorkspaceKernelEventIdentity = Pick< + WorkspaceRealtimeEvent, + "actorUserId" | "clientMutationId" | "createdAt" | "id" | "revision" | "workspaceId" +>; + +const storedCreatedItemPayloadSchema = z.object({ + item: z.object({ id: z.string() }), +}); + +const storedProjectionPayloadSchema = z.object({ + itemFacts: z.array(z.object({ itemId: z.string() })), +}); + export class WorkspaceKernelEventBus { private readonly sql: WorkspaceKernelSql; private readonly workspaceId: () => string; @@ -25,41 +38,31 @@ 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))} - `; + findCreatedItemEvent(input: { clientMutationId: string; itemId: string }) { + const row = this.findEventRow("workspace.item.created", input.clientMutationId); + if (!row) { + return null; + } - return rows.map((row) => mapKernelEventRow(row, this.workspaceId())); - } + const payload = storedCreatedItemPayloadSchema.parse(JSON.parse(row.payload_json)); + if (payload.item.id !== input.itemId) { + throw new Error("Workspace client mutation id was already used."); + } - 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 - `; + return mapEventIdentity(row, this.workspaceId()); + } + findProjectionEvent(input: { clientMutationId: string; itemId: string }) { + const row = this.findEventRow("workspace.item.projection.updated", input.clientMutationId); if (!row) { return null; } - - const event = mapKernelEventRow(row, this.workspaceId()); - if (event.type !== "workspace.item.created" || event.payload.item.id !== input.itemId) { + const payload = storedProjectionPayloadSchema.parse(JSON.parse(row.payload_json)); + if (!payload.itemFacts.some((facts) => facts.itemId === input.itemId)) { throw new Error("Workspace client mutation id was already used."); } - return event; + return mapEventIdentity(row, this.workspaceId()); } commit(input: Omit) { @@ -100,4 +103,50 @@ export class WorkspaceKernelEventBus { return event; } + + private findEventRow(type: WorkspaceRealtimeEvent["type"], clientMutationId: string) { + const [row] = this.sql` + SELECT * + FROM kernel_events + WHERE type = ${type} + AND client_mutation_id = ${clientMutationId} + ORDER BY revision ASC + LIMIT 1 + `; + return row ?? null; + } +} + +export function hydrateCreatedItemEvent( + event: WorkspaceKernelEventIdentity, + item: WorkspaceItemSummary, + itemFacts: WorkspaceItemFacts[], +): Extract { + return { + ...event, + type: "workspace.item.created", + payload: { item, itemFacts }, + }; +} + +export function hydrateProjectionEvent( + event: WorkspaceKernelEventIdentity, + itemFacts: WorkspaceItemFacts[], +): Extract { + return { + ...event, + type: "workspace.item.projection.updated", + payload: { itemFacts }, + }; +} + +function mapEventIdentity(row: KernelEventRow, workspaceId: string): WorkspaceKernelEventIdentity { + return { + actorUserId: row.actor_user_id, + clientMutationId: row.client_mutation_id, + createdAt: new Date(row.created_at).toISOString(), + id: row.id, + revision: row.revision, + workspaceId, + }; } diff --git a/src/features/workspaces/kernel/workspace-kernel-file-commands.ts b/src/features/workspaces/kernel/workspace-kernel-file-commands.ts index f04957684..09aafb314 100644 --- a/src/features/workspaces/kernel/workspace-kernel-file-commands.ts +++ b/src/features/workspaces/kernel/workspace-kernel-file-commands.ts @@ -5,7 +5,11 @@ import type { } 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"; +import { + hydrateCreatedItemEvent, + hydrateProjectionEvent, + type WorkspaceKernelEventBus, +} from "#/features/workspaces/kernel/workspace-kernel-events"; import { getWorkspaceKernelFileShellPath } from "#/features/workspaces/kernel/workspace-kernel-files"; import { parseWorkspaceMetadataJson } from "#/features/workspaces/kernel/workspace-kernel-metadata"; import type { WorkspaceKernelSql } from "#/features/workspaces/kernel/workspace-kernel-schema"; @@ -61,14 +65,21 @@ export class WorkspaceKernelFileCommands { ): Promise> { const parentId = input.parentId ?? null; const getPriorResult = () => { - const event = input.clientMutationId - ? this.events.getCreatedItemEvent({ + const storedEvent = input.clientMutationId + ? this.events.findCreatedItemEvent({ clientMutationId: input.clientMutationId, itemId: input.id, }) : null; - return event ? { event, result: this.store.requireItem(input.id) } : null; + if (!storedEvent) { + return null; + } + const item = this.store.requireItem(input.id); + return { + event: hydrateCreatedItemEvent(storedEvent, item, this.store.getItemFacts([item])), + result: item, + }; }; const priorResult = getPriorResult(); @@ -277,6 +288,16 @@ export class WorkspaceKernelFileCommands { if (row.type !== "file") { throw new Error("Workspace item is not a file."); } + const storedEvent = input.clientMutationId + ? this.events.findProjectionEvent({ + clientMutationId: input.clientMutationId, + itemId: input.itemId, + }) + : null; + if (storedEvent) { + const itemFacts = this.store.getItemFacts([this.store.requireItem(input.itemId)]); + return { event: hydrateProjectionEvent(storedEvent, itemFacts), result: itemFacts }; + } if (input.status === "ready") { if (!row.object_key) { throw new Error("Ready file projections require a current source object."); diff --git a/src/features/workspaces/kernel/workspace-kernel-item-commands.ts b/src/features/workspaces/kernel/workspace-kernel-item-commands.ts index 96e298c44..c93664750 100644 --- a/src/features/workspaces/kernel/workspace-kernel-item-commands.ts +++ b/src/features/workspaces/kernel/workspace-kernel-item-commands.ts @@ -7,7 +7,10 @@ import { persistDocumentItemContentUpdate, touchWorkspaceItemUpdatedAt, } from "#/features/workspaces/documents/document-item-content"; -import type { WorkspaceKernelEventBus } from "#/features/workspaces/kernel/workspace-kernel-events"; +import { + hydrateCreatedItemEvent, + type WorkspaceKernelEventBus, +} from "#/features/workspaces/kernel/workspace-kernel-events"; import { getInitialWorkspaceKernelContent, getWorkspaceKernelContentMimeType, @@ -75,15 +78,22 @@ export class WorkspaceKernelItemCommands { const id = input.id ?? crypto.randomUUID(); const parentId = input.parentId ?? null; const getPriorResult = () => { - const event = + const storedEvent = input.id && input.clientMutationId - ? this.events.getCreatedItemEvent({ + ? this.events.findCreatedItemEvent({ clientMutationId: input.clientMutationId, itemId: input.id, }) : null; - return event ? { event, result: this.store.requireItem(id) } : null; + if (!storedEvent) { + return null; + } + const item = this.store.requireItem(id); + return { + event: hydrateCreatedItemEvent(storedEvent, item, this.store.getItemFacts([item])), + result: item, + }; }; const priorResult = getPriorResult(); diff --git a/src/features/workspaces/kernel/workspace-kernel-rows.ts b/src/features/workspaces/kernel/workspace-kernel-rows.ts index 0a64d201f..45a7cc336 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, @@ -26,7 +25,7 @@ export type KernelItemRow = { export type KernelEventRow = { id: string; revision: number; - type: WorkspaceRealtimeEvent["type"]; + type: string; actor_user_id: string | null; client_mutation_id: string | null; payload_json: string; @@ -66,23 +65,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-types.ts b/src/features/workspaces/kernel/workspace-kernel-types.ts index ee3ecbada..73fa9c840 100644 --- a/src/features/workspaces/kernel/workspace-kernel-types.ts +++ b/src/features/workspaces/kernel/workspace-kernel-types.ts @@ -286,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 e4ba5f160..a490f75c1 100644 --- a/src/features/workspaces/kernel/workspace-kernel.ts +++ b/src/features/workspaces/kernel/workspace-kernel.ts @@ -18,7 +18,8 @@ import { } from "#/features/workspaces/kernel/workspace-kernel-schema"; import { WorkspaceKernelRelations } from "#/features/workspaces/kernel/workspace-kernel-relations"; import { - listWorkspaceKernelTreeItems, + formatWorkspaceKernelListSelection, + selectWorkspaceKernelTreeItems, type ListWorkspaceKernelItemsResult, } from "#/features/workspaces/kernel/workspace-kernel-list"; import { @@ -35,7 +36,6 @@ import type { DeleteWorkspaceKernelItemsArgs, DeleteWorkspaceKernelItemsResult, GetWorkspaceKernelItemPathsArgs, - ListWorkspaceKernelEventsArgs, ListWorkspaceKernelItemRelationsArgs, ListWorkspaceKernelItemsArgs, LinkWorkspaceKernelItemsArgs, @@ -57,7 +57,6 @@ import { getChatAttachmentWorkspacePrefix } from "#/features/workspaces/ai/chat- import type { WorkspaceCommandResult, WorkspaceConnectionState, - WorkspaceRealtimeEvent, WorkspaceRealtimeServerMessage, } from "#/features/workspaces/realtime/messages"; import { @@ -144,11 +143,14 @@ export class WorkspaceKernel extends Agent { input: ListWorkspaceKernelItemsArgs = {}, ): Promise { const items = this.store.getPageItems(); - return listWorkspaceKernelTreeItems({ + const selection = selectWorkspaceKernelTreeItems({ tree: buildWorkspaceKernelTree(items), - itemFactsById: new Map(this.store.getItemFacts(items).map((facts) => [facts.itemId, facts])), ...input, }); + return formatWorkspaceKernelListSelection( + selection, + this.store.getItemFacts(selection.rows.map((row) => row.item)), + ); } async resolvePaths( @@ -316,13 +318,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/operations/create-items.ts b/src/features/workspaces/operations/create-items.ts index f0c7be060..bd93fcfc8 100644 --- a/src/features/workspaces/operations/create-items.ts +++ b/src/features/workspaces/operations/create-items.ts @@ -81,6 +81,7 @@ export async function createWorkspaceItemsOperation( const items: CreatedWorkspaceItem[] = []; const failed: CreateWorkspaceItemsFailure[] = []; + // 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); From d27e4948a52e18eb2883c4aa8d84b524b92919dc Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Fri, 17 Jul 2026 23:44:22 -0400 Subject: [PATCH 05/16] fix(workspaces): harden realtime projection state Validate realtime messages before cache updates and collapse reconnect, revision-gap, and invalid-message recovery into one canonical refetch path. Ignore stale events, query facts only for selected items, and report pending extraction separately from successful reads. Tests: pnpm test; pnpm test:workers; pnpm exec vp check --- .../workspaces/components/WorkspaceLayout.tsx | 7 +- .../ai-chat/ai-chat-tool-receipts.test.ts | 36 +++++ .../ai-chat/ai-chat-tool-receipts.ts | 17 ++- .../kernel/workspace-kernel-list.ts | 54 ++++--- .../kernel/workspace-kernel-store.ts | 7 +- .../workspaces/model/workspace-page.ts | 4 + .../workspace-operation-observability.test.ts | 37 +++++ .../workspace-operation-observability.ts | 18 ++- src/features/workspaces/realtime/messages.ts | 140 +++++++++++------- .../realtime/use-workspace-presence.ts | 30 ++-- 10 files changed, 247 insertions(+), 103 deletions(-) create mode 100644 src/features/workspaces/components/ai-chat/ai-chat-tool-receipts.test.ts create mode 100644 src/features/workspaces/operations/workspace-operation-observability.test.ts 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.test.ts b/src/features/workspaces/components/ai-chat/ai-chat-tool-receipts.test.ts new file mode 100644 index 000000000..9b955ab8b --- /dev/null +++ b/src/features/workspaces/components/ai-chat/ai-chat-tool-receipts.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; + +import { getFinishedToolReceipt } from "#/features/workspaces/components/ai-chat/ai-chat-tool-receipts"; + +describe("workspace read tool receipts", () => { + it("describes pending-only reads as extraction in progress", () => { + expect( + getFinishedToolReceipt({ + baseStatus: "completed", + output: { results: [{ path: "/Paper.pdf", status: "pending", type: "file" }] }, + toolInput: {}, + toolName: "workspace_read_items", + }), + ).toEqual({ status: "completed", summary: "Extraction in progress for 1 item" }); + }); + + it("keeps pending and failed reads visible beside ready content", () => { + expect( + getFinishedToolReceipt({ + baseStatus: "completed", + output: { + results: [ + { path: "/Notes", status: "ready", type: "document" }, + { path: "/Paper.pdf", status: "pending", type: "file" }, + { code: "path_not_found", path: "/Missing", status: "failed" }, + ], + }, + toolInput: {}, + toolName: "workspace_read_items", + }), + ).toEqual({ + status: "completed", + summary: "Read “Notes” · 1 item still processing, 1 failure", + }); + }); +}); 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 67680c9cd..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 @@ -258,17 +258,30 @@ function summarizeWorkspaceRead(output: unknown): AiChatToolReceipt { (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/kernel/workspace-kernel-list.ts b/src/features/workspaces/kernel/workspace-kernel-list.ts index 99e9c73a0..8d65257f0 100644 --- a/src/features/workspaces/kernel/workspace-kernel-list.ts +++ b/src/features/workspaces/kernel/workspace-kernel-list.ts @@ -36,43 +36,43 @@ export interface ListWorkspaceKernelItemsFailure { path: string; } -interface WorkspaceKernelListedItems { - items: ListWorkspaceKernelItem[]; +export interface WorkspaceKernelListSelection { + failed: ListWorkspaceKernelItemsFailure[]; + path: string; + rows: WorkspaceKernelListRow[]; total: number; nextOffset?: number; } -interface WorkspaceKernelListRow { +export interface WorkspaceKernelListRow { item: WorkspaceItemSummary; path: string; } -export function listWorkspaceKernelTreeItems(input: { +export function selectWorkspaceKernelTreeItems(input: { tree: WorkspaceKernelTree; - itemFactsById: ReadonlyMap; offset?: number; path?: string; recursive?: boolean; limit?: number; -}): ListWorkspaceKernelItemsResult { +}): 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 +81,7 @@ export function listWorkspaceKernelTreeItems(input: { return { path, total: 0, - items: [], + rows: [], failed: [ { code: error.code, @@ -95,14 +95,33 @@ export function listWorkspaceKernelTreeItems(input: { } } -function collectWorkspaceKernelListItems({ +export 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 +129,7 @@ function collectWorkspaceKernelListItems({ recursive: boolean; limit: number; childrenByParentId: Map; - itemFactsById: ReadonlyMap; -}): WorkspaceKernelListedItems { +}): Pick { const rows: WorkspaceKernelListRow[] = []; const visitedIds = new Set(); @@ -140,13 +158,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-store.ts b/src/features/workspaces/kernel/workspace-kernel-store.ts index 09c1cb491..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), ); diff --git a/src/features/workspaces/model/workspace-page.ts b/src/features/workspaces/model/workspace-page.ts index 297264cc7..0d1eec425 100644 --- a/src/features/workspaces/model/workspace-page.ts +++ b/src/features/workspaces/model/workspace-page.ts @@ -18,6 +18,10 @@ export function applyWorkspaceEventToPage( page: WorkspacePage, event: WorkspaceRealtimeEvent, ): WorkspacePage { + if (event.revision <= page.revision) { + return page; + } + switch (event.type) { case "workspace.item.created": return upsertWorkspaceItemFactsInPage( 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 3b906a195..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; } @@ -58,15 +59,18 @@ 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); + return summarizeWorkspaceResult(succeededCount, failures, pendingCount); } export function summarizeWorkspaceItemResult(input: { @@ -98,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, @@ -110,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/realtime/messages.ts b/src/features/workspaces/realtime/messages.ts index de507ae8f..c4571b2d4 100644 --- a/src/features/workspaces/realtime/messages.ts +++ b/src/features/workspaces/realtime/messages.ts @@ -1,67 +1,97 @@ -import type { WorkspaceItemFacts, 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.literal("workspace.relations.updated"), + payload: z.object({ itemFacts: z.array(workspaceItemFactsSchema) }), + }), + z.object({ + ...workspaceRealtimeEventBase, + type: z.literal("workspace.item.projection.updated"), + payload: z.object({ itemFacts: z.array(workspaceItemFactsSchema) }), + }), +]); -export type WorkspaceRealtimeEvent = - | (WorkspaceRealtimeEventBase & { - type: "workspace.item.created"; - payload: { item: WorkspaceItemSummary; itemFacts: WorkspaceItemFacts[] }; - }) - | (WorkspaceRealtimeEventBase & { - type: - | "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[]; - itemFacts: WorkspaceItemFacts[]; - }; - }) - | (WorkspaceRealtimeEventBase & { - type: "workspace.relations.updated" | "workspace.item.projection.updated"; - payload: { itemFacts: WorkspaceItemFacts[] }; - }); +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..5fc152cd4 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,10 @@ export function useWorkspaceRealtime({ const handleMessage = useCallback( (event: MessageEvent) => { const message = parseServerMessage(event.data); + if (!message) { + onDesyncRef.current?.(); + return; + } if (message?.type === "presence.snapshot" && message.workspaceId === workspaceId) { setPresence((current) => ({ @@ -144,7 +144,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; } From 7546ad367569fe064e4302c303564cf983a8600b Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Fri, 17 Jul 2026 23:49:22 -0400 Subject: [PATCH 06/16] fix(workspaces): retire unsupported item rows Run an explicit Durable Object SQLite migration before current item schemas load. Remove legacy relations and projections, then soft-retire flashcard and quiz rows without a runtime shim. Tests: pnpm test; pnpm test:workers; pnpm exec vp check --- .../kernel/workspace-kernel-schema.ts | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/features/workspaces/kernel/workspace-kernel-schema.ts b/src/features/workspaces/kernel/workspace-kernel-schema.ts index 88eb76864..b14516dca 100644 --- a/src/features/workspaces/kernel/workspace-kernel-schema.ts +++ b/src/features/workspaces/kernel/workspace-kernel-schema.ts @@ -3,6 +3,7 @@ import { recordOperationalFailure } from "#/integrations/observability/operation export const workspaceRevisionKey = "workspace_revision"; export const workspaceItemSortStep = WORKSPACE_ITEM_SORT_STEP; +const retireLegacyLearningItemsMigration = 1; export type WorkspaceKernelSql = >( strings: TemplateStringsArray, @@ -88,6 +89,44 @@ export function initializeWorkspaceKernelStorage(sql: WorkspaceKernelSql) { 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`; + applyWorkspaceKernelMigrations(sql); +} + +function applyWorkspaceKernelMigrations(sql: WorkspaceKernelSql) { + sql` + CREATE TABLE IF NOT EXISTS _sql_schema_migrations ( + id INTEGER PRIMARY KEY, + applied_at INTEGER NOT NULL + ) + `; + const appliedMigrationIds = new Set( + sql<{ id: number }>`SELECT id FROM _sql_schema_migrations`.map((row) => row.id), + ); + if (!appliedMigrationIds.has(retireLegacyLearningItemsMigration)) { + retireLegacyLearningItems(sql); + sql` + INSERT INTO _sql_schema_migrations (id, applied_at) + VALUES (${retireLegacyLearningItemsMigration}, ${Date.now()}) + `; + } +} + +function retireLegacyLearningItems(sql: WorkspaceKernelSql) { + const retiredAt = Date.now(); + sql` + DELETE FROM kernel_relations + WHERE from_item_id IN (SELECT id FROM kernel_items WHERE type IN ('flashcard', 'quiz')) + OR to_item_id IN (SELECT id FROM kernel_items WHERE type IN ('flashcard', 'quiz')) + `; + sql` + DELETE FROM kernel_item_projections + WHERE item_id IN (SELECT id FROM kernel_items WHERE type IN ('flashcard', 'quiz')) + `; + sql` + UPDATE kernel_items + SET deleted_at = COALESCE(deleted_at, ${retiredAt}), updated_at = ${retiredAt} + WHERE type IN ('flashcard', 'quiz') + `; } function createSiblingNameIndexes(sql: WorkspaceKernelSql) { From ce9ed12df79d973013a002179c2708b07e278198 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sat, 18 Jul 2026 00:14:12 -0400 Subject: [PATCH 07/16] refactor(workspaces): preserve future item surfaces Keep flashcards, quizzes, and audio recording as future workspace capabilities. Remove the speculative retirement migration. Use explicit menu groups instead of generic registry filters. --- .../components/WorkspaceCreateMenu.tsx | 35 +++++++----- src/features/workspaces/contracts.ts | 2 +- src/features/workspaces/defaults.ts | 8 +++ .../kernel/workspace-kernel-files.ts | 8 +++ .../kernel/workspace-kernel-schema.ts | 39 -------------- src/features/workspaces/model/item-display.ts | 53 ++++++++++++------- .../workspaces/model/object-registry.ts | 14 ++++- .../workspaces/model/workspace-item-colors.ts | 2 + .../operations/workspace-tool-schemas.ts | 2 +- 9 files changed, 89 insertions(+), 74 deletions(-) diff --git a/src/features/workspaces/components/WorkspaceCreateMenu.tsx b/src/features/workspaces/components/WorkspaceCreateMenu.tsx index 86aefb0e4..d86a9ea10 100644 --- a/src/features/workspaces/components/WorkspaceCreateMenu.tsx +++ b/src/features/workspaces/components/WorkspaceCreateMenu.tsx @@ -23,7 +23,8 @@ import { } from "#/features/workspaces/components/workspace-viewer-ui"; import type { WorkspaceItemType } from "#/features/workspaces/contracts"; import { - workspaceFileUploadAction, + workspaceItemAcquisitionActions, + workspaceItemLearnCreateActions, workspaceItemPrimaryCreateActions, } from "#/features/workspaces/model/item-display"; @@ -96,13 +97,6 @@ function getWorkspaceCreateMenuActions({ }: WorkspaceCreateMenuProps & { onUploadFile: (parentId: string | null) => void; }) { - const { - id: uploadActionId, - label: uploadActionLabel, - Icon: UploadIcon, - iconClassName: uploadIconClassName, - } = workspaceFileUploadAction; - return [ ...workspaceItemPrimaryCreateActions.map(({ type, label, Icon, iconClassName }) => ({ kind: "item" as const, @@ -111,12 +105,25 @@ function getWorkspaceCreateMenuActions({ leading: , onSelect: () => onCreateItem({ type, parentId }), })), - { + ...workspaceItemAcquisitionActions.map( + ({ id, label, description, Icon, iconClassName, disabled }) => ({ + kind: "item" as const, + id, + label, + trailing: description, + disabled, + leading: , + onSelect: id === "upload-file" ? () => onUploadFile(parentId) : undefined, + }), + ), + ...workspaceItemLearnCreateActions.map(({ type, label, Icon, iconClassName }) => ({ kind: "item" as const, - id: uploadActionId, - label: uploadActionLabel, - leading: , - onSelect: () => onUploadFile(parentId), - }, + id: type, + label, + trailing: "Soon", + disabled: true, + leading: , + onSelect: () => onCreateItem({ type, parentId }), + })), ]; } diff --git a/src/features/workspaces/contracts.ts b/src/features/workspaces/contracts.ts index d6bda7afe..901955304 100644 --- a/src/features/workspaces/contracts.ts +++ b/src/features/workspaces/contracts.ts @@ -190,7 +190,7 @@ export const workspaceSummarySchema = z.object({ membershipRole: workspaceMembershipRoleSchema, }); -export const workspaceItemTypeSchema = z.enum(["folder", "document", "file"]); +export const workspaceItemTypeSchema = z.enum(["folder", "document", "file", "flashcard", "quiz"]); export const workspaceItemFactsSchema = z.object({ itemId: z.string(), diff --git a/src/features/workspaces/defaults.ts b/src/features/workspaces/defaults.ts index 0ce98222e..f2b760227 100644 --- a/src/features/workspaces/defaults.ts +++ b/src/features/workspaces/defaults.ts @@ -17,6 +17,10 @@ export function getDefaultWorkspaceItemName(type: WorkspaceItemType) { return "New document"; case "file": return "New file"; + case "flashcard": + return "New flashcards"; + case "quiz": + return "New quiz"; } } @@ -28,6 +32,10 @@ export function getWorkspaceItemTypeMeta(type: WorkspaceItemType) { return "Document"; case "file": return "File"; + case "flashcard": + return "Flashcards"; + case "quiz": + return "Quiz"; } } diff --git a/src/features/workspaces/kernel/workspace-kernel-files.ts b/src/features/workspaces/kernel/workspace-kernel-files.ts index 4c815eebe..98ff5c078 100644 --- a/src/features/workspaces/kernel/workspace-kernel-files.ts +++ b/src/features/workspaces/kernel/workspace-kernel-files.ts @@ -19,6 +19,8 @@ export function getWorkspaceKernelFileShellPath(input: { itemId: string; extensi export function getWorkspaceKernelContentMimeType(type: WorkspaceItemType) { switch (type) { case "document": + case "flashcard": + case "quiz": return "application/json"; case "file": return "text/plain"; @@ -31,6 +33,10 @@ export function getInitialWorkspaceKernelContent(type: WorkspaceItemType) { switch (type) { 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 ""; @@ -40,6 +46,8 @@ export function getInitialWorkspaceKernelContent(type: WorkspaceItemType) { function getContentExtension(type: WorkspaceItemType) { switch (type) { case "document": + case "flashcard": + case "quiz": return "json"; case "file": return "txt"; diff --git a/src/features/workspaces/kernel/workspace-kernel-schema.ts b/src/features/workspaces/kernel/workspace-kernel-schema.ts index b14516dca..88eb76864 100644 --- a/src/features/workspaces/kernel/workspace-kernel-schema.ts +++ b/src/features/workspaces/kernel/workspace-kernel-schema.ts @@ -3,7 +3,6 @@ import { recordOperationalFailure } from "#/integrations/observability/operation export const workspaceRevisionKey = "workspace_revision"; export const workspaceItemSortStep = WORKSPACE_ITEM_SORT_STEP; -const retireLegacyLearningItemsMigration = 1; export type WorkspaceKernelSql = >( strings: TemplateStringsArray, @@ -89,44 +88,6 @@ export function initializeWorkspaceKernelStorage(sql: WorkspaceKernelSql) { 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`; - applyWorkspaceKernelMigrations(sql); -} - -function applyWorkspaceKernelMigrations(sql: WorkspaceKernelSql) { - sql` - CREATE TABLE IF NOT EXISTS _sql_schema_migrations ( - id INTEGER PRIMARY KEY, - applied_at INTEGER NOT NULL - ) - `; - const appliedMigrationIds = new Set( - sql<{ id: number }>`SELECT id FROM _sql_schema_migrations`.map((row) => row.id), - ); - if (!appliedMigrationIds.has(retireLegacyLearningItemsMigration)) { - retireLegacyLearningItems(sql); - sql` - INSERT INTO _sql_schema_migrations (id, applied_at) - VALUES (${retireLegacyLearningItemsMigration}, ${Date.now()}) - `; - } -} - -function retireLegacyLearningItems(sql: WorkspaceKernelSql) { - const retiredAt = Date.now(); - sql` - DELETE FROM kernel_relations - WHERE from_item_id IN (SELECT id FROM kernel_items WHERE type IN ('flashcard', 'quiz')) - OR to_item_id IN (SELECT id FROM kernel_items WHERE type IN ('flashcard', 'quiz')) - `; - sql` - DELETE FROM kernel_item_projections - WHERE item_id IN (SELECT id FROM kernel_items WHERE type IN ('flashcard', 'quiz')) - `; - sql` - UPDATE kernel_items - SET deleted_at = COALESCE(deleted_at, ${retiredAt}), updated_at = ${retiredAt} - WHERE type IN ('flashcard', 'quiz') - `; } function createSiblingNameIndexes(sql: WorkspaceKernelSql) { diff --git a/src/features/workspaces/model/item-display.ts b/src/features/workspaces/model/item-display.ts index 25db07ecd..4a641464d 100644 --- a/src/features/workspaces/model/item-display.ts +++ b/src/features/workspaces/model/item-display.ts @@ -1,4 +1,4 @@ -import { Upload } from "lucide-react"; +import { Mic, Upload } from "lucide-react"; import { getWorkspaceObjectRegistryEntry } from "#/features/workspaces/model/object-registry"; import type { WorkspaceItem } from "#/features/workspaces/model/types"; @@ -24,22 +24,39 @@ export function getWorkspaceItemDisplay(item: WorkspaceItem) { } const workspaceItemPrimaryCreateActionOrder = ["document", "folder"] as const; +const workspaceItemLearnCreateActionOrder = ["flashcard", "quiz"] as const; -export const workspaceItemPrimaryCreateActions = workspaceItemPrimaryCreateActionOrder.map( - (type) => { - const display = getWorkspaceObjectRegistryEntry(type); - return { - type, - label: display.menuLabel, - Icon: display.icon, - iconClassName: workspaceColors[workspaceItemTypeColors[type]].iconClassName, - }; +export const workspaceItemPrimaryCreateActions = + workspaceItemPrimaryCreateActionOrder.map(createWorkspaceItemAction); + +export const workspaceItemLearnCreateActions = + workspaceItemLearnCreateActionOrder.map(createWorkspaceItemAction); + +function createWorkspaceItemAction(type: "document" | "folder" | "flashcard" | "quiz") { + const display = getWorkspaceObjectRegistryEntry(type); + return { + type, + label: display.menuLabel, + Icon: display.icon, + iconClassName: workspaceColors[workspaceItemTypeColors[type]].iconClassName, + }; +} + +export const workspaceItemAcquisitionActions = [ + { + id: "upload-file", + label: "Upload", + description: undefined, + Icon: Upload, + iconClassName: workspaceColors[workspaceItemTypeColors.file].iconClassName, + disabled: false, + }, + { + id: "record-audio", + label: "Record", + description: "Soon", + Icon: Mic, + iconClassName: workspaceColors.orange.iconClassName, + disabled: true, }, -); - -export const workspaceFileUploadAction = { - id: "upload-file", - label: "Upload", - Icon: Upload, - iconClassName: workspaceColors[workspaceItemTypeColors.file].iconClassName, -}; +] as const; diff --git a/src/features/workspaces/model/object-registry.ts b/src/features/workspaces/model/object-registry.ts index 11b8b7c9d..2936d955f 100644 --- a/src/features/workspaces/model/object-registry.ts +++ b/src/features/workspaces/model/object-registry.ts @@ -1,4 +1,4 @@ -import { FilePen, Folder, type LucideIcon, Paperclip } from "lucide-react"; +import { FilePen, Folder, Layers3, ListChecks, type LucideIcon, Paperclip } from "lucide-react"; import type { WorkspaceItemType } from "#/features/workspaces/contracts"; interface WorkspaceItemRegistryEntry { @@ -27,6 +27,18 @@ export const workspaceObjectRegistry = { menuLabel: "Upload file", icon: Paperclip, }, + flashcard: { + type: "flashcard", + label: "Flashcard deck", + menuLabel: "Flashcards", + icon: Layers3, + }, + quiz: { + type: "quiz", + label: "Quiz", + menuLabel: "Quiz", + icon: ListChecks, + }, } satisfies Record; export function getWorkspaceObjectRegistryEntry(type: WorkspaceItemType) { diff --git a/src/features/workspaces/model/workspace-item-colors.ts b/src/features/workspaces/model/workspace-item-colors.ts index 427b66a77..7b2a1d015 100644 --- a/src/features/workspaces/model/workspace-item-colors.ts +++ b/src/features/workspaces/model/workspace-item-colors.ts @@ -12,7 +12,9 @@ import { export const workspaceItemTypeColors = { document: "sky", file: "rose", + flashcard: "violet", folder: "amber", + quiz: "emerald", } as const satisfies Record; export const workspaceItemColorOptions = workspaceColorOptions; diff --git a/src/features/workspaces/operations/workspace-tool-schemas.ts b/src/features/workspaces/operations/workspace-tool-schemas.ts index 1e6205051..59b36e1b8 100644 --- a/src/features/workspaces/operations/workspace-tool-schemas.ts +++ b/src/features/workspaces/operations/workspace-tool-schemas.ts @@ -53,7 +53,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"]), + type: z.enum(["folder", "document", "pdf", "image", "file", "flashcard", "quiz"]), }); const workspacePreviousPathItemSchema = workspacePathItemSchema.extend({ From cddb113b3305c51739910b345dfe75dfea81f56d Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sat, 18 Jul 2026 00:14:23 -0400 Subject: [PATCH 08/16] refactor(workspaces): deepen read boundaries Use the generated DocumentSession stub instead of a hand-written client mirror. Keep the narrow read seam at its actual test boundary. Hide kernel list selection and formatting behind one complete operation. --- .../content/workspace-content-reader.test.ts | 5 +--- .../content/workspace-content-reader.ts | 17 ++++++++++---- .../workspaces/document-session-access.ts | 20 ++-------------- .../kernel/workspace-kernel-list.ts | 23 +++++++++++++++---- .../workspaces/kernel/workspace-kernel.ts | 10 +++----- 5 files changed, 38 insertions(+), 37 deletions(-) diff --git a/src/features/workspaces/content/workspace-content-reader.test.ts b/src/features/workspaces/content/workspace-content-reader.test.ts index 9e5c2595a..cf9d45e7b 100644 --- a/src/features/workspaces/content/workspace-content-reader.test.ts +++ b/src/features/workspaces/content/workspace-content-reader.test.ts @@ -2,7 +2,6 @@ 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 type { DocumentSessionClient } from "#/features/workspaces/document-session-access"; 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"; @@ -187,8 +186,6 @@ describe("WorkspaceContentReader", () => { function createDocumentSession(snapshot: { markdown: string; revision: string }) { return { - applyMarkdownEdits: vi.fn(), - purgeForDeletion: vi.fn(), readMarkdownChunk: vi.fn(async ({ expectedRevision, offset }) => { if (expectedRevision && expectedRevision !== snapshot.revision) { return { status: "content_changed" as const }; @@ -198,7 +195,7 @@ function createDocumentSession(snapshot: { markdown: string; revision: string }) ? { ...chunk, revision: snapshot.revision, status: "ready" as const } : { status: "invalid_offset" as const }; }), - } satisfies DocumentSessionClient; + }; } function createKernel() { diff --git a/src/features/workspaces/content/workspace-content-reader.ts b/src/features/workspaces/content/workspace-content-reader.ts index d86870ce6..f03e231bb 100644 --- a/src/features/workspaces/content/workspace-content-reader.ts +++ b/src/features/workspaces/content/workspace-content-reader.ts @@ -3,7 +3,10 @@ import type { WorkspaceContentReadRequest, WorkspaceContentReadResult, } from "#/features/workspaces/content/workspace-content-contract"; -import type { DocumentSessionClient } from "#/features/workspaces/document-session-access"; +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"; @@ -16,6 +19,12 @@ import { const maxWorkspaceContentBatchBytes = 2 * 1024 * 1024 + 64 * 1024; +interface DocumentContentReader { + readMarkdownChunk( + input: DocumentMarkdownChunkReadInput, + ): Promise; +} + interface PendingReadyResult { item: WorkspaceItemSummary; read: Extract; @@ -28,7 +37,7 @@ export interface WorkspaceContentReader { export function createWorkspaceContentReader(input: { bucket: R2Bucket; - getDocumentSession: (itemId: string) => DocumentSessionClient; + getDocumentSession: (itemId: string) => DocumentContentReader; kernel: WorkspaceKernelClient; }): WorkspaceContentReader { return { @@ -111,7 +120,7 @@ export function createWorkspaceContentReader(input: { async function readWorkspaceItem(input: { bucket: R2Bucket; - getDocumentSession: (itemId: string) => DocumentSessionClient; + getDocumentSession: (itemId: string) => DocumentContentReader; item: WorkspaceItemSummary; kernel: WorkspaceKernelClient; path: string; @@ -127,7 +136,7 @@ async function readWorkspaceItem(input: { } async function readDocument(input: { - getDocumentSession: (itemId: string) => DocumentSessionClient; + getDocumentSession: (itemId: string) => DocumentContentReader; item: WorkspaceItemSummary; path: string; request: WorkspaceContentReadRequest; diff --git a/src/features/workspaces/document-session-access.ts b/src/features/workspaces/document-session-access.ts index 6426da432..993298df0 100644 --- a/src/features/workspaces/document-session-access.ts +++ b/src/features/workspaces/document-session-access.ts @@ -1,24 +1,8 @@ import { getDocumentSessionRoomName } from "#/features/workspaces/agent-routes"; -import type { - DocumentMarkdownChunkReadInput, - DocumentMarkdownChunkReadResult, -} from "#/features/workspaces/documents/document-markdown-chunk"; -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; - readMarkdownChunk( - input: DocumentMarkdownChunkReadInput, - ): 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/kernel/workspace-kernel-list.ts b/src/features/workspaces/kernel/workspace-kernel-list.ts index 8d65257f0..3c4b4f383 100644 --- a/src/features/workspaces/kernel/workspace-kernel-list.ts +++ b/src/features/workspaces/kernel/workspace-kernel-list.ts @@ -36,7 +36,7 @@ export interface ListWorkspaceKernelItemsFailure { path: string; } -export interface WorkspaceKernelListSelection { +interface WorkspaceKernelListSelection { failed: ListWorkspaceKernelItemsFailure[]; path: string; rows: WorkspaceKernelListRow[]; @@ -44,12 +44,27 @@ export interface WorkspaceKernelListSelection { nextOffset?: number; } -export interface WorkspaceKernelListRow { +interface WorkspaceKernelListRow { item: WorkspaceItemSummary; path: string; } -export function selectWorkspaceKernelTreeItems(input: { +export function listWorkspaceKernelTreeItems(input: { + getItemFacts: (items: WorkspaceItemSummary[]) => WorkspaceItemFacts[]; + tree: WorkspaceKernelTree; + 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; @@ -95,7 +110,7 @@ export function selectWorkspaceKernelTreeItems(input: { } } -export function formatWorkspaceKernelListSelection( +function formatWorkspaceKernelListSelection( selection: WorkspaceKernelListSelection, itemFacts: WorkspaceItemFacts[], ): ListWorkspaceKernelItemsResult { diff --git a/src/features/workspaces/kernel/workspace-kernel.ts b/src/features/workspaces/kernel/workspace-kernel.ts index a490f75c1..474a3717b 100644 --- a/src/features/workspaces/kernel/workspace-kernel.ts +++ b/src/features/workspaces/kernel/workspace-kernel.ts @@ -18,8 +18,7 @@ import { } from "#/features/workspaces/kernel/workspace-kernel-schema"; import { WorkspaceKernelRelations } from "#/features/workspaces/kernel/workspace-kernel-relations"; import { - formatWorkspaceKernelListSelection, - selectWorkspaceKernelTreeItems, + listWorkspaceKernelTreeItems, type ListWorkspaceKernelItemsResult, } from "#/features/workspaces/kernel/workspace-kernel-list"; import { @@ -143,14 +142,11 @@ export class WorkspaceKernel extends Agent { input: ListWorkspaceKernelItemsArgs = {}, ): Promise { const items = this.store.getPageItems(); - const selection = selectWorkspaceKernelTreeItems({ + return listWorkspaceKernelTreeItems({ + getItemFacts: (listedItems) => this.store.getItemFacts(listedItems), tree: buildWorkspaceKernelTree(items), ...input, }); - return formatWorkspaceKernelListSelection( - selection, - this.store.getItemFacts(selection.rows.map((row) => row.item)), - ); } async resolvePaths( From e4a9178782a075489a4719966d60a49a0267f072 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sat, 18 Jul 2026 00:21:55 -0400 Subject: [PATCH 09/16] fix(workspaces): tighten content boundaries Keep document chunks on valid UTF-16 boundaries and ignore unknown realtime messages. Require stable item IDs for idempotent create commands. --- src/features/workspaces/contracts.ts | 2 +- .../documents/document-markdown-chunk.test.ts | 14 ++++++++++++++ .../documents/document-markdown-chunk.ts | 9 ++++++++- .../kernel/workspace-kernel-item-commands.ts | 15 +++++++-------- .../workspaces/kernel/workspace-kernel-types.ts | 2 +- .../workspaces/realtime/use-workspace-presence.ts | 1 - 6 files changed, 31 insertions(+), 12 deletions(-) diff --git a/src/features/workspaces/contracts.ts b/src/features/workspaces/contracts.ts index 901955304..942c8e55c 100644 --- a/src/features/workspaces/contracts.ts +++ b/src/features/workspaces/contracts.ts @@ -216,7 +216,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, diff --git a/src/features/workspaces/documents/document-markdown-chunk.test.ts b/src/features/workspaces/documents/document-markdown-chunk.test.ts index 132a49ad5..2da2607b8 100644 --- a/src/features/workspaces/documents/document-markdown-chunk.test.ts +++ b/src/features/workspaces/documents/document-markdown-chunk.test.ts @@ -24,4 +24,18 @@ describe("document Markdown 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 index 4098699c8..52e9ca754 100644 --- a/src/features/workspaces/documents/document-markdown-chunk.ts +++ b/src/features/workspaces/documents/document-markdown-chunk.ts @@ -34,7 +34,8 @@ export function createDocumentMarkdownSnapshot(markdown: string): DocumentMarkdo return undefined; } - const hardEnd = Math.min(markdown.length, offset + maxDocumentChunkCharacters); + 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 @@ -55,6 +56,12 @@ export function createDocumentMarkdownSnapshot(markdown: string): DocumentMarkdo }; } +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 []; diff --git a/src/features/workspaces/kernel/workspace-kernel-item-commands.ts b/src/features/workspaces/kernel/workspace-kernel-item-commands.ts index c93664750..db8d9300f 100644 --- a/src/features/workspaces/kernel/workspace-kernel-item-commands.ts +++ b/src/features/workspaces/kernel/workspace-kernel-item-commands.ts @@ -75,16 +75,15 @@ 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 storedEvent = - input.id && input.clientMutationId - ? this.events.findCreatedItemEvent({ - clientMutationId: input.clientMutationId, - itemId: input.id, - }) - : null; + const storedEvent = input.clientMutationId + ? this.events.findCreatedItemEvent({ + clientMutationId: input.clientMutationId, + itemId: id, + }) + : null; if (!storedEvent) { return null; diff --git a/src/features/workspaces/kernel/workspace-kernel-types.ts b/src/features/workspaces/kernel/workspace-kernel-types.ts index 73fa9c840..cf4ef4c0a 100644 --- a/src/features/workspaces/kernel/workspace-kernel-types.ts +++ b/src/features/workspaces/kernel/workspace-kernel-types.ts @@ -116,7 +116,7 @@ export function requireAppliedWorkspaceKernelMutation( } export interface CreateWorkspaceKernelItemArgs { - id?: string; + id: string; parentId?: string | null; type: WorkspaceItemType; name?: string; diff --git a/src/features/workspaces/realtime/use-workspace-presence.ts b/src/features/workspaces/realtime/use-workspace-presence.ts index 5fc152cd4..a4f95402b 100644 --- a/src/features/workspaces/realtime/use-workspace-presence.ts +++ b/src/features/workspaces/realtime/use-workspace-presence.ts @@ -128,7 +128,6 @@ export function useWorkspaceRealtime({ (event: MessageEvent) => { const message = parseServerMessage(event.data); if (!message) { - onDesyncRef.current?.(); return; } From 837a7d9ca4e1c8510a0015b474a2a29c3c2b1663 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sat, 18 Jul 2026 00:46:52 -0400 Subject: [PATCH 10/16] refactor(core): consolidate binary encoding Use one Base64URL codec and SHA-256 helper. Share it across upload tokens, content cursors, extraction identities, and document revisions. --- .../content/workspace-content-cursor.ts | 8 ++-- .../workspaces/documents/document-session.ts | 6 +-- src/features/workspaces/extraction/binary.ts | 22 ----------- .../request-workspace-file-extraction.ts | 2 +- .../upload/workspace-file-direct-upload.ts | 23 +---------- src/lib/binary.ts | 39 +++++++++++++++++++ 6 files changed, 47 insertions(+), 53 deletions(-) delete mode 100644 src/features/workspaces/extraction/binary.ts create mode 100644 src/lib/binary.ts diff --git a/src/features/workspaces/content/workspace-content-cursor.ts b/src/features/workspaces/content/workspace-content-cursor.ts index dfc580a38..1bb704e36 100644 --- a/src/features/workspaces/content/workspace-content-cursor.ts +++ b/src/features/workspaces/content/workspace-content-cursor.ts @@ -1,5 +1,7 @@ import { z } from "zod"; +import { decodeBase64UrlText, encodeBase64UrlText } from "#/lib/binary"; + const workspaceContentCursorSchema = z.discriminatedUnion("kind", [ z.object({ itemId: z.string().min(1), @@ -20,7 +22,7 @@ const workspaceContentCursorSchema = z.discriminatedUnion("kind", [ export type WorkspaceContentCursor = z.infer; export function encodeWorkspaceContentCursor(cursor: WorkspaceContentCursor) { - return btoa(JSON.stringify(cursor)).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, ""); + return encodeBase64UrlText(JSON.stringify(cursor)); } export function decodeWorkspaceContentCursor(value: string): WorkspaceContentCursor | null { @@ -28,9 +30,7 @@ export function decodeWorkspaceContentCursor(value: string): WorkspaceContentCur return null; } try { - const base64 = value.replaceAll("-", "+").replaceAll("_", "/"); - const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "="); - return workspaceContentCursorSchema.parse(JSON.parse(atob(padded))); + return workspaceContentCursorSchema.parse(JSON.parse(decodeBase64UrlText(value))); } catch { return null; } diff --git a/src/features/workspaces/documents/document-session.ts b/src/features/workspaces/documents/document-session.ts index b6f03572c..d9ee7f9dc 100644 --- a/src/features/workspaces/documents/document-session.ts +++ b/src/features/workspaces/documents/document-session.ts @@ -42,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; @@ -197,11 +198,8 @@ export class DocumentSession extends YServer { let currentSnapshot = this.markdownSnapshot; if (!currentSnapshot || !uint8ArraysEqual(currentSnapshot.stateVector, stateVector)) { const markdown = serializeTiptapDocumentToMarkdown(this.getCurrentTiptapDocument()); - const revisionBytes = await crypto.subtle.digest("SHA-256", stateVector.buffer); currentSnapshot = { - revision: Array.from(new Uint8Array(revisionBytes), (byte) => - byte.toString(16).padStart(2, "0"), - ).join(""), + revision: await sha256Base64Url(stateVector), snapshot: createDocumentMarkdownSnapshot(markdown), stateVector, }; 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/request-workspace-file-extraction.ts b/src/features/workspaces/extraction/request-workspace-file-extraction.ts index 810466c07..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"; 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/lib/binary.ts b/src/lib/binary.ts new file mode 100644 index 000000000..681199497 --- /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 await sha256Base64Url(textEncoder.encode(value)); +} From e48391cae6b342351e2fce463d20c30c39f7821d Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sat, 18 Jul 2026 00:47:03 -0400 Subject: [PATCH 11/16] refactor(workspaces): centralize item policy Define item schemas, names, labels, colors, and storage metadata in one server-safe registry. Keep React icons in the UI adapter. --- .../WorkspaceClipboardIntakeDialog.tsx | 6 +- src/features/workspaces/contracts.ts | 11 ++- src/features/workspaces/defaults.ts | 27 +------- .../kernel/workspace-kernel-files.ts | 35 ++-------- src/features/workspaces/model/item-display.ts | 9 +-- .../workspaces/model/object-registry.ts | 54 ++++----------- .../workspaces/model/workspace-file/index.ts | 15 +++-- .../workspaces/model/workspace-file/types.ts | 3 + .../workspaces/model/workspace-item-colors.ts | 15 ++--- .../operations/workspace-tool-schemas.ts | 3 +- .../workspaces/workspace-item-registry.ts | 67 +++++++++++++++++++ 11 files changed, 122 insertions(+), 123 deletions(-) create mode 100644 src/features/workspaces/workspace-item-registry.ts 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/contracts.ts b/src/features/workspaces/contracts.ts index 942c8e55c..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(), @@ -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/kernel/workspace-kernel-files.ts b/src/features/workspaces/kernel/workspace-kernel-files.ts index 98ff5c078..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,41 +19,18 @@ export function getWorkspaceKernelFileShellPath(input: { itemId: string; extensi } export function getWorkspaceKernelContentMimeType(type: WorkspaceItemType) { - switch (type) { - case "document": - 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) { - switch (type) { + 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": - case "flashcard": - case "quiz": - return "json"; - case "file": - return "txt"; - case "folder": + case "empty": return ""; } } diff --git a/src/features/workspaces/model/item-display.ts b/src/features/workspaces/model/item-display.ts index 4a641464d..3bd89d117 100644 --- a/src/features/workspaces/model/item-display.ts +++ b/src/features/workspaces/model/item-display.ts @@ -4,10 +4,7 @@ import { getWorkspaceObjectRegistryEntry } from "#/features/workspaces/model/obj 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); @@ -38,7 +35,7 @@ function createWorkspaceItemAction(type: "document" | "folder" | "flashcard" | " type, label: display.menuLabel, Icon: display.icon, - iconClassName: workspaceColors[workspaceItemTypeColors[type]].iconClassName, + iconClassName: workspaceColors[display.color].iconClassName, }; } @@ -48,7 +45,7 @@ export const workspaceItemAcquisitionActions = [ label: "Upload", description: undefined, Icon: Upload, - iconClassName: workspaceColors[workspaceItemTypeColors.file].iconClassName, + iconClassName: workspaceColors[getWorkspaceObjectRegistryEntry("file").color].iconClassName, disabled: false, }, { diff --git a/src/features/workspaces/model/object-registry.ts b/src/features/workspaces/model/object-registry.ts index 2936d955f..229436aa7 100644 --- a/src/features/workspaces/model/object-registry.ts +++ b/src/features/workspaces/model/object-registry.ts @@ -1,46 +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"; -interface WorkspaceItemRegistryEntry { - type: WorkspaceItemType; - label: string; - menuLabel: string; - icon: LucideIcon; -} +import { getWorkspaceItemRegistryEntry } from "#/features/workspaces/workspace-item-registry"; -export const workspaceObjectRegistry = { - folder: { - type: "folder", - label: "Folder", - menuLabel: "Folder", - icon: Folder, - }, - document: { - type: "document", - label: "Document", - menuLabel: "Document", - icon: FilePen, - }, - file: { - type: "file", - label: "File", - menuLabel: "Upload file", - icon: Paperclip, - }, - flashcard: { - type: "flashcard", - label: "Flashcard deck", - menuLabel: "Flashcards", - icon: Layers3, - }, - quiz: { - type: "quiz", - label: "Quiz", - menuLabel: "Quiz", - 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, + }; } 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/operations/workspace-tool-schemas.ts b/src/features/workspaces/operations/workspace-tool-schemas.ts index 59b36e1b8..27991c3c6 100644 --- a/src/features/workspaces/operations/workspace-tool-schemas.ts +++ b/src/features/workspaces/operations/workspace-tool-schemas.ts @@ -15,6 +15,7 @@ import { 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 }; @@ -53,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({ 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]; +} From e9547f6b4a2483cd4a1de37ad1a89745c56c97a0 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sat, 18 Jul 2026 00:47:18 -0400 Subject: [PATCH 12/16] refactor(workspaces): separate mutation receipts Keep realtime event payloads immutable. Store idempotency identity in a dedicated receipt table keyed by client mutation ID. --- .../kernel/workspace-kernel-events.test.ts | 99 +++++++++++++------ .../kernel/workspace-kernel-events.ts | 82 +++++++-------- .../kernel/workspace-kernel-file-commands.ts | 40 +++++--- .../kernel/workspace-kernel-item-commands.ts | 20 ++-- .../kernel/workspace-kernel-schema.ts | 11 ++- 5 files changed, 149 insertions(+), 103 deletions(-) diff --git a/src/features/workspaces/kernel/workspace-kernel-events.test.ts b/src/features/workspaces/kernel/workspace-kernel-events.test.ts index bda366186..c78a4615a 100644 --- a/src/features/workspaces/kernel/workspace-kernel-events.test.ts +++ b/src/features/workspaces/kernel/workspace-kernel-events.test.ts @@ -13,9 +13,10 @@ describe("workspace kernel event idempotency", () => { const events = createEventBus(createItemEventRow("item-1")); expect( - events.findCreatedItemEvent({ + events.findMutationEvent({ clientMutationId: "mutation-1", - itemId: "item-1", + eventType: "workspace.item.created", + resultId: "item-1", }), ).toMatchObject({ clientMutationId: "mutation-1", @@ -24,30 +25,45 @@ describe("workspace kernel event idempotency", () => { }); }); + it("records a mutation receipt beside an idempotent event", () => { + const statements: string[] = []; + const sql: WorkspaceKernelSql = (strings) => { + statements.push(strings.join(" ")); + return []; + }; + const events = new WorkspaceKernelEventBus({ + broadcast: vi.fn(), + getNextRevision: () => 1, + sql, + workspaceId: () => "workspace-1", + }); + + events.commit( + { + actorUserId: null, + clientMutationId: "mutation-1", + payload: { item: createItem(), itemFacts: [] }, + type: "workspace.item.created", + }, + { resultId: "item-1" }, + ); + + expect(statements.some((statement) => statement.includes("kernel_mutation_receipts"))).toBe( + true, + ); + }); + it("hydrates historical creation events with the current payload shape", () => { - const events = createEventBus(createItemEventRow("item-1", false)); - const storedEvent = events.findCreatedItemEvent({ + const events = createEventBus(createItemEventRow("item-1")); + const storedEvent = events.findMutationEvent({ clientMutationId: "mutation-1", - itemId: "item-1", + eventType: "workspace.item.created", + resultId: "item-1", }); if (!storedEvent) { throw new Error("Expected a stored creation event."); } - const item = { - color: null, - createdAt: "2026-07-15T00:00:00.000Z", - deletedAt: null, - id: "item-1", - meta: "Document", - metadataJson: {}, - name: "Notes", - parentId: null, - sortOrder: 1, - title: "Notes", - type: "document" as const, - updatedAt: "2026-07-15T00:00:00.000Z", - workspaceId: "workspace-1", - }; + const item = createItem(); expect(hydrateCreatedItemEvent(storedEvent, item, [])).toMatchObject({ payload: { item, itemFacts: [] }, @@ -59,18 +75,20 @@ describe("workspace kernel event idempotency", () => { const events = createEventBus(createItemEventRow("item-1")); expect(() => - events.findCreatedItemEvent({ + events.findMutationEvent({ clientMutationId: "mutation-1", - itemId: "item-2", + eventType: "workspace.item.created", + resultId: "item-2", }), ).toThrow("client mutation id was already used"); }); it("recovers and hydrates a committed projection update", () => { const events = createEventBus(createProjectionEventRow("item-1")); - const storedEvent = events.findProjectionEvent({ + const storedEvent = events.findMutationEvent({ clientMutationId: "mutation-1", - itemId: "item-1", + eventType: "workspace.item.projection.updated", + resultId: "item-1", }); if (!storedEvent) { throw new Error("Expected a stored projection event."); @@ -85,7 +103,7 @@ describe("workspace kernel event idempotency", () => { }); }); -function createEventBus(row: KernelEventRow) { +function createEventBus(row: KernelEventRow & { result_id: string }) { const sql = vi.fn(() => [row]) as unknown as WorkspaceKernelSql; return new WorkspaceKernelEventBus({ broadcast: vi.fn(), @@ -95,29 +113,46 @@ function createEventBus(row: KernelEventRow) { }); } -function createProjectionEventRow(itemId: string): KernelEventRow { +function createProjectionEventRow(itemId: string): KernelEventRow & { result_id: string } { return { actor_user_id: null, client_mutation_id: "mutation-1", created_at: Date.parse("2026-07-15T00:00:00Z"), id: "event-1", - payload_json: JSON.stringify({ itemFacts: [{ itemId }] }), + payload_json: "{}", revision: 1, + result_id: itemId, type: "workspace.item.projection.updated", }; } -function createItemEventRow(itemId: string, includeFacts = true): KernelEventRow { +function createItemEventRow(itemId: string): KernelEventRow & { result_id: string } { 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 }, - ...(includeFacts ? { itemFacts: [] } : {}), - }), + payload_json: "{}", revision: 1, + result_id: itemId, type: "workspace.item.created", }; } + +function createItem() { + return { + color: null, + createdAt: "2026-07-15T00:00:00.000Z", + deletedAt: null, + id: "item-1", + meta: "Document", + metadataJson: {}, + name: "Notes", + parentId: null, + sortOrder: 1, + title: "Notes", + type: "document" as const, + updatedAt: "2026-07-15T00:00:00.000Z", + workspaceId: "workspace-1", + }; +} diff --git a/src/features/workspaces/kernel/workspace-kernel-events.ts b/src/features/workspaces/kernel/workspace-kernel-events.ts index a94942477..ce09e4e9e 100644 --- a/src/features/workspaces/kernel/workspace-kernel-events.ts +++ b/src/features/workspaces/kernel/workspace-kernel-events.ts @@ -1,4 +1,3 @@ -import { z } from "zod"; import type { KernelEventRow } from "#/features/workspaces/kernel/workspace-kernel-rows"; import type { WorkspaceKernelSql } from "#/features/workspaces/kernel/workspace-kernel-schema"; import type { WorkspaceItemFacts, WorkspaceItemSummary } from "#/features/workspaces/contracts"; @@ -12,13 +11,7 @@ type WorkspaceKernelEventIdentity = Pick< "actorUserId" | "clientMutationId" | "createdAt" | "id" | "revision" | "workspaceId" >; -const storedCreatedItemPayloadSchema = z.object({ - item: z.object({ id: z.string() }), -}); - -const storedProjectionPayloadSchema = z.object({ - itemFacts: z.array(z.object({ itemId: z.string() })), -}); +type MutationReceiptEventRow = KernelEventRow & { result_id: string }; export class WorkspaceKernelEventBus { private readonly sql: WorkspaceKernelSql; @@ -38,34 +31,10 @@ export class WorkspaceKernelEventBus { this.broadcast = input.broadcast; } - findCreatedItemEvent(input: { clientMutationId: string; itemId: string }) { - const row = this.findEventRow("workspace.item.created", input.clientMutationId); - if (!row) { - return null; - } - - const payload = storedCreatedItemPayloadSchema.parse(JSON.parse(row.payload_json)); - if (payload.item.id !== input.itemId) { - throw new Error("Workspace client mutation id was already used."); - } - - return mapEventIdentity(row, this.workspaceId()); - } - - findProjectionEvent(input: { clientMutationId: string; itemId: string }) { - const row = this.findEventRow("workspace.item.projection.updated", input.clientMutationId); - if (!row) { - return null; - } - const payload = storedProjectionPayloadSchema.parse(JSON.parse(row.payload_json)); - if (!payload.itemFacts.some((facts) => facts.itemId === input.itemId)) { - throw new Error("Workspace client mutation id was already used."); - } - - return mapEventIdentity(row, this.workspaceId()); - } - - commit(input: Omit) { + commit( + input: Omit, + receipt?: { resultId: string }, + ) { const createdAt = Date.now(); const event = { id: crypto.randomUUID(), @@ -95,6 +64,22 @@ export class WorkspaceKernelEventBus { ${createdAt} ) `; + if (event.clientMutationId && receipt) { + this.sql` + INSERT INTO kernel_mutation_receipts ( + client_mutation_id, + result_id, + event_id, + created_at + ) + VALUES ( + ${event.clientMutationId}, + ${receipt.resultId}, + ${event.id}, + ${createdAt} + ) + `; + } this.broadcast({ type: "workspace.event", workspaceId: this.workspaceId(), @@ -104,16 +89,25 @@ export class WorkspaceKernelEventBus { return event; } - private findEventRow(type: WorkspaceRealtimeEvent["type"], clientMutationId: string) { - const [row] = this.sql` - SELECT * - FROM kernel_events - WHERE type = ${type} - AND client_mutation_id = ${clientMutationId} - ORDER BY revision ASC + findMutationEvent(input: { + clientMutationId: string; + eventType: WorkspaceRealtimeEvent["type"]; + resultId: string; + }) { + const [row] = this.sql` + SELECT kernel_events.*, kernel_mutation_receipts.result_id + FROM kernel_mutation_receipts + INNER JOIN kernel_events ON kernel_events.id = kernel_mutation_receipts.event_id + WHERE kernel_mutation_receipts.client_mutation_id = ${input.clientMutationId} LIMIT 1 `; - return row ?? null; + if (!row) { + return null; + } + if (row.type !== input.eventType || row.result_id !== input.resultId) { + throw new Error("Workspace client mutation id was already used."); + } + return mapEventIdentity(row, this.workspaceId()); } } diff --git a/src/features/workspaces/kernel/workspace-kernel-file-commands.ts b/src/features/workspaces/kernel/workspace-kernel-file-commands.ts index 09aafb314..8404f330d 100644 --- a/src/features/workspaces/kernel/workspace-kernel-file-commands.ts +++ b/src/features/workspaces/kernel/workspace-kernel-file-commands.ts @@ -66,9 +66,10 @@ export class WorkspaceKernelFileCommands { const parentId = input.parentId ?? null; const getPriorResult = () => { const storedEvent = input.clientMutationId - ? this.events.findCreatedItemEvent({ + ? this.events.findMutationEvent({ clientMutationId: input.clientMutationId, - itemId: input.id, + eventType: "workspace.item.created", + resultId: input.id, }) : null; @@ -210,12 +211,15 @@ 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, itemFacts }, - }); + const event = this.events.commit( + { + type: "workspace.item.created", + actorUserId: input.actorUserId ?? null, + clientMutationId: input.clientMutationId ?? null, + payload: { item, itemFacts }, + }, + { resultId: itemId }, + ); return { result: item, event }; } @@ -289,9 +293,10 @@ export class WorkspaceKernelFileCommands { throw new Error("Workspace item is not a file."); } const storedEvent = input.clientMutationId - ? this.events.findProjectionEvent({ + ? this.events.findMutationEvent({ clientMutationId: input.clientMutationId, - itemId: input.itemId, + eventType: "workspace.item.projection.updated", + resultId: input.itemId, }) : null; if (storedEvent) { @@ -322,12 +327,15 @@ export class WorkspaceKernelFileCommands { 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 }, - }); + const event = this.events.commit( + { + type: "workspace.item.projection.updated", + actorUserId: input.actorUserId ?? null, + clientMutationId: input.clientMutationId ?? null, + payload: { itemFacts }, + }, + { resultId: input.itemId }, + ); return { event, result: itemFacts }; } diff --git a/src/features/workspaces/kernel/workspace-kernel-item-commands.ts b/src/features/workspaces/kernel/workspace-kernel-item-commands.ts index db8d9300f..134856709 100644 --- a/src/features/workspaces/kernel/workspace-kernel-item-commands.ts +++ b/src/features/workspaces/kernel/workspace-kernel-item-commands.ts @@ -79,9 +79,10 @@ export class WorkspaceKernelItemCommands { const parentId = input.parentId ?? null; const getPriorResult = () => { const storedEvent = input.clientMutationId - ? this.events.findCreatedItemEvent({ + ? this.events.findMutationEvent({ clientMutationId: input.clientMutationId, - itemId: id, + eventType: "workspace.item.created", + resultId: id, }) : null; @@ -190,12 +191,15 @@ export class WorkspaceKernelItemCommands { 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, itemFacts }, - }); + const event = this.events.commit( + { + type: "workspace.item.created", + actorUserId: input.actorUserId ?? null, + clientMutationId: input.clientMutationId ?? null, + payload: { item, itemFacts }, + }, + { resultId: id }, + ); return { command: { result: item, event }, status: "applied" }; } diff --git a/src/features/workspaces/kernel/workspace-kernel-schema.ts b/src/features/workspaces/kernel/workspace-kernel-schema.ts index 88eb76864..93dd7d6d2 100644 --- a/src/features/workspaces/kernel/workspace-kernel-schema.ts +++ b/src/features/workspaces/kernel/workspace-kernel-schema.ts @@ -85,9 +85,14 @@ 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`; + sql` + CREATE TABLE IF NOT EXISTS kernel_mutation_receipts ( + client_mutation_id TEXT PRIMARY KEY, + result_id TEXT NOT NULL, + event_id TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL + ) + `; } function createSiblingNameIndexes(sql: WorkspaceKernelSql) { From a6368c4d3ee33a51b126665c663a30aa77a8c588 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sat, 18 Jul 2026 00:49:09 -0400 Subject: [PATCH 13/16] fix(workspaces): align kernel create contract Reuse the canonical create command input so kernel clients cannot omit stable item IDs. --- .../kernel/workspace-kernel-access.ts | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/src/features/workspaces/kernel/workspace-kernel-access.ts b/src/features/workspaces/kernel/workspace-kernel-access.ts index 527f4c48d..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,7 +14,7 @@ import type { import { requireAppliedWorkspaceKernelMutation, type CreateWorkspaceKernelFileFromUploadArgs, - type CreateWorkspaceKernelRelationArgs, + type CreateWorkspaceKernelItemArgs, type DeleteWorkspaceKernelItemsResult, type GetWorkspaceKernelItemPathsArgs, type ListWorkspaceKernelItemRelationsArgs, @@ -64,19 +63,9 @@ export interface WorkspaceKernelClient { 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>; From 1b72b862d51b0118ea36537215f63afbf402f8cc Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sat, 18 Jul 2026 00:59:57 -0400 Subject: [PATCH 14/16] refactor(workspaces): remove mutation replay receipts --- .../kernel/workspace-kernel-events.test.ts | 158 ------------------ .../kernel/workspace-kernel-events.ts | 85 +--------- .../kernel/workspace-kernel-file-commands.ts | 75 ++------- .../kernel/workspace-kernel-item-commands.ts | 48 +----- .../kernel/workspace-kernel-rows.ts | 10 -- .../kernel/workspace-kernel-schema.ts | 8 - 6 files changed, 21 insertions(+), 363 deletions(-) delete mode 100644 src/features/workspaces/kernel/workspace-kernel-events.test.ts 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 c78a4615a..000000000 --- a/src/features/workspaces/kernel/workspace-kernel-events.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import { - hydrateCreatedItemEvent, - hydrateProjectionEvent, - 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.findMutationEvent({ - clientMutationId: "mutation-1", - eventType: "workspace.item.created", - resultId: "item-1", - }), - ).toMatchObject({ - clientMutationId: "mutation-1", - id: "event-1", - revision: 1, - }); - }); - - it("records a mutation receipt beside an idempotent event", () => { - const statements: string[] = []; - const sql: WorkspaceKernelSql = (strings) => { - statements.push(strings.join(" ")); - return []; - }; - const events = new WorkspaceKernelEventBus({ - broadcast: vi.fn(), - getNextRevision: () => 1, - sql, - workspaceId: () => "workspace-1", - }); - - events.commit( - { - actorUserId: null, - clientMutationId: "mutation-1", - payload: { item: createItem(), itemFacts: [] }, - type: "workspace.item.created", - }, - { resultId: "item-1" }, - ); - - expect(statements.some((statement) => statement.includes("kernel_mutation_receipts"))).toBe( - true, - ); - }); - - it("hydrates historical creation events with the current payload shape", () => { - const events = createEventBus(createItemEventRow("item-1")); - const storedEvent = events.findMutationEvent({ - clientMutationId: "mutation-1", - eventType: "workspace.item.created", - resultId: "item-1", - }); - if (!storedEvent) { - throw new Error("Expected a stored creation event."); - } - const item = createItem(); - - expect(hydrateCreatedItemEvent(storedEvent, item, [])).toMatchObject({ - payload: { item, itemFacts: [] }, - type: "workspace.item.created", - }); - }); - - it("rejects reuse of a client mutation id for another item", () => { - const events = createEventBus(createItemEventRow("item-1")); - - expect(() => - events.findMutationEvent({ - clientMutationId: "mutation-1", - eventType: "workspace.item.created", - resultId: "item-2", - }), - ).toThrow("client mutation id was already used"); - }); - - it("recovers and hydrates a committed projection update", () => { - const events = createEventBus(createProjectionEventRow("item-1")); - const storedEvent = events.findMutationEvent({ - clientMutationId: "mutation-1", - eventType: "workspace.item.projection.updated", - resultId: "item-1", - }); - if (!storedEvent) { - throw new Error("Expected a stored projection event."); - } - const itemFacts = [{ itemId: "item-1", pageCount: 3, relationshipCount: 0 }]; - - expect(hydrateProjectionEvent(storedEvent, itemFacts)).toMatchObject({ - clientMutationId: "mutation-1", - payload: { itemFacts }, - type: "workspace.item.projection.updated", - }); - }); -}); - -function createEventBus(row: KernelEventRow & { result_id: string }) { - const sql = vi.fn(() => [row]) as unknown as WorkspaceKernelSql; - return new WorkspaceKernelEventBus({ - broadcast: vi.fn(), - getNextRevision: () => 2, - sql, - workspaceId: () => "workspace-1", - }); -} - -function createProjectionEventRow(itemId: string): KernelEventRow & { result_id: string } { - return { - actor_user_id: null, - client_mutation_id: "mutation-1", - created_at: Date.parse("2026-07-15T00:00:00Z"), - id: "event-1", - payload_json: "{}", - revision: 1, - result_id: itemId, - type: "workspace.item.projection.updated", - }; -} - -function createItemEventRow(itemId: string): KernelEventRow & { result_id: string } { - 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: "{}", - revision: 1, - result_id: itemId, - type: "workspace.item.created", - }; -} - -function createItem() { - return { - color: null, - createdAt: "2026-07-15T00:00:00.000Z", - deletedAt: null, - id: "item-1", - meta: "Document", - metadataJson: {}, - name: "Notes", - parentId: null, - sortOrder: 1, - title: "Notes", - type: "document" as const, - updatedAt: "2026-07-15T00:00:00.000Z", - workspaceId: "workspace-1", - }; -} diff --git a/src/features/workspaces/kernel/workspace-kernel-events.ts b/src/features/workspaces/kernel/workspace-kernel-events.ts index ce09e4e9e..1b1edcd6c 100644 --- a/src/features/workspaces/kernel/workspace-kernel-events.ts +++ b/src/features/workspaces/kernel/workspace-kernel-events.ts @@ -1,18 +1,9 @@ -import type { KernelEventRow } from "#/features/workspaces/kernel/workspace-kernel-rows"; import type { WorkspaceKernelSql } from "#/features/workspaces/kernel/workspace-kernel-schema"; -import type { WorkspaceItemFacts, WorkspaceItemSummary } from "#/features/workspaces/contracts"; import type { WorkspaceRealtimeEvent, WorkspaceRealtimeServerMessage, } from "#/features/workspaces/realtime/messages"; -type WorkspaceKernelEventIdentity = Pick< - WorkspaceRealtimeEvent, - "actorUserId" | "clientMutationId" | "createdAt" | "id" | "revision" | "workspaceId" ->; - -type MutationReceiptEventRow = KernelEventRow & { result_id: string }; - export class WorkspaceKernelEventBus { private readonly sql: WorkspaceKernelSql; private readonly workspaceId: () => string; @@ -31,10 +22,7 @@ export class WorkspaceKernelEventBus { this.broadcast = input.broadcast; } - commit( - input: Omit, - receipt?: { resultId: string }, - ) { + commit(input: Omit) { const createdAt = Date.now(); const event = { id: crypto.randomUUID(), @@ -64,22 +52,6 @@ export class WorkspaceKernelEventBus { ${createdAt} ) `; - if (event.clientMutationId && receipt) { - this.sql` - INSERT INTO kernel_mutation_receipts ( - client_mutation_id, - result_id, - event_id, - created_at - ) - VALUES ( - ${event.clientMutationId}, - ${receipt.resultId}, - ${event.id}, - ${createdAt} - ) - `; - } this.broadcast({ type: "workspace.event", workspaceId: this.workspaceId(), @@ -88,59 +60,4 @@ export class WorkspaceKernelEventBus { return event; } - - findMutationEvent(input: { - clientMutationId: string; - eventType: WorkspaceRealtimeEvent["type"]; - resultId: string; - }) { - const [row] = this.sql` - SELECT kernel_events.*, kernel_mutation_receipts.result_id - FROM kernel_mutation_receipts - INNER JOIN kernel_events ON kernel_events.id = kernel_mutation_receipts.event_id - WHERE kernel_mutation_receipts.client_mutation_id = ${input.clientMutationId} - LIMIT 1 - `; - if (!row) { - return null; - } - if (row.type !== input.eventType || row.result_id !== input.resultId) { - throw new Error("Workspace client mutation id was already used."); - } - return mapEventIdentity(row, this.workspaceId()); - } -} - -export function hydrateCreatedItemEvent( - event: WorkspaceKernelEventIdentity, - item: WorkspaceItemSummary, - itemFacts: WorkspaceItemFacts[], -): Extract { - return { - ...event, - type: "workspace.item.created", - payload: { item, itemFacts }, - }; -} - -export function hydrateProjectionEvent( - event: WorkspaceKernelEventIdentity, - itemFacts: WorkspaceItemFacts[], -): Extract { - return { - ...event, - type: "workspace.item.projection.updated", - payload: { itemFacts }, - }; -} - -function mapEventIdentity(row: KernelEventRow, workspaceId: string): WorkspaceKernelEventIdentity { - return { - actorUserId: row.actor_user_id, - clientMutationId: row.client_mutation_id, - createdAt: new Date(row.created_at).toISOString(), - id: row.id, - revision: row.revision, - workspaceId, - }; } diff --git a/src/features/workspaces/kernel/workspace-kernel-file-commands.ts b/src/features/workspaces/kernel/workspace-kernel-file-commands.ts index 8404f330d..1133f952a 100644 --- a/src/features/workspaces/kernel/workspace-kernel-file-commands.ts +++ b/src/features/workspaces/kernel/workspace-kernel-file-commands.ts @@ -5,11 +5,7 @@ import type { } 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 { - hydrateCreatedItemEvent, - hydrateProjectionEvent, - type WorkspaceKernelEventBus, -} from "#/features/workspaces/kernel/workspace-kernel-events"; +import type { WorkspaceKernelEventBus } from "#/features/workspaces/kernel/workspace-kernel-events"; import { getWorkspaceKernelFileShellPath } from "#/features/workspaces/kernel/workspace-kernel-files"; import { parseWorkspaceMetadataJson } from "#/features/workspaces/kernel/workspace-kernel-metadata"; import type { WorkspaceKernelSql } from "#/features/workspaces/kernel/workspace-kernel-schema"; @@ -64,29 +60,6 @@ export class WorkspaceKernelFileCommands { input: CreateWorkspaceKernelFileFromUploadArgs, ): Promise> { const parentId = input.parentId ?? null; - const getPriorResult = () => { - const storedEvent = input.clientMutationId - ? this.events.findMutationEvent({ - clientMutationId: input.clientMutationId, - eventType: "workspace.item.created", - resultId: input.id, - }) - : null; - - if (!storedEvent) { - return null; - } - const item = this.store.requireItem(input.id); - return { - event: hydrateCreatedItemEvent(storedEvent, item, this.store.getItemFacts([item])), - result: item, - }; - }; - const priorResult = getPriorResult(); - - if (priorResult) { - return priorResult; - } this.store.assertParentIsValid(parentId); @@ -116,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."); @@ -211,15 +179,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, itemFacts }, - }, - { resultId: itemId }, - ); + const event = this.events.commit({ + type: "workspace.item.created", + actorUserId: input.actorUserId ?? null, + clientMutationId: input.clientMutationId ?? null, + payload: { item, itemFacts }, + }); return { result: item, event }; } @@ -292,17 +257,6 @@ export class WorkspaceKernelFileCommands { if (row.type !== "file") { throw new Error("Workspace item is not a file."); } - const storedEvent = input.clientMutationId - ? this.events.findMutationEvent({ - clientMutationId: input.clientMutationId, - eventType: "workspace.item.projection.updated", - resultId: input.itemId, - }) - : null; - if (storedEvent) { - const itemFacts = this.store.getItemFacts([this.store.requireItem(input.itemId)]); - return { event: hydrateProjectionEvent(storedEvent, itemFacts), result: itemFacts }; - } if (input.status === "ready") { if (!row.object_key) { throw new Error("Ready file projections require a current source object."); @@ -327,15 +281,12 @@ export class WorkspaceKernelFileCommands { 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 }, - }, - { resultId: 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 }; } diff --git a/src/features/workspaces/kernel/workspace-kernel-item-commands.ts b/src/features/workspaces/kernel/workspace-kernel-item-commands.ts index 134856709..2459803ab 100644 --- a/src/features/workspaces/kernel/workspace-kernel-item-commands.ts +++ b/src/features/workspaces/kernel/workspace-kernel-item-commands.ts @@ -7,10 +7,7 @@ import { persistDocumentItemContentUpdate, touchWorkspaceItemUpdatedAt, } from "#/features/workspaces/documents/document-item-content"; -import { - hydrateCreatedItemEvent, - type WorkspaceKernelEventBus, -} from "#/features/workspaces/kernel/workspace-kernel-events"; +import type { WorkspaceKernelEventBus } from "#/features/workspaces/kernel/workspace-kernel-events"; import { getInitialWorkspaceKernelContent, getWorkspaceKernelContentMimeType, @@ -77,29 +74,6 @@ export class WorkspaceKernelItemCommands { const id = input.id; const parentId = input.parentId ?? null; - const getPriorResult = () => { - const storedEvent = input.clientMutationId - ? this.events.findMutationEvent({ - clientMutationId: input.clientMutationId, - eventType: "workspace.item.created", - resultId: id, - }) - : null; - - if (!storedEvent) { - return null; - } - const item = this.store.requireItem(id); - return { - event: hydrateCreatedItemEvent(storedEvent, item, this.store.getItemFacts([item])), - result: item, - }; - }; - const priorResult = getPriorResult(); - - if (priorResult) { - return { command: priorResult, status: "applied" }; - } const color = resolveWorkspaceItemColorForCreate({ type, @@ -147,11 +121,6 @@ export class WorkspaceKernelItemCommands { 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` @@ -191,15 +160,12 @@ export class WorkspaceKernelItemCommands { 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, itemFacts }, - }, - { resultId: id }, - ); + const event = this.events.commit({ + type: "workspace.item.created", + actorUserId: input.actorUserId ?? null, + clientMutationId: input.clientMutationId ?? null, + payload: { item, itemFacts }, + }); return { command: { result: item, event }, status: "applied" }; } diff --git a/src/features/workspaces/kernel/workspace-kernel-rows.ts b/src/features/workspaces/kernel/workspace-kernel-rows.ts index 45a7cc336..f970974ee 100644 --- a/src/features/workspaces/kernel/workspace-kernel-rows.ts +++ b/src/features/workspaces/kernel/workspace-kernel-rows.ts @@ -22,16 +22,6 @@ export type KernelItemRow = { deleted_at: number | null; }; -export type KernelEventRow = { - id: string; - revision: number; - type: string; - actor_user_id: string | null; - client_mutation_id: string | null; - payload_json: string; - created_at: number; -}; - export type KernelItemProjectionRow = { item_id: string; format: WorkspaceKernelFileProjectionFormat; diff --git a/src/features/workspaces/kernel/workspace-kernel-schema.ts b/src/features/workspaces/kernel/workspace-kernel-schema.ts index 93dd7d6d2..1fb0c590a 100644 --- a/src/features/workspaces/kernel/workspace-kernel-schema.ts +++ b/src/features/workspaces/kernel/workspace-kernel-schema.ts @@ -85,14 +85,6 @@ export function initializeWorkspaceKernelStorage(sql: WorkspaceKernelSql) { `; sql`CREATE INDEX IF NOT EXISTS kernel_events_revision_idx ON kernel_events (revision)`; - sql` - CREATE TABLE IF NOT EXISTS kernel_mutation_receipts ( - client_mutation_id TEXT PRIMARY KEY, - result_id TEXT NOT NULL, - event_id TEXT NOT NULL UNIQUE, - created_at INTEGER NOT NULL - ) - `; } function createSiblingNameIndexes(sql: WorkspaceKernelSql) { From 1edf3e3489cd9e41138b70b5ceed784607ed3966 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sat, 18 Jul 2026 01:03:41 -0400 Subject: [PATCH 15/16] test(workspaces): remove brittle receipt copy tests --- .../ai-chat/ai-chat-tool-receipts.test.ts | 36 ------------------- 1 file changed, 36 deletions(-) delete mode 100644 src/features/workspaces/components/ai-chat/ai-chat-tool-receipts.test.ts diff --git a/src/features/workspaces/components/ai-chat/ai-chat-tool-receipts.test.ts b/src/features/workspaces/components/ai-chat/ai-chat-tool-receipts.test.ts deleted file mode 100644 index 9b955ab8b..000000000 --- a/src/features/workspaces/components/ai-chat/ai-chat-tool-receipts.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { getFinishedToolReceipt } from "#/features/workspaces/components/ai-chat/ai-chat-tool-receipts"; - -describe("workspace read tool receipts", () => { - it("describes pending-only reads as extraction in progress", () => { - expect( - getFinishedToolReceipt({ - baseStatus: "completed", - output: { results: [{ path: "/Paper.pdf", status: "pending", type: "file" }] }, - toolInput: {}, - toolName: "workspace_read_items", - }), - ).toEqual({ status: "completed", summary: "Extraction in progress for 1 item" }); - }); - - it("keeps pending and failed reads visible beside ready content", () => { - expect( - getFinishedToolReceipt({ - baseStatus: "completed", - output: { - results: [ - { path: "/Notes", status: "ready", type: "document" }, - { path: "/Paper.pdf", status: "pending", type: "file" }, - { code: "path_not_found", path: "/Missing", status: "failed" }, - ], - }, - toolInput: {}, - toolName: "workspace_read_items", - }), - ).toEqual({ - status: "completed", - summary: "Read “Notes” · 1 item still processing, 1 failure", - }); - }); -}); From 50fbf559605042a84757e7104169900d94e3818e Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sat, 18 Jul 2026 01:12:09 -0400 Subject: [PATCH 16/16] refactor(workspaces): simplify content read internals --- .../content/workspace-content-contract.ts | 4 +- .../content/workspace-content-cursor.ts | 2 +- .../content/workspace-content-reader.test.ts | 40 +++-- .../content/workspace-content-reader.ts | 152 +++++++++--------- .../extraction/workspace-page-projection.ts | 132 ++++----------- .../workspaces/operations/read-items.ts | 7 +- .../operations/workspace-operation-context.ts | 2 +- src/features/workspaces/realtime/messages.ts | 7 +- src/lib/binary.ts | 2 +- 9 files changed, 141 insertions(+), 207 deletions(-) diff --git a/src/features/workspaces/content/workspace-content-contract.ts b/src/features/workspaces/content/workspace-content-contract.ts index 5b43a37e5..7b8157837 100644 --- a/src/features/workspaces/content/workspace-content-contract.ts +++ b/src/features/workspaces/content/workspace-content-contract.ts @@ -4,7 +4,7 @@ import { workspaceRelationKindSchema } from "#/features/workspaces/contracts"; const workspacePathSchema = z.string().min(1); -export const readWorkspaceItemsFailureCodes = [ +const readWorkspaceItemsFailureCodes = [ "content_changed", "invalid_cursor", "invalid_selection", @@ -18,7 +18,7 @@ export const readWorkspaceItemsFailureCodes = [ "unsupported_item_type", ] as const; -export const workspacePageRangeSchema = z +const workspacePageRangeSchema = z .string() .trim() .min(1) diff --git a/src/features/workspaces/content/workspace-content-cursor.ts b/src/features/workspaces/content/workspace-content-cursor.ts index 1bb704e36..54ff00b50 100644 --- a/src/features/workspaces/content/workspace-content-cursor.ts +++ b/src/features/workspaces/content/workspace-content-cursor.ts @@ -19,7 +19,7 @@ const workspaceContentCursorSchema = z.discriminatedUnion("kind", [ }), ]); -export type WorkspaceContentCursor = z.infer; +type WorkspaceContentCursor = z.infer; export function encodeWorkspaceContentCursor(cursor: WorkspaceContentCursor) { return encodeBase64UrlText(JSON.stringify(cursor)); diff --git a/src/features/workspaces/content/workspace-content-reader.test.ts b/src/features/workspaces/content/workspace-content-reader.test.ts index cf9d45e7b..3d525f686 100644 --- a/src/features/workspaces/content/workspace-content-reader.test.ts +++ b/src/features/workspaces/content/workspace-content-reader.test.ts @@ -5,7 +5,7 @@ import type { WorkspaceContentReadRequest } from "#/features/workspaces/content/ 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 { createWorkspaceContentReader } from "#/features/workspaces/content/workspace-content-reader"; +import { readWorkspaceContent } from "#/features/workspaces/content/workspace-content-reader"; import { encodeWorkspaceContentCursor } from "#/features/workspaces/content/workspace-content-cursor"; const documentItem: WorkspaceItemSummary = { @@ -28,13 +28,13 @@ 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 reader = createWorkspaceContentReader({ + const read = createReader({ bucket: {} as R2Bucket, getDocumentSession: () => session, kernel: createKernel(), }); - const [first] = await reader.read([{ mode: "start", path: "/Notes" }]); + const [first] = await read([{ mode: "start", path: "/Notes" }]); expect(first).toMatchObject({ format: "markdown", location: { kind: "lines", startLine: 1, totalLines: 20_000 }, @@ -51,9 +51,7 @@ describe("WorkspaceContentReader", () => { throw new Error("Expected the first document chunk to have a continuation cursor."); } - const [second] = await reader.read([ - { cursor: first.nextCursor, mode: "continue", path: "/Notes" }, - ]); + const [second] = await read([{ cursor: first.nextCursor, mode: "continue", path: "/Notes" }]); expect(second).toMatchObject({ location: { kind: "lines" }, path: "/Notes", @@ -71,25 +69,25 @@ describe("WorkspaceContentReader", () => { markdown: "a\n".repeat(40_000), revision: "revision-1", }); - const reader = createWorkspaceContentReader({ + const read = createReader({ bucket: {} as R2Bucket, getDocumentSession: () => session, kernel: createKernel(), }); - const [first] = await reader.read([{ mode: "start", path: "/Notes" }]); + 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( - reader.read([{ cursor: first.nextCursor, mode: "continue", path: "/Notes" }]), + 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 reader = createWorkspaceContentReader({ + const read = createReader({ bucket: {} as R2Bucket, getDocumentSession: () => createDocumentSession({ markdown, revision: "revision-1" }), kernel: createKernel(), @@ -98,7 +96,7 @@ describe("WorkspaceContentReader", () => { const contents: string[] = []; let request: WorkspaceContentReadRequest = { mode: "start", path: "/Notes" }; for (;;) { - const [result] = await reader.read([request]); + const [result] = await read([request]); expect(result).toMatchObject({ status: "ready", type: "document" }); if (!result || result.status !== "ready") { throw new Error("Expected a document chunk."); @@ -113,7 +111,7 @@ describe("WorkspaceContentReader", () => { }); it("rejects a nonzero continuation offset for an empty document", async () => { - const reader = createWorkspaceContentReader({ + const read = createReader({ bucket: {} as R2Bucket, getDocumentSession: () => createDocumentSession({ markdown: "", revision: "revision-1" }), kernel: createKernel(), @@ -126,13 +124,13 @@ describe("WorkspaceContentReader", () => { version: 1, }); - await expect(reader.read([{ cursor, mode: "continue", path: "/Notes" }])).resolves.toEqual([ + 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 reader = createWorkspaceContentReader({ + const read = createReader({ bucket: {} as R2Bucket, getDocumentSession: () => createDocumentSession({ markdown: "😀".repeat(40_000), revision: "revision-1" }), @@ -143,7 +141,7 @@ describe("WorkspaceContentReader", () => { path: `/Notes ${index + 1}`, })); - const results = await reader.read(requests); + const results = await read(requests); expect(results.filter((result) => result.status === "ready")).toHaveLength(16); expect(results.slice(16)).toEqual( requests.slice(16).map((request) => ({ @@ -164,14 +162,14 @@ describe("WorkspaceContentReader", () => { { path: "/", status: "root" }, ] satisfies WorkspaceKernelPathResolution[], ); - const reader = createWorkspaceContentReader({ + const read = createReader({ bucket: {} as R2Bucket, getDocumentSession: () => createDocumentSession({ markdown: "", revision: "revision-1" }), kernel, }); await expect( - reader.read([ + read([ { mode: "start", path: "Notes" }, { mode: "start", path: "/Missing" }, { mode: "start", path: "/" }, @@ -207,3 +205,11 @@ function createKernel() { 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 index f03e231bb..4a2cc7cef 100644 --- a/src/features/workspaces/content/workspace-content-reader.ts +++ b/src/features/workspaces/content/workspace-content-reader.ts @@ -31,91 +31,85 @@ interface PendingReadyResult { relations: Awaited>; } -export interface WorkspaceContentReader { - read(requests: WorkspaceContentReadRequest[]): Promise; -} - -export function createWorkspaceContentReader(input: { +export async function readWorkspaceContent(input: { bucket: R2Bucket; getDocumentSession: (itemId: string) => DocumentContentReader; kernel: WorkspaceKernelClient; -}): WorkspaceContentReader { - return { - async read(requests) { - 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; + 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; - } + // 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; + 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; - } + 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; - }, - }; + await attachRelationPaths(input.kernel, readyResults); + return results; } async function readWorkspaceItem(input: { @@ -127,10 +121,10 @@ async function readWorkspaceItem(input: { request: WorkspaceContentReadRequest; }): Promise { if (input.item.type === "document") { - return await readDocument(input); + return readDocument(input); } if (input.item.type === "file") { - return await readFile(input); + return readFile(input); } return { code: "unsupported_item_type", path: input.path, status: "failed" }; } diff --git a/src/features/workspaces/extraction/workspace-page-projection.ts b/src/features/workspaces/extraction/workspace-page-projection.ts index be9d29c43..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,31 +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; - pages?: WorkspacePageProjectionManifestPage[]; - provider: string; - providerMode: string; - runId: string; - schemaVersion: typeof projectionSchemaVersion; - sourceHash: string; - workspaceId: string; -} - -interface WorkspacePageProjectionManifestPage { - markdownBytes: number; - pageNumber: number; -} - -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; @@ -51,7 +54,7 @@ export async function writeWorkspacePageProjection(input: { sourceHash: string; tier: "enhanced" | "fast"; workspaceId: string; -}): Promise { +}) { const prefix = getWorkspacePageProjectionPrefix(input); const encoder = new TextEncoder(); const writes: Promise[] = []; @@ -242,73 +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."); - } - - const pages = value.pages === undefined ? undefined : parseManifestPages(value); - - return { - createdAt: value.createdAt, - itemId: value.itemId, - markdownBytes: value.markdownBytes, - markdownLength: value.markdownLength, - metadata, - pageCount: value.pageCount, - ...(pages ? { pages } : {}), - provider: value.provider, - providerMode: value.providerMode, - runId: value.runId, - schemaVersion: value.schemaVersion, - sourceHash: value.sourceHash, - workspaceId: value.workspaceId, - }; -} - -function parseManifestPages(value: Record): WorkspacePageProjectionManifestPage[] { - if (!Array.isArray(value.pages) || value.pages.length !== value.pageCount) { - throw new Error("Workspace page projection manifest is invalid."); - } - - return value.pages.map((page, index) => { - if ( - !isRecord(page) || - page.pageNumber !== index + 1 || - typeof page.markdownBytes !== "number" || - !Number.isInteger(page.markdownBytes) || - page.markdownBytes < 0 - ) { + for (const [index, page] of (manifest.pages ?? []).entries()) { + if (page.pageNumber !== index + 1) { throw new Error("Workspace page projection manifest is invalid."); } - - return { - markdownBytes: page.markdownBytes, - pageNumber: page.pageNumber, - }; - }); + } + return manifest; } async function schedulePageWrite( @@ -365,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/operations/read-items.ts b/src/features/workspaces/operations/read-items.ts index 1909e769d..3ac3be416 100644 --- a/src/features/workspaces/operations/read-items.ts +++ b/src/features/workspaces/operations/read-items.ts @@ -4,7 +4,7 @@ import { type WorkspaceContentReadRequest, type WorkspaceContentReadResult, } from "#/features/workspaces/content/workspace-content-contract"; -import { createWorkspaceContentReader } from "#/features/workspaces/content/workspace-content-reader"; +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"; @@ -25,7 +25,7 @@ export async function readWorkspaceItemsOperation( access: "read", context: accessContext, }); - const reader = createWorkspaceContentReader({ + const results = await readWorkspaceContent({ bucket: env.WORKSPACE_KERNEL_FILES, getDocumentSession: (itemId) => getDocumentSessionFromEnv(env, { @@ -33,7 +33,8 @@ export async function readWorkspaceItemsOperation( workspaceId: accessContext.workspaceId, }), kernel, + requests: input.requests, }); - return { results: await reader.read(input.requests) }; + return { results }; } diff --git a/src/features/workspaces/operations/workspace-operation-context.ts b/src/features/workspaces/operations/workspace-operation-context.ts index cbeb387a6..4a3058b73 100644 --- a/src/features/workspaces/operations/workspace-operation-context.ts +++ b/src/features/workspaces/operations/workspace-operation-context.ts @@ -13,7 +13,7 @@ import { type WorkspaceAccessContext, } from "#/features/workspaces/operations/workspace-access-context"; -export type WorkspaceOperationAccessMode = "read" | "mutate"; +type WorkspaceOperationAccessMode = "read" | "mutate"; export async function getAuthorizedWorkspaceKernel(input: { access: WorkspaceOperationAccessMode; diff --git a/src/features/workspaces/realtime/messages.ts b/src/features/workspaces/realtime/messages.ts index c4571b2d4..8e6553db2 100644 --- a/src/features/workspaces/realtime/messages.ts +++ b/src/features/workspaces/realtime/messages.ts @@ -55,12 +55,7 @@ const workspaceRealtimeEventSchema = z.discriminatedUnion("type", [ }), z.object({ ...workspaceRealtimeEventBase, - type: z.literal("workspace.relations.updated"), - payload: z.object({ itemFacts: z.array(workspaceItemFactsSchema) }), - }), - z.object({ - ...workspaceRealtimeEventBase, - type: z.literal("workspace.item.projection.updated"), + type: z.enum(["workspace.relations.updated", "workspace.item.projection.updated"]), payload: z.object({ itemFacts: z.array(workspaceItemFactsSchema) }), }), ]); diff --git a/src/lib/binary.ts b/src/lib/binary.ts index 681199497..f04ffe7b9 100644 --- a/src/lib/binary.ts +++ b/src/lib/binary.ts @@ -35,5 +35,5 @@ export async function sha256Base64Url(bytes: Uint8Array) { } export async function sha256Base64UrlText(value: string) { - return await sha256Base64Url(textEncoder.encode(value)); + return sha256Base64Url(textEncoder.encode(value)); }