diff --git a/.changeset/calm-clocks-wait.md b/.changeset/calm-clocks-wait.md new file mode 100644 index 0000000000..b688cc509f --- /dev/null +++ b/.changeset/calm-clocks-wait.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Stabilize A2A polling deadline coverage under full-suite load. diff --git a/.changeset/fuzzy-lemons-save.md b/.changeset/fuzzy-lemons-save.md new file mode 100644 index 0000000000..79342d6b32 --- /dev/null +++ b/.changeset/fuzzy-lemons-save.md @@ -0,0 +1,5 @@ +--- +"@agent-native/toolkit": patch +--- + +Allow newly created empty collaborative editors to persist their first real user edit after the shared document finishes loading. diff --git a/packages/core/src/a2a/client.spec.ts b/packages/core/src/a2a/client.spec.ts index 2558d8f63b..06557b630d 100644 --- a/packages/core/src/a2a/client.spec.ts +++ b/packages/core/src/a2a/client.spec.ts @@ -173,12 +173,9 @@ describe("A2AClient", () => { { role: "user", parts: [{ type: "text", text: "hello" }] }, { timeoutMs: 5_000, pollIntervalMs: 1_000 }, ); - const assertion = expect(result).rejects.toMatchObject({ - name: "A2ATaskTimeoutError", - taskId: "task-hung-poll", - lastState: "working", - timeoutMs: 5_000, - }); + // Attach a handler before advancing timers so the intentional rejection is + // never reported as unhandled while the fake clock is moving. + void result.catch(() => undefined); const hasTaskRead = () => fetchMock.mock.calls.some( @@ -186,9 +183,20 @@ describe("A2AClient", () => { init?.method === "POST" && JSON.parse(String(init.body)).method === "tasks/get", ); - while (!hasTaskRead()) await vi.advanceTimersByTimeAsync(1); - await vi.advanceTimersByTimeAsync(5_000); - await assertion; + // waitFor advances fake time in coarse intervals. Stepping the clock 1ms at + // a time performs 1,000 async flushes and can exceed Vitest's real 5s test + // timeout when the full suite is under load. + await vi.waitFor(() => expect(hasTaskRead()).toBe(true), { + interval: 100, + timeout: 5_000, + }); + await vi.runAllTimersAsync(); + await expect(result).rejects.toMatchObject({ + name: "A2ATaskTimeoutError", + taskId: "task-hung-poll", + lastState: "working", + timeoutMs: 5_000, + }); expect(hasTaskRead()).toBe(true); expect( fetchMock.mock.calls.find( diff --git a/packages/toolkit/src/editor/useCollabReconcile.concurrent.spec.ts b/packages/toolkit/src/editor/useCollabReconcile.concurrent.spec.ts index cdcef92dca..ad536e2166 100644 --- a/packages/toolkit/src/editor/useCollabReconcile.concurrent.spec.ts +++ b/packages/toolkit/src/editor/useCollabReconcile.concurrent.spec.ts @@ -1,9 +1,12 @@ // @vitest-environment happy-dom +import { Editor as CoreEditor } from "@tiptap/core"; import { useEditor, type Editor } from "@tiptap/react"; import React, { act } from "react"; import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Awareness } from "y-protocols/awareness"; +import * as Y from "yjs"; import { createRichMarkdownExtensions } from "./RichMarkdownEditor.js"; import { useCollabReconcile, getEditorMarkdown } from "./useCollabReconcile.js"; @@ -38,6 +41,7 @@ beforeEach(() => { afterEach(() => { act(() => root.unmount()); + vi.useRealTimers(); container.remove(); }); @@ -330,6 +334,37 @@ describe("useCollabReconcile — concurrent edit / lost-update guards", () => { expect(results).toEqual([true]); }); + it("allows the first user edit after an authoritative empty doc finishes loading", async () => { + let shouldIgnoreUpdate: + | ((transaction: Editor["state"]["tr"]) => boolean) + | null = null; + let editor: Editor | null = null; + + function Probe() { + editor = useEditor({ + extensions: createRichMarkdownExtensions({ dialect: "gfm" }), + content: "", + }); + const fakeYdoc = { clientID: 1, getXmlFragment: () => ({ length: 0 }) }; + const guards = useCollabReconcile({ + editor, + ydoc: fakeYdoc as never, + collabSynced: true, + value: "", + contentUpdatedAt: "2024-01-01T00:00:01.000Z", + editable: true, + }); + shouldIgnoreUpdate = guards.shouldIgnoreUpdate; + return React.createElement("div", null); + } + + act(() => root.render(React.createElement(Probe))); + + expect(editor).not.toBeNull(); + expect(shouldIgnoreUpdate).not.toBeNull(); + expect(shouldIgnoreUpdate!(editor!.state.tr)).toBe(false); + }); + it("refuses to persist an empty doc in collab mode (registerEmitted guard)", async () => { // Directly exercise the guard contract: in collab mode an empty markdown // string must not be registered/persisted (would clobber stored content @@ -399,6 +434,240 @@ describe("useCollabReconcile — concurrent edit / lost-update guards", () => { expect(setContentValues).toEqual(["second seed"]); }); + it("does not seed beside persisted Y.Doc content projected during initial sync", async () => { + const persistedYdoc = new Y.Doc(); + const persistedEditor = new CoreEditor({ + extensions: createRichMarkdownExtensions({ + dialect: "gfm", + ydoc: persistedYdoc, + }), + }); + persistedEditor.commands.setContent("persisted collab body"); + const persistedUpdate = Y.encodeStateAsUpdate(persistedYdoc); + persistedEditor.destroy(); + persistedYdoc.destroy(); + + const liveYdoc = new Y.Doc(); + const setContentValues: string[] = []; + let capturedEditor: Editor | null = null; + + function Probe({ collabSynced }: { collabSynced: boolean }) { + const editor = useEditor({ + extensions: createRichMarkdownExtensions({ + dialect: "gfm", + ydoc: liveYdoc, + }), + }); + capturedEditor = editor; + useCollabReconcile({ + editor, + ydoc: liveYdoc, + collabSynced, + value: "persisted collab body", + contentUpdatedAt: "2024-01-01T00:00:01.000Z", + editable: true, + setContent: (_editor, nextValue) => { + setContentValues.push(nextValue); + }, + }); + return React.createElement("div", null); + } + + act(() => root.render(React.createElement(Probe, { collabSynced: false }))); + act(() => Y.applyUpdate(liveYdoc, persistedUpdate, "remote")); + act(() => root.render(React.createElement(Probe, { collabSynced: true }))); + await flush(); + + expect(getEditorMarkdown(capturedEditor!)).toBe("persisted collab body"); + expect(setContentValues).toEqual([]); + expect( + getEditorMarkdown(capturedEditor!).match(/persisted collab body/g), + ).toHaveLength(1); + liveYdoc.destroy(); + }); + + it("adopts a nonempty synced Y.Doc instead of reconciling an empty SQL snapshot", async () => { + vi.useFakeTimers(); + const persistedYdoc = new Y.Doc(); + const persistedEditor = new CoreEditor({ + extensions: createRichMarkdownExtensions({ + dialect: "gfm", + ydoc: persistedYdoc, + }), + }); + persistedEditor.commands.setContent("live collaborator body"); + const liveYdoc = new Y.Doc(); + Y.applyUpdate(liveYdoc, Y.encodeStateAsUpdate(persistedYdoc), "remote"); + persistedEditor.destroy(); + persistedYdoc.destroy(); + + const awareness = new Awareness(liveYdoc); + const setContentValues: string[] = []; + let capturedEditor: Editor | null = null; + + function Probe() { + const editor = useEditor({ + extensions: createRichMarkdownExtensions({ + dialect: "gfm", + ydoc: liveYdoc, + }), + }); + capturedEditor = editor; + useCollabReconcile({ + editor, + ydoc: liveYdoc, + awareness, + collabSynced: true, + value: "", + contentUpdatedAt: "2024-01-01T00:00:01.000Z", + editable: true, + setContent: (_editor, nextValue) => { + setContentValues.push(nextValue); + }, + }); + return React.createElement("div", null); + } + + act(() => root.render(React.createElement(Probe))); + await act(async () => vi.advanceTimersByTimeAsync(0)); + // The document state can finish syncing before the first awareness poll. + // Publish the active peer after mount to cover that real transport order. + act(() => { + awareness.getStates().set(4_294_967_295, { + user: { name: "Active peer" }, + visible: true, + }); + awareness.emit("change", [ + { added: [4_294_967_295], updated: [], removed: [] }, + "remote", + ]); + }); + await act(async () => vi.advanceTimersByTimeAsync(2500)); + + expect(getEditorMarkdown(capturedEditor!)).toBe("live collaborator body"); + expect(setContentValues).toEqual([]); + vi.useRealTimers(); + awareness.destroy(); + liveYdoc.destroy(); + }); + + it("lets canonical empty SQL clear stale persisted Y.Doc content with no active peer", async () => { + const persistedYdoc = new Y.Doc(); + const persistedEditor = new CoreEditor({ + extensions: createRichMarkdownExtensions({ + dialect: "gfm", + ydoc: persistedYdoc, + }), + }); + persistedEditor.commands.setContent("stale persisted body"); + const liveYdoc = new Y.Doc(); + Y.applyUpdate(liveYdoc, Y.encodeStateAsUpdate(persistedYdoc), "remote"); + persistedEditor.destroy(); + persistedYdoc.destroy(); + + const setContentValues: string[] = []; + let capturedEditor: Editor | null = null; + const awareness = new Awareness(liveYdoc); + + function Probe() { + const editor = useEditor({ + extensions: createRichMarkdownExtensions({ + dialect: "gfm", + ydoc: liveYdoc, + }), + }); + capturedEditor = editor; + useCollabReconcile({ + editor, + ydoc: liveYdoc, + awareness, + collabSynced: true, + value: "", + contentUpdatedAt: "2024-01-01T00:00:01.000Z", + editable: true, + setContent: (editorToClear, nextValue) => { + setContentValues.push(nextValue); + editorToClear.commands.setContent(nextValue); + }, + }); + return React.createElement("div", null); + } + + vi.useFakeTimers(); + act(() => root.render(React.createElement(Probe))); + await act(async () => vi.advanceTimersByTimeAsync(2499)); + expect(setContentValues).toEqual([]); + await act(async () => vi.advanceTimersByTimeAsync(51)); + + expect(setContentValues).toContain(""); + expect(getEditorMarkdown(capturedEditor!)).toBe(""); + vi.useRealTimers(); + awareness.destroy(); + liveYdoc.destroy(); + }); + + it("preserves and permits a first local edit during the awareness settle window", async () => { + const persistedYdoc = new Y.Doc(); + const persistedEditor = new CoreEditor({ + extensions: createRichMarkdownExtensions({ + dialect: "gfm", + ydoc: persistedYdoc, + }), + }); + persistedEditor.commands.setContent("stale persisted body"); + const liveYdoc = new Y.Doc(); + Y.applyUpdate(liveYdoc, Y.encodeStateAsUpdate(persistedYdoc), "remote"); + persistedEditor.destroy(); + persistedYdoc.destroy(); + + const awareness = new Awareness(liveYdoc); + let capturedEditor: Editor | null = null; + let guards: ReturnType | null = null; + const setContentValues: string[] = []; + + function Probe() { + const editor = useEditor({ + extensions: createRichMarkdownExtensions({ + dialect: "gfm", + ydoc: liveYdoc, + }), + }); + capturedEditor = editor; + guards = useCollabReconcile({ + editor, + ydoc: liveYdoc, + awareness, + collabSynced: true, + value: "", + contentUpdatedAt: "2024-01-01T00:00:01.000Z", + editable: true, + setContent: (editorToSet, nextValue) => { + setContentValues.push(nextValue); + editorToSet.commands.setContent(nextValue); + }, + }); + return React.createElement("div", null); + } + + vi.useFakeTimers(); + act(() => root.render(React.createElement(Probe))); + await act(async () => vi.advanceTimersByTimeAsync(0)); + + expect(guards).not.toBeNull(); + expect(capturedEditor).not.toBeNull(); + expect(guards!.shouldIgnoreUpdate(capturedEditor!.state.tr)).toBe(false); + expect(guards!.registerEmitted("first local edit")).toBe(true); + act(() => capturedEditor!.commands.setContent("first local edit")); + + await act(async () => vi.advanceTimersByTimeAsync(2500)); + expect(getEditorMarkdown(capturedEditor!)).toBe("first local edit"); + expect(setContentValues).toEqual([]); + + vi.useRealTimers(); + awareness.destroy(); + liveYdoc.destroy(); + }); + it("applies a genuinely newer external value once the user is no longer focused", async () => { const { captured, Harness } = makeHarness(); diff --git a/packages/toolkit/src/editor/useCollabReconcile.ts b/packages/toolkit/src/editor/useCollabReconcile.ts index 4d38864faa..4bd1a71c4a 100644 --- a/packages/toolkit/src/editor/useCollabReconcile.ts +++ b/packages/toolkit/src/editor/useCollabReconcile.ts @@ -33,6 +33,11 @@ export function getEditorMarkdown(editor: Editor): string { * is identical, so skipping it is safe by construction. */ const EMITTED_RING_MAX = 16; +// The hosted awareness transport polls every 2s when its SSE gateway cannot +// forward presence. Give that first snapshot one poll plus margin before an +// empty SQL value is allowed to clear a nonempty Y.Doc. This is the same settle +// window used below when a known peer may deliver an edit through Yjs. +const PEER_SETTLE_MS = 2500; function pushEmittedRing(ring: string[], value: string): void { if (!value) return; if (ring[ring.length - 1] === value) return; @@ -264,6 +269,11 @@ export function useCollabReconcile({ // arrives via Yjs, so external markdown reconcile must defer (avoid applying // the same change through both Yjs and setContent). const peerCountRef = useRef(0); + // Briefly gates the first empty-SQL reconcile while Collaboration projects + // a nonempty fragment. `seededRef` is still released immediately so a real + // first keystroke can persist; this ref only postpones the ambiguous clear + // decision until we can distinguish an active/local edit from stale CRDT. + const emptySnapshotDecisionPendingRef = useRef(false); useEffect(() => { if (!collab || !awareness || !ydoc) { setIsLeadClient(true); @@ -299,25 +309,73 @@ export function useCollabReconcile({ if (!collab || !editor || editor.isDestroyed || !ydoc) return; if (seededRef.current) return; if (!collabSynced) return; - if (!isLeadClient) return; - if (!value.trim()) return; - const fragment = ydoc.getXmlFragment("default"); - const currentMarkdown = getMarkdown(editor); - // Seed only when the shared doc is genuinely empty — either the fragment has - // no nodes yet, or it holds no semantic markdown (an empty paragraph, or an - // app's sentinel-empty filler via a custom `shouldSeed`). - if ( - !shouldSeed({ value, currentMarkdown, fragmentLength: fragment.length }) - ) { + // An empty SQL value has nothing to seed. Release the first real keystroke + // immediately, but when a fragment already exists defer the ambiguous + // reconcile decision for one task: active-peer or just-emitted local content + // is live and must be adopted; stale persisted CRDT with no active writer is + // cleared by the canonical SQL snapshot. + if (!value.trim()) { seededRef.current = true; - return; - } + const fragment = ydoc.getXmlFragment("default"); + const currentMarkdown = getMarkdown(editor); + if (fragment.length === 0 && !currentMarkdown.trim()) return; + emptySnapshotDecisionPendingRef.current = true; + let cancelled = false; + const adoptTimer = setTimeout( + () => { + if (cancelled || editor.isDestroyed) return; + const projectedMarkdown = getMarkdown(editor); + const isOwnFreshEdit = + projectedMarkdown.trim() && + (projectedMarkdown === lastEmittedRef.current || + recentEmittedRef.current.includes(projectedMarkdown)); + if ( + projectedMarkdown.trim() && + (peerCountRef.current > 0 || isOwnFreshEdit) + ) { + lastAppliedValueRef.current = value; + lastAppliedSerializedRef.current = projectedMarkdown; + if (contentUpdatedAt) { + lastAppliedUpdatedAtRef.current = contentUpdatedAt; + } + } + emptySnapshotDecisionPendingRef.current = false; + }, + awareness ? PEER_SETTLE_MS : 0, + ); + return () => { + cancelled = true; + clearTimeout(adoptTimer); + emptySnapshotDecisionPendingRef.current = false; + }; + } + if (!isLeadClient) return; let cancelled = false; // Defer via a timer task (NOT a microtask — microtasks can still run // inside React's commit and trigger flushSync-from-lifecycle warnings). + // Read the editor and fragment INSIDE that task. The collaboration + // extension can finish projecting an already-populated Y.Doc into + // ProseMirror after this effect is scheduled. Capturing the pre-projection + // empty editor here would incorrectly seed the SQL snapshot alongside the + // existing CRDT content, duplicating the whole document after reload. const seedTimer = setTimeout(() => { if (cancelled || editor.isDestroyed) return; + const fragment = ydoc.getXmlFragment("default"); + const currentMarkdown = getMarkdown(editor); + // Seed only when the shared doc is genuinely empty — either the fragment + // has no nodes yet, or it holds no semantic markdown (an empty paragraph, + // or an app's sentinel-empty filler via a custom `shouldSeed`). + if ( + !shouldSeed({ + value, + currentMarkdown, + fragmentLength: fragment.length, + }) + ) { + seededRef.current = true; + return; + } isSettingContentRef.current = true; try { setContent(editor, value, {}); @@ -362,16 +420,26 @@ export function useCollabReconcile({ // With peers present, a peer's edit also arrives via Yjs. Defer one poll // cycle (+margin) and re-check before applying via setContent so the same // change isn't inserted twice (Yjs + setContent → duplicated region). - const PEER_SETTLE_MS = 2500; - const apply = (deferred = false) => { if (cancelled || editor.isDestroyed) return; // In collab mode, defer all reconcile until the shared doc is seeded so we // never setContent over an unseeded fragment. - if (collab && (!collabSynced || !seededRef.current)) { + if (collab && !collabSynced) { retry = setTimeout(() => apply(deferred), 300); return; } + // The seed decision itself is deferred one task so Collaboration can + // project persisted Y.Doc state before we inspect it. Poll that short + // handoff promptly: waiting the full provider cadence here makes a newer + // canonical SQL snapshot visibly stale after reload. + if (collab && !seededRef.current) { + retry = setTimeout(() => apply(deferred), 25); + return; + } + if (collab && emptySnapshotDecisionPendingRef.current) { + retry = setTimeout(() => apply(deferred), 50); + return; + } const currentMarkdown = getMarkdown(editor); // Compare against the canonical form the editor would emit so a serializer // that re-normalizes (Content's NFM) still recognizes "already in sync". diff --git a/templates/content/actions/update-document.db.test.ts b/templates/content/actions/update-document.db.test.ts index 7a528bc866..9a10c08b40 100644 --- a/templates/content/actions/update-document.db.test.ts +++ b/templates/content/actions/update-document.db.test.ts @@ -17,6 +17,8 @@ let schema: Schema; let updateDocumentAction: typeof import("./update-document.js").default; const OWNER = "owner@example.com"; +const EDITOR = "editor@example.com"; +const VIEWER = "viewer@example.com"; beforeAll(async () => { process.env.DATABASE_URL = `file:${TEST_DB_PATH}`; @@ -218,4 +220,179 @@ describe("update-document compare-and-swap", () => { expect(current.title).toBe("Renamed"); expect(current.content).toBe("pulled from notion"); }); + + it("always snapshots the last nonempty body before an intentional clear", async () => { + const documentId = await createDocument({ content: "original body" }); + + await runWithRequestContext({ userEmail: OWNER }, () => + updateDocumentAction.run({ + id: documentId, + content: "second body within the snapshot interval", + }), + ); + await runWithRequestContext({ userEmail: OWNER }, () => + updateDocumentAction.run({ + id: documentId, + content: "", + }), + ); + + const versions = await getDb() + .select() + .from(schema.documentVersions) + .where(eq(schema.documentVersions.documentId, documentId)); + expect(versions.map((version: any) => version.content)).toEqual( + expect.arrayContaining([ + "original body", + "second body within the snapshot interval", + ]), + ); + expect(versions).toHaveLength(2); + }); + + it("targets a shared editor's update audit event to the document owner", async () => { + const documentId = await createDocument({ content: "owner body" }); + await getDb() + .insert(schema.documentShares) + .values({ + id: nextId("share"), + resourceId: documentId, + principalType: "user", + principalId: EDITOR, + role: "editor", + createdBy: OWNER, + createdAt: new Date().toISOString(), + }); + + const result = await runWithRequestContext({ userEmail: EDITOR }, () => + updateDocumentAction.run( + { id: documentId, content: "edited by collaborator" }, + { + caller: "frontend", + actionName: "update-document", + userEmail: EDITOR, + }, + ), + ); + const { queryAuditEvents } = await import("@agent-native/core/audit"); + const events = await queryAuditEvents( + { userEmail: OWNER }, + { + action: "update-document", + targetType: "document", + targetId: documentId, + }, + ); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + actorEmail: EDITOR, + ownerEmail: OWNER, + targetType: "document", + targetId: documentId, + status: "success", + }); + expect(JSON.stringify(result)).not.toContain(OWNER); + }); + + it("keeps a viewer's favorite preference in the viewer's private audit trail", async () => { + const documentId = await createDocument({ content: "owner body" }); + await getDb() + .insert(schema.documentShares) + .values({ + id: nextId("share"), + resourceId: documentId, + principalType: "user", + principalId: VIEWER, + role: "viewer", + createdBy: OWNER, + createdAt: new Date().toISOString(), + }); + + await runWithRequestContext({ userEmail: VIEWER }, () => + updateDocumentAction.run( + { id: documentId, isFavorite: true }, + { + caller: "frontend", + actionName: "update-document", + userEmail: VIEWER, + }, + ), + ); + const { queryAuditEvents } = await import("@agent-native/core/audit"); + const viewerEvents = await queryAuditEvents( + { userEmail: VIEWER }, + { + action: "update-document", + targetType: "document", + targetId: documentId, + }, + ); + const ownerEvents = await queryAuditEvents( + { userEmail: OWNER }, + { + action: "update-document", + targetType: "document", + targetId: documentId, + }, + ); + + expect(viewerEvents).toHaveLength(1); + expect(viewerEvents[0]).toMatchObject({ + actorEmail: VIEWER, + ownerEmail: VIEWER, + targetType: "document", + targetId: documentId, + status: "success", + }); + expect(ownerEvents).toHaveLength(0); + }); + + it("preserves mixed updates without exposing their inputs to the owner", async () => { + const documentId = await createDocument({ content: "owner body" }); + await getDb() + .insert(schema.documentShares) + .values({ + id: nextId("share"), + resourceId: documentId, + principalType: "user", + principalId: EDITOR, + role: "editor", + createdBy: OWNER, + createdAt: new Date().toISOString(), + }); + + await runWithRequestContext({ userEmail: EDITOR }, () => + updateDocumentAction.run( + { + id: documentId, + content: "collaborator body", + isFavorite: true, + }, + { + caller: "frontend", + actionName: "update-document", + userEmail: EDITOR, + }, + ), + ); + const { queryAuditEvents } = await import("@agent-native/core/audit"); + const ownerEvents = await queryAuditEvents( + { userEmail: OWNER }, + { + action: "update-document", + targetType: "document", + targetId: documentId, + }, + ); + + expect((await documentRow(documentId)).content).toBe("collaborator body"); + expect(ownerEvents).toHaveLength(1); + expect(ownerEvents[0]).toMatchObject({ + actorEmail: EDITOR, + ownerEmail: OWNER, + input: null, + status: "success", + }); + }); }); diff --git a/templates/content/actions/update-document.ts b/templates/content/actions/update-document.ts index 63e222aafd..b45ca80614 100644 --- a/templates/content/actions/update-document.ts +++ b/templates/content/actions/update-document.ts @@ -40,6 +40,33 @@ export interface DocumentUpdateConflictResponse { document: DocumentUpdateResponse; } +const documentAuditOwner = Symbol("documentAuditOwner"); + +type DocumentAuditScopedResult = { + [documentAuditOwner]?: string; +}; + +function scopeDocumentAudit(result: T, ownerEmail: string) { + Object.defineProperty(result, documentAuditOwner, { value: ownerEmail }); + return result; +} + +function isFavoriteOnlyUpdate(args: { + isFavorite?: boolean; + title?: string; + content?: string; + description?: string; + icon?: string | null; +}) { + return ( + args.isFavorite !== undefined && + args.title === undefined && + args.content === undefined && + args.description === undefined && + args.icon === undefined + ); +} + function nanoid(size = 12): string { const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; @@ -301,6 +328,29 @@ export default defineAction({ .default([]) .describe("Exact item versions that influenced this agent update."), }), + audit: { + // Document bodies and personal favorite preferences are both sensitive. + // Keep actor/target/outcome attribution without copying mutation payloads + // into an owner-visible audit row. + recordInputs: false, + target: (args, result) => { + const favoriteOnly = isFavoriteOnlyUpdate(args); + return { + type: "document", + id: args.id, + // Favorites are a private preference owned by the actor, even when + // the underlying document belongs to somebody else. + ownerEmail: favoriteOnly + ? undefined + : (result as DocumentAuditScopedResult | null)?.[documentAuditOwner], + visibility: "private", + }; + }, + summary: (args, result) => + (result as DocumentUpdateConflictResponse | null)?.conflict + ? `Document update conflicted for ${args.id}` + : `Updated document ${args.id}`, + }, run: async ( args, ctx, @@ -315,12 +365,7 @@ export default defineAction({ const isAgentCaller = ctx?.caller === "tool" || ctx?.caller === "mcp" || ctx?.caller === "a2a"; - const favoriteOnly = - args.isFavorite !== undefined && - args.title === undefined && - args.content === undefined && - args.description === undefined && - args.icon === undefined; + const favoriteOnly = isFavoriteOnlyUpdate(args); const access = favoriteOnly ? await resolveContentDocumentAccess(id) : await assertAccess("document", id, "editor"); @@ -411,6 +456,11 @@ export default defineAction({ args.title !== undefined && args.title !== existing.title; const contentChanged = content !== undefined && content !== existing.content; + const clearsNonEmptyContent = + contentChanged && + content !== undefined && + isEffectivelyEmptyDocumentContent(content) && + !isEffectivelyEmptyDocumentContent(existing.content); const iconChanged = args.icon !== undefined && args.icon !== existing.icon; const favoriteChanged = args.isFavorite !== undefined && args.isFavorite !== currentFavorite; @@ -426,40 +476,6 @@ export default defineAction({ favoriteChanged || descriptionChanged; - // Snapshot the current state before applying content/title changes. - // Versions are scoped to the document owner, not the caller — an editor - // share collaborator shouldn't create a phantom version row under their - // own email. - if (titleChanged || contentChanged) { - const [latestVersion] = await db - .select({ createdAt: schema.documentVersions.createdAt }) - .from(schema.documentVersions) - .where( - and( - eq(schema.documentVersions.documentId, id), - eq(schema.documentVersions.ownerEmail, ownerEmail), - ), - ) - .orderBy(desc(schema.documentVersions.createdAt)) - .limit(1); - - const shouldSnapshot = - !latestVersion || - Date.now() - new Date(latestVersion.createdAt).getTime() > - SNAPSHOT_INTERVAL_MS; - - if (shouldSnapshot) { - await db.insert(schema.documentVersions).values({ - id: nanoid(), - ownerEmail, - documentId: id, - title: existing.title, - content: existing.content, - createdAt: new Date().toISOString(), - }); - } - } - let softDeletedDatabaseIds: string[] = []; let creativeContext: | Awaited> @@ -507,6 +523,36 @@ export default defineAction({ .where(eq(schema.documents.id, id)); } + if (titleChanged || contentChanged) { + const [latestVersion] = await tx + .select({ createdAt: schema.documentVersions.createdAt }) + .from(schema.documentVersions) + .where( + and( + eq(schema.documentVersions.documentId, id), + eq(schema.documentVersions.ownerEmail, ownerEmail), + ), + ) + .orderBy(desc(schema.documentVersions.createdAt)) + .limit(1); + const shouldSnapshot = + clearsNonEmptyContent || + !latestVersion || + Date.now() - new Date(latestVersion.createdAt).getTime() > + SNAPSHOT_INTERVAL_MS; + + if (shouldSnapshot) { + await tx.insert(schema.documentVersions).values({ + id: nanoid(), + ownerEmail, + documentId: id, + title: existing.title, + content: existing.content, + createdAt: new Date().toISOString(), + }); + } + } + if (favoriteChanged) { await setFavoriteMembership({ db: tx, @@ -580,30 +626,35 @@ export default defineAction({ .select() .from(schema.documents) .where(eq(schema.documents.id, id)); - return { - conflict: true, - id, - document: { - id: current.id, - urlPath: `/page/${current.id}`, - parentId: current.parentId, - title: current.title, - content: current.content, - description: current.description, - icon: current.icon, - position: current.position, - isFavorite: currentFavorite, - hideFromSearch: parseDocumentHideFromSearch(current.hideFromSearch), - visibility: current.visibility, - accessRole: access.role, - canEdit: canEditRole(access.role), - canManage: canManageRole(access.role), - createdAt: current.createdAt, - updatedAt: current.updatedAt, - source: serializeDocumentSource(current), - softDeletedDatabaseIds: [], - }, - }; + return scopeDocumentAudit( + { + conflict: true, + id, + document: { + id: current.id, + urlPath: `/page/${current.id}`, + parentId: current.parentId, + title: current.title, + content: current.content, + description: current.description, + icon: current.icon, + position: current.position, + isFavorite: currentFavorite, + hideFromSearch: parseDocumentHideFromSearch( + current.hideFromSearch, + ), + visibility: current.visibility, + accessRole: access.role, + canEdit: canEditRole(access.role), + canManage: canManageRole(access.role), + createdAt: current.createdAt, + updatedAt: current.updatedAt, + source: serializeDocumentSource(current), + softDeletedDatabaseIds: [], + }, + } satisfies DocumentUpdateConflictResponse, + ownerEmail, + ); } if (contentChanged) { @@ -665,32 +716,35 @@ export default defineAction({ await writeAppState("refresh-signal", { ts: Date.now() }); - return { - id: doc.id, - urlPath: `/page/${doc.id}`, - parentId: doc.parentId, - title: doc.title, - content: doc.content, - description: doc.description, - icon: doc.icon, - position: doc.position, - isFavorite: finalFavorite, - hideFromSearch: parseDocumentHideFromSearch(doc.hideFromSearch), - visibility: doc.visibility, - accessRole: access.role, - canEdit: canEditRole(access.role), - canManage: canManageRole(access.role), - createdAt: doc.createdAt, - updatedAt: doc.updatedAt, - source: serializeDocumentSource(doc), - softDeletedDatabaseIds, - ...(creativeContext - ? { - contextMode: creativeContext.contextMode, - contextPackId: creativeContext.contextPackId, - reuseLabels: creativeContext.reuseLabels, - } - : {}), - }; + return scopeDocumentAudit( + { + id: doc.id, + urlPath: `/page/${doc.id}`, + parentId: doc.parentId, + title: doc.title, + content: doc.content, + description: doc.description, + icon: doc.icon, + position: doc.position, + isFavorite: finalFavorite, + hideFromSearch: parseDocumentHideFromSearch(doc.hideFromSearch), + visibility: doc.visibility, + accessRole: access.role, + canEdit: canEditRole(access.role), + canManage: canManageRole(access.role), + createdAt: doc.createdAt, + updatedAt: doc.updatedAt, + source: serializeDocumentSource(doc), + softDeletedDatabaseIds, + ...(creativeContext + ? { + contextMode: creativeContext.contextMode, + contextPackId: creativeContext.contextPackId, + reuseLabels: creativeContext.reuseLabels, + } + : {}), + } satisfies DocumentUpdateResponse, + ownerEmail, + ); }, }); diff --git a/templates/content/app/components/editor/CommentComposer.test.tsx b/templates/content/app/components/editor/CommentComposer.test.tsx new file mode 100644 index 0000000000..197d47654a --- /dev/null +++ b/templates/content/app/components/editor/CommentComposer.test.tsx @@ -0,0 +1,51 @@ +// @vitest-environment happy-dom + +import { act, createElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { CommentComposer } from "./CommentComposer"; + +describe("CommentComposer", () => { + let container: HTMLDivElement | null = null; + let root: Root | null = null; + + afterEach(() => { + act(() => root?.unmount()); + container?.remove(); + root = null; + container = null; + }); + + function render(disabled: boolean) { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + act(() => { + root!.render( + createElement(CommentComposer, { + value: "A durable draft", + onChange: vi.fn(), + onSubmit: vi.fn(), + onMentionAdd: vi.fn(), + members: [], + disabled, + }), + ); + }); + return container.querySelector("textarea") as HTMLTextAreaElement; + } + + it("freezes the draft while its mutation is pending", () => { + const textarea = render(true); + + expect(textarea.disabled).toBe(true); + expect(textarea.value).toBe("A durable draft"); + }); + + it("keeps the composer editable before submission", () => { + const textarea = render(false); + + expect(textarea.disabled).toBe(false); + }); +}); diff --git a/templates/content/app/components/editor/CommentComposer.tsx b/templates/content/app/components/editor/CommentComposer.tsx index de734279bc..7bb859f393 100644 --- a/templates/content/app/components/editor/CommentComposer.tsx +++ b/templates/content/app/components/editor/CommentComposer.tsx @@ -28,6 +28,7 @@ interface CommentComposerProps { members: MentionMember[]; placeholder?: string; autoFocus?: boolean; + disabled?: boolean; rows?: number; className?: string; } @@ -52,6 +53,7 @@ export const CommentComposer = forwardRef< members, placeholder, autoFocus, + disabled = false, rows = 2, className, }, @@ -158,6 +160,7 @@ export const CommentComposer = forwardRef<