From 4feb5d72e0b2ebfcf56706addb64ab059c0c01a5 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 14:55:13 -0400 Subject: [PATCH 01/79] Support markdown chat attachments - Store supported text files in the project workspace - Insert attachment links into the chat prompt --- apps/web/src/components/chat/ChatComposer.tsx | 50 ++++++++++++++++--- 1 file changed, 44 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 5f9aec837ca..e3a6198de25 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -123,6 +123,11 @@ import { } from "../../lib/contextWindow"; import { formatProviderSkillDisplayName } from "../../providerSkillPresentation"; import { searchProviderSkills } from "../../providerSkillSearch"; +import { projectEnvironment } from "~/state/projects"; +import { useAtomCommand } from "~/state/use-atom-command"; +import { resolvePathLinkTarget } from "~/terminal-links"; + +const TEXT_ATTACHMENT_MAX_BYTES = 1024 * 1024; import { useMediaQuery } from "../../hooks/useMediaQuery"; import type { ReviewCommentContext } from "../../reviewCommentContext"; @@ -613,6 +618,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // Store subscriptions (prompt / images / terminal contexts) // ------------------------------------------------------------------ const composerDraft = useComposerThreadDraft(composerDraftTarget); + const writeProjectFile = useAtomCommand(projectEnvironment.writeFile, { reportFailure: false }); const prompt = composerDraft.prompt; const composerImages = composerDraft.images; const composerTerminalContexts = composerDraft.terminalContexts; @@ -1758,14 +1764,46 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }; // ------------------------------------------------------------------ - // Callbacks: images + // Callbacks: attachments // ------------------------------------------------------------------ - const addComposerImages = (files: File[]) => { + const addComposerTextAttachment = async (file: File): Promise => { + if (!gitCwd) return `Could not resolve the workspace path for '${file.name}'.`; + if (file.size > TEXT_ATTACHMENT_MAX_BYTES) { + return `'${file.name}' exceeds the 1 MB text attachment limit.`; + } + const safeName = file.name.replace(/[^a-zA-Z0-9._-]+/g, "-") || "context.md"; + const relativePath = `.t3/attachments/${randomUUID()}/${safeName}`; + const result = await file + .arrayBuffer() + .then((buffer) => { + const bytes = new Uint8Array(buffer); + if (bytes.includes(0)) return null; + const contents = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + return writeProjectFile({ + environmentId, + input: { cwd: gitCwd, relativePath, contents }, + }); + }) + .catch(() => null); + if (result === null) return `'${file.name}' is not a supported text file.`; + if (result._tag === "Failure") return `Could not attach '${file.name}'.`; + + const currentPrompt = promptRef.current; + const separator = currentPrompt.length > 0 && !/\s$/.test(currentPrompt) ? " " : ""; + applyPromptReplacement( + currentPrompt.length, + currentPrompt.length, + `${separator}${serializeComposerFileLink(resolvePathLinkTarget(relativePath, gitCwd))} `, + ); + return null; + }; + + const addComposerAttachments = async (files: File[]) => { if (!activeThreadId || files.length === 0) return; if (pendingUserInputs.length > 0) { toastManager.add({ type: "error", - title: "Attach images after answering plan questions.", + title: "Attach files after answering plan questions.", }); return; } @@ -1774,7 +1812,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) let error: string | null = null; for (const file of files) { if (!file.type.startsWith("image/")) { - error = `Unsupported file type for '${file.name}'. Please attach image files only.`; + error = (await addComposerTextAttachment(file)) ?? error; continue; } if (file.size > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { @@ -1818,7 +1856,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const imageFiles = files.filter((file) => file.type.startsWith("image/")); if (imageFiles.length === 0) return; event.preventDefault(); - addComposerImages(imageFiles); + void addComposerAttachments(imageFiles); }; const onComposerDragEnter = (event: React.DragEvent) => { @@ -1852,7 +1890,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) dragDepthRef.current = 0; setIsDragOverComposer(false); const files = Array.from(event.dataTransfer.files); - addComposerImages(files); + void addComposerAttachments(files); focusComposer(); }; const handleInterruptPrimaryAction = useCallback(() => { From f044e403be5e2b8afa47c31a9d84348a4e47d2f0 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 15:05:44 -0400 Subject: [PATCH 02/79] Avoid collapsing chat messages with long file links - Base user-message collapse thresholds on visible link text - Add regression coverage for markdown attachment links --- .../components/chat/MessagesTimeline.test.tsx | 23 +++++++++++++++++++ .../src/components/chat/MessagesTimeline.tsx | 10 +++++--- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 0957e025311..b39ebee90dd 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -338,6 +338,29 @@ describe("MessagesTimeline", () => { expect(markup).toContain('data-user-message-collapsible="false"'); }); + it("does not collapse short visible prompts with long file-link destinations", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const fileLinks = Array.from( + { length: 15 }, + (_, index) => + `[test-${index}.ts](/Users/test/project/.t3/attachments/12345678-1234-1234-1234-123456789abc/test-${index}.ts)`, + ); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("test-0.ts"); + expect(markup).toContain("test-14.ts"); + expect(markup).toContain("tell me the contents of each file"); + expect(markup).toContain('data-user-message-body="true"'); + expect(markup).not.toContain("Show full message"); + }); + it("renders inline terminal labels with the composer chip UI", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderToStaticMarkup( diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 1a4dc6b6895..87173caba1f 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1375,15 +1375,19 @@ const MAX_COLLAPSED_USER_MESSAGE_LINES = 8; const MAX_COLLAPSED_USER_MESSAGE_LENGTH = 600; const COLLAPSED_USER_MESSAGE_FADE_HEIGHT_REM = 1.75; const COLLAPSED_USER_MESSAGE_FADE_MASK = `linear-gradient(to bottom, black calc(100% - ${COLLAPSED_USER_MESSAGE_FADE_HEIGHT_REM}rem), transparent)`; +const USER_MESSAGE_FILE_LINK_PATTERN = /\[((?:\\.|[^\]\\])*)\]\([^)\s]+\)/g; function shouldCollapseUserMessage(text: string): boolean { - if (text.trim().length === 0) { + const visibleText = text.replace(USER_MESSAGE_FILE_LINK_PATTERN, (_source, label: string) => + label.replace(/\\(.)/g, "$1"), + ); + if (visibleText.trim().length === 0) { return false; } return ( - text.length > MAX_COLLAPSED_USER_MESSAGE_LENGTH || - text.split("\n").length > MAX_COLLAPSED_USER_MESSAGE_LINES + visibleText.length > MAX_COLLAPSED_USER_MESSAGE_LENGTH || + visibleText.split("\n").length > MAX_COLLAPSED_USER_MESSAGE_LINES ); } From 8d2b4db518fcaa7e4fb37e4d2b3d66261e29d8e4 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 15:29:49 -0400 Subject: [PATCH 03/79] Scope text attachments to their composer --- apps/web/src/components/chat/ChatComposer.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index e3a6198de25..10965fbc078 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1788,12 +1788,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) if (result === null) return `'${file.name}' is not a supported text file.`; if (result._tag === "Failure") return `Could not attach '${file.name}'.`; - const currentPrompt = promptRef.current; + const currentPrompt = getComposerDraft(composerDraftTarget)?.prompt ?? ""; const separator = currentPrompt.length > 0 && !/\s$/.test(currentPrompt) ? " " : ""; - applyPromptReplacement( - currentPrompt.length, - currentPrompt.length, - `${separator}${serializeComposerFileLink(resolvePathLinkTarget(relativePath, gitCwd))} `, + setComposerDraftPrompt( + composerDraftTarget, + `${currentPrompt}${separator}${serializeComposerFileLink( + resolvePathLinkTarget(relativePath, gitCwd), + )} `, ); return null; }; From 558841ffdbdcf3a51d3b77054aaceba145d6a2ac Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 15:30:08 -0400 Subject: [PATCH 04/79] Serialize composer attachment batches --- apps/web/src/components/chat/ChatComposer.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 10965fbc078..f930e1b9ab3 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -905,6 +905,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const mobileComposerExpandReleaseFrameRef = useRef(null); const mobileComposerExpandInFlightRef = useRef(false); const dragDepthRef = useRef(0); + const attachmentQueueRef = useRef>(Promise.resolve()); // ------------------------------------------------------------------ // Derived: composer send state @@ -1844,6 +1845,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setThreadError(activeThreadId, error); }; + const enqueueComposerAttachments = (files: File[]) => { + const pending = attachmentQueueRef.current.then(() => addComposerAttachments(files)); + attachmentQueueRef.current = pending.catch(() => undefined); + return pending; + }; + const removeComposerImage = (imageId: string) => { removeComposerImageFromDraft(imageId); }; @@ -1857,7 +1864,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const imageFiles = files.filter((file) => file.type.startsWith("image/")); if (imageFiles.length === 0) return; event.preventDefault(); - void addComposerAttachments(imageFiles); + void enqueueComposerAttachments(imageFiles); }; const onComposerDragEnter = (event: React.DragEvent) => { @@ -1891,7 +1898,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) dragDepthRef.current = 0; setIsDragOverComposer(false); const files = Array.from(event.dataTransfer.files); - void addComposerAttachments(files); + void enqueueComposerAttachments(files); focusComposer(); }; const handleInterruptPrimaryAction = useCallback(() => { From 9bd8b80062dd9e2fa347c62cbdfd3edd1938f786 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 15:30:24 -0400 Subject: [PATCH 05/79] Clear stale text attachment errors --- apps/web/src/components/chat/ChatComposer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index f930e1b9ab3..a8347ee6d49 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1814,7 +1814,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) let error: string | null = null; for (const file of files) { if (!file.type.startsWith("image/")) { - error = (await addComposerTextAttachment(file)) ?? error; + error = await addComposerTextAttachment(file); continue; } if (file.size > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { From 3e668f61a65a681615724991331289f8f514063f Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 15:30:38 -0400 Subject: [PATCH 06/79] Continue text attachments after image limit --- apps/web/src/components/chat/ChatComposer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index a8347ee6d49..4c564dc3ce4 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1823,7 +1823,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } if (nextImageCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`; - break; + continue; } const previewUrl = URL.createObjectURL(file); nextImages.push({ From 3d0f79ae4cfd07ccb7bcdededae5d178d6604536 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 15:32:02 -0400 Subject: [PATCH 07/79] Block send while attachments are pending --- apps/web/src/components/chat/ChatComposer.tsx | 39 +++++++++++++++++-- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 4c564dc3ce4..991b08aa913 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -128,6 +128,12 @@ import { useAtomCommand } from "~/state/use-atom-command"; import { resolvePathLinkTarget } from "~/terminal-links"; const TEXT_ATTACHMENT_MAX_BYTES = 1024 * 1024; + +function composerAttachmentTargetKey(target: ScopedThreadRef | DraftId): string { + return typeof target === "string" + ? `draft:${target}` + : `thread:${target.environmentId}:${target.threadId}`; +} import { useMediaQuery } from "../../hooks/useMediaQuery"; import type { ReviewCommentContext } from "../../reviewCommentContext"; @@ -887,8 +893,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false); const [isComposerModelPickerOpen, setIsComposerModelPickerOpen] = useState(false); const [isComposerFocused, setIsComposerFocused] = useState(false); + const [pendingAttachmentCounts, setPendingAttachmentCounts] = useState>( + {}, + ); const isMobileViewport = useMediaQuery("max-sm"); const isComposerCollapsedMobile = isMobileViewport && !isComposerFocused; + const composerAttachmentKey = composerAttachmentTargetKey(composerDraftTarget); + const isAttachingFiles = (pendingAttachmentCounts[composerAttachmentKey] ?? 0) > 0; // ------------------------------------------------------------------ // Refs @@ -1142,7 +1153,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) [activePendingIsResponding, activePendingProgress, activePendingResolvedAnswers], ); const collapsedComposerPrimaryActionDisabled = - phase === "running" || isSendBusy || isConnecting || !composerSendState.hasSendableContent; + phase === "running" || + isSendBusy || + isAttachingFiles || + isConnecting || + !composerSendState.hasSendableContent; const collapsedComposerPrimaryActionLabel = "Send message"; const showMobilePendingAnswerActions = isMobileViewport && !isComposerCollapsedMobile && pendingPrimaryAction !== null; @@ -1698,12 +1713,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const submitComposer = useCallback( (event?: { preventDefault: () => void }) => { + if (isAttachingFiles) { + event?.preventDefault(); + return; + } onSend(event); if (shouldBlurMobileComposerOnSubmit()) { blurMobileComposerAfterSend(); } }, - [blurMobileComposerAfterSend, onSend, shouldBlurMobileComposerOnSubmit], + [blurMobileComposerAfterSend, isAttachingFiles, onSend, shouldBlurMobileComposerOnSubmit], ); const expandMobileComposer = useCallback(() => { if (composerBlurFrameRef.current !== null) { @@ -1846,8 +1865,22 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }; const enqueueComposerAttachments = (files: File[]) => { + const targetKey = composerAttachmentTargetKey(composerDraftTarget); + setPendingAttachmentCounts((current) => ({ + ...current, + [targetKey]: (current[targetKey] ?? 0) + 1, + })); const pending = attachmentQueueRef.current.then(() => addComposerAttachments(files)); attachmentQueueRef.current = pending.catch(() => undefined); + const settle = () => { + setPendingAttachmentCounts((current) => { + const nextCount = (current[targetKey] ?? 1) - 1; + if (nextCount > 0) return { ...current, [targetKey]: nextCount }; + const { [targetKey]: _settled, ...remaining } = current; + return remaining; + }); + }; + void pending.then(settle, settle); return pending; }; @@ -2591,7 +2624,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isRunning={phase === "running"} showPlanFollowUpPrompt={pendingUserInputs.length === 0 && showPlanFollowUpPrompt} promptHasText={prompt.trim().length > 0} - isSendBusy={isSendBusy} + isSendBusy={isSendBusy || isAttachingFiles} isConnecting={isConnecting} isEnvironmentUnavailable={environmentUnavailable !== null} isPreparingWorktree={isPreparingWorktree} From 0250de1a5ce4c12f55cd298b8e755f64e2900772 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 15:32:24 -0400 Subject: [PATCH 08/79] Handle pasted text file attachments --- apps/web/src/components/chat/ChatComposer.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 991b08aa913..29f445b3bb7 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1894,10 +1894,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const onComposerPaste = (event: React.ClipboardEvent) => { const files = Array.from(event.clipboardData.files); if (files.length === 0) return; - const imageFiles = files.filter((file) => file.type.startsWith("image/")); - if (imageFiles.length === 0) return; event.preventDefault(); - void enqueueComposerAttachments(imageFiles); + void enqueueComposerAttachments(files); }; const onComposerDragEnter = (event: React.DragEvent) => { From ba5fdab3bda67861b0e759ee15ca4adb4f2ac116 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 15:34:12 -0400 Subject: [PATCH 09/79] Store text attachments outside projects --- apps/web/src/components/chat/ChatComposer.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 29f445b3bb7..3cda60cb8fd 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -134,6 +134,11 @@ function composerAttachmentTargetKey(target: ScopedThreadRef | DraftId): string ? `draft:${target}` : `thread:${target.environmentId}:${target.threadId}`; } + +function textAttachmentStorageCwd(projectCwd: string): string { + const separator = projectCwd.includes("\\") ? "\\" : "/"; + return `${projectCwd.replace(/[\\/]+$/, "")}${separator}..${separator}.t3${separator}attachments`; +} import { useMediaQuery } from "../../hooks/useMediaQuery"; import type { ReviewCommentContext } from "../../reviewCommentContext"; @@ -1792,7 +1797,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return `'${file.name}' exceeds the 1 MB text attachment limit.`; } const safeName = file.name.replace(/[^a-zA-Z0-9._-]+/g, "-") || "context.md"; - const relativePath = `.t3/attachments/${randomUUID()}/${safeName}`; + const attachmentCwd = textAttachmentStorageCwd(gitCwd); + const relativePath = `${randomUUID()}/${safeName}`; const result = await file .arrayBuffer() .then((buffer) => { @@ -1801,7 +1807,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const contents = new TextDecoder("utf-8", { fatal: true }).decode(bytes); return writeProjectFile({ environmentId, - input: { cwd: gitCwd, relativePath, contents }, + input: { cwd: attachmentCwd, relativePath, contents }, }); }) .catch(() => null); @@ -1813,7 +1819,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setComposerDraftPrompt( composerDraftTarget, `${currentPrompt}${separator}${serializeComposerFileLink( - resolvePathLinkTarget(relativePath, gitCwd), + resolvePathLinkTarget(relativePath, attachmentCwd), )} `, ); return null; From 9b6dbd7e4dd51c9b53b3e8d30e6b97c826bb4c6f Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 15:46:25 -0400 Subject: [PATCH 10/79] Report rejected files in mixed attachment batches --- apps/web/src/components/chat/ChatComposer.tsx | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 3cda60cb8fd..cbeef00d53d 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1836,18 +1836,23 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } const nextImages: ComposerImageAttachment[] = []; let nextImageCount = composerImagesRef.current.length; - let error: string | null = null; + const errors: string[] = []; + let attachedCount = 0; for (const file of files) { if (!file.type.startsWith("image/")) { - error = await addComposerTextAttachment(file); + const error = await addComposerTextAttachment(file); + if (error) errors.push(error); + else attachedCount += 1; continue; } if (file.size > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { - error = `'${file.name}' exceeds the ${IMAGE_SIZE_LIMIT_LABEL} attachment limit.`; + errors.push(`'${file.name}' exceeds the ${IMAGE_SIZE_LIMIT_LABEL} attachment limit.`); continue; } if (nextImageCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { - error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`; + errors.push( + `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`, + ); continue; } const previewUrl = URL.createObjectURL(file); @@ -1861,13 +1866,24 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) file, }); nextImageCount += 1; + attachedCount += 1; } if (nextImages.length === 1 && nextImages[0]) { addComposerImage(nextImages[0]); } else if (nextImages.length > 1) { addComposerImagesToDraft(nextImages); } - setThreadError(activeThreadId, error); + if (attachedCount > 0 && errors.length > 0) { + toastManager.add({ + type: "error", + title: "Some files could not be attached", + description: errors.join(" "), + }); + } + setThreadError( + activeThreadId, + attachedCount === 0 ? (errors[errors.length - 1] ?? null) : null, + ); }; const enqueueComposerAttachments = (files: File[]) => { From 42b7f3f3f195f5ece0c61ddd1f16c52073ba17bf Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 15:47:34 -0400 Subject: [PATCH 11/79] Render extensionless attachments as files --- .../src/components/chat/FileTagChip.test.tsx | 17 +++++++++++++++++ apps/web/src/components/chat/FileTagChip.tsx | 8 +++++++- 2 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/components/chat/FileTagChip.test.tsx diff --git a/apps/web/src/components/chat/FileTagChip.test.tsx b/apps/web/src/components/chat/FileTagChip.test.tsx new file mode 100644 index 00000000000..46e85c63369 --- /dev/null +++ b/apps/web/src/components/chat/FileTagChip.test.tsx @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { inferFileTagEntryKind } from "./FileTagChip"; + +describe("inferFileTagEntryKind", () => { + it("renders extensionless generated attachments as files", () => { + expect( + inferFileTagEntryKind( + "/Users/test/.t3/attachments/12345678-1234-1234-1234-123456789abc/extensionless-test", + ), + ).toBe("file"); + }); + + it("preserves directory inference for ordinary extensionless paths", () => { + expect(inferFileTagEntryKind("packages/client-runtime")).toBe("directory"); + }); +}); diff --git a/apps/web/src/components/chat/FileTagChip.tsx b/apps/web/src/components/chat/FileTagChip.tsx index d3cd656b37c..49bf1058012 100644 --- a/apps/web/src/components/chat/FileTagChip.tsx +++ b/apps/web/src/components/chat/FileTagChip.tsx @@ -11,6 +11,12 @@ import { PierreEntryIcon } from "./PierreEntryIcon"; export const FILE_TAG_CHIP_CLASS_NAME = COMPOSER_INLINE_CHIP_CLASS_NAME; export const CHAT_FILE_TAG_CHIP_CLASS_NAME = CHAT_INLINE_CHIP_CLASS_NAME; +const TEXT_ATTACHMENT_PATH_PATTERN = /(?:^|[\\/])\.t3[\\/]attachments[\\/]/; + +export function inferFileTagEntryKind(path: string): "file" | "directory" { + return TEXT_ATTACHMENT_PATH_PATTERN.test(path) ? "file" : inferEntryKindFromPath(path); +} + export function FileTagChipContent(props: { path: string; label: string; @@ -21,7 +27,7 @@ export function FileTagChipContent(props: { <> From e38c0b47fe196d6231c181f136dabb86d559b582 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 15:50:21 -0400 Subject: [PATCH 12/79] Count queued images from composer drafts --- apps/web/src/components/chat/ChatComposer.tsx | 6 +++++- .../chat/composerAttachmentBatch.test.ts | 16 ++++++++++++++++ .../components/chat/composerAttachmentBatch.ts | 6 ++++++ 3 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/components/chat/composerAttachmentBatch.test.ts create mode 100644 apps/web/src/components/chat/composerAttachmentBatch.ts diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index cbeef00d53d..a586e202af9 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -76,6 +76,7 @@ import { ComposerPendingApprovalPanel } from "./ComposerPendingApprovalPanel"; import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner"; import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight"; +import { currentComposerImageCount } from "./composerAttachmentBatch"; import { searchSlashCommandItems } from "./composerSlashCommandSearch"; import { getComposerPromptInjectionState, @@ -1835,7 +1836,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return; } const nextImages: ComposerImageAttachment[] = []; - let nextImageCount = composerImagesRef.current.length; + let nextImageCount = currentComposerImageCount( + getComposerDraft(composerDraftTarget), + composerImagesRef.current, + ); const errors: string[] = []; let attachedCount = 0; for (const file of files) { diff --git a/apps/web/src/components/chat/composerAttachmentBatch.test.ts b/apps/web/src/components/chat/composerAttachmentBatch.test.ts new file mode 100644 index 00000000000..fff23353d7e --- /dev/null +++ b/apps/web/src/components/chat/composerAttachmentBatch.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { currentComposerImageCount } from "./composerAttachmentBatch"; + +describe("currentComposerImageCount", () => { + it("uses the synchronous draft count between queued batches", () => { + const staleRefImages = [{ id: "first" }]; + const latestDraft = { images: [{ id: "first" }, { id: "second" }] }; + + expect(currentComposerImageCount(latestDraft, staleRefImages)).toBe(2); + }); + + it("falls back to the synchronized ref before a draft exists", () => { + expect(currentComposerImageCount(null, [{ id: "first" }])).toBe(1); + }); +}); diff --git a/apps/web/src/components/chat/composerAttachmentBatch.ts b/apps/web/src/components/chat/composerAttachmentBatch.ts new file mode 100644 index 00000000000..f1dda3e2504 --- /dev/null +++ b/apps/web/src/components/chat/composerAttachmentBatch.ts @@ -0,0 +1,6 @@ +export function currentComposerImageCount( + draft: { readonly images: ReadonlyArray } | null, + fallbackImages: ReadonlyArray, +): number { + return draft?.images.length ?? fallbackImages.length; +} From 29be486efe8b22b65223606a68d3c95f56ccd72a Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 16:19:09 -0400 Subject: [PATCH 13/79] Store text attachments in environment data --- apps/server/src/attachmentStore.test.ts | 14 +++++++++ apps/server/src/attachmentStore.ts | 19 ++++++++++++ apps/server/src/ws.ts | 31 +++++++++++++++++++ apps/web/src/components/chat/ChatComposer.tsx | 19 +++++------- packages/client-runtime/src/state/assets.ts | 6 +++- packages/contracts/src/assets.ts | 24 ++++++++++++++ packages/contracts/src/ipc.ts | 10 +++++- packages/contracts/src/rpc.ts | 17 +++++++++- 8 files changed, 125 insertions(+), 15 deletions(-) diff --git a/apps/server/src/attachmentStore.test.ts b/apps/server/src/attachmentStore.test.ts index e21d9cf62cf..664535718ca 100644 --- a/apps/server/src/attachmentStore.test.ts +++ b/apps/server/src/attachmentStore.test.ts @@ -7,6 +7,7 @@ import { describe, expect, it } from "vite-plus/test"; import { createAttachmentId, + createTextAttachmentPath, parseThreadSegmentFromAttachmentId, resolveAttachmentPathById, } from "./attachmentStore.ts"; @@ -77,4 +78,17 @@ describe("attachmentStore", () => { NodeFS.rmSync(attachmentsDir, { recursive: true, force: true }); } }); + + it("creates normalized text attachment paths under the environment attachment store", () => { + const attachmentsDir = NodePath.join(NodeOS.tmpdir(), "t3code-attachments"); + const attachmentPath = createTextAttachmentPath({ + attachmentsDir, + fileName: "../unsafe name.ts", + }); + + expect(NodePath.relative(attachmentsDir, attachmentPath)).toMatch( + /^text[/\\][0-9a-f-]+[/\\]\.\.-unsafe-name\.ts$/, + ); + expect(attachmentPath).not.toContain(`${NodePath.sep}..${NodePath.sep}`); + }); }); diff --git a/apps/server/src/attachmentStore.ts b/apps/server/src/attachmentStore.ts index 3d5b531db21..a591ff279c2 100644 --- a/apps/server/src/attachmentStore.ts +++ b/apps/server/src/attachmentStore.ts @@ -1,6 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off import * as NodeCrypto from "node:crypto"; import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; import type { ChatAttachment } from "@t3tools/contracts"; @@ -11,6 +12,7 @@ import { import { inferImageExtension, SAFE_IMAGE_FILE_EXTENSIONS } from "./imageMime.ts"; const ATTACHMENT_FILENAME_EXTENSIONS = [...SAFE_IMAGE_FILE_EXTENSIONS, ".bin"]; +const TEXT_ATTACHMENT_DIRECTORY = "text"; const ATTACHMENT_ID_THREAD_SEGMENT_MAX_CHARS = 80; const ATTACHMENT_ID_THREAD_SEGMENT_PATTERN = "[a-z0-9_]+(?:-[a-z0-9_]+)*"; const ATTACHMENT_ID_UUID_PATTERN = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"; @@ -108,3 +110,20 @@ export function parseAttachmentIdFromRelativePath(relativePath: string): string const id = normalized.slice(0, extensionIndex); return id.length > 0 && !id.includes(".") ? id : null; } + +export function createTextAttachmentPath(input: { + readonly attachmentsDir: string; + readonly fileName: string; +}): string { + const sanitizedName = input.fileName.replace(/[^a-zA-Z0-9._-]+/g, "-"); + const safeName = + sanitizedName.length > 0 && sanitizedName !== "." && sanitizedName !== ".." + ? sanitizedName + : "context.txt"; + return NodePath.join( + input.attachmentsDir, + TEXT_ATTACHMENT_DIRECTORY, + NodeCrypto.randomUUID(), + safeName, + ); +} diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index fafdbfa6814..f56449c1cea 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -3,8 +3,10 @@ import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; @@ -50,6 +52,7 @@ import { FilesystemBrowseError, AssetWorkspaceContextNotFoundError, AssetWorkspaceContextResolutionError, + AssetTextAttachmentWriteError, EnvironmentAuthorizationError, ThreadId, type TerminalAttachStreamEvent, @@ -84,6 +87,7 @@ import * as TerminalManager from "./terminal/Manager.ts"; import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; import * as PreviewManager from "./preview/Manager.ts"; import { issueAssetUrl } from "./assets/AssetAccess.ts"; +import { createTextAttachmentPath } from "./attachmentStore.ts"; import * as PortScanner from "./preview/PortScanner.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; @@ -308,6 +312,7 @@ const RPC_REQUIRED_SCOPE = new Map([ [WS_METHODS.shellOpenInEditor, AuthOrchestrationOperateScope], [WS_METHODS.filesystemBrowse, AuthOrchestrationReadScope], [WS_METHODS.assetsCreateUrl, AuthOrchestrationReadScope], + [WS_METHODS.assetsWriteTextAttachment, AuthOrchestrationOperateScope], [WS_METHODS.subscribeVcsStatus, AuthOrchestrationReadScope], [WS_METHODS.vcsRefreshStatus, AuthOrchestrationReadScope], [WS_METHODS.vcsPull, AuthOrchestrationOperateScope], @@ -395,6 +400,8 @@ const makeWsRpcLayer = ( Effect.gen(function* () { const currentSessionId = currentSession.sessionId; const crypto = yield* Crypto.Crypto; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; const orchestrationEngine = yield* OrchestrationEngine.OrchestrationEngineService; const checkpointDiffQuery = yield* CheckpointDiffQuery.CheckpointDiffQuery; @@ -1519,6 +1526,30 @@ const makeWsRpcLayer = ( }), { "rpc.aggregate": "workspace" }, ), + [WS_METHODS.assetsWriteTextAttachment]: (input) => + observeRpcEffect( + WS_METHODS.assetsWriteTextAttachment, + Effect.gen(function* () { + const attachmentPath = createTextAttachmentPath({ + attachmentsDir: config.attachmentsDir, + fileName: input.fileName, + }); + yield* fileSystem + .makeDirectory(path.dirname(attachmentPath), { recursive: true }) + .pipe( + Effect.andThen(fileSystem.writeFileString(attachmentPath, input.contents)), + Effect.mapError( + (cause) => + new AssetTextAttachmentWriteError({ + fileName: input.fileName, + cause, + }), + ), + ); + return { path: attachmentPath }; + }), + { "rpc.aggregate": "workspace" }, + ), [WS_METHODS.subscribeVcsStatus]: (input) => observeRpcStream( WS_METHODS.subscribeVcsStatus, diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index a586e202af9..eba1d012f3c 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -124,7 +124,7 @@ import { } from "../../lib/contextWindow"; import { formatProviderSkillDisplayName } from "../../providerSkillPresentation"; import { searchProviderSkills } from "../../providerSkillSearch"; -import { projectEnvironment } from "~/state/projects"; +import { assetEnvironment } from "~/state/assets"; import { useAtomCommand } from "~/state/use-atom-command"; import { resolvePathLinkTarget } from "~/terminal-links"; @@ -136,10 +136,6 @@ function composerAttachmentTargetKey(target: ScopedThreadRef | DraftId): string : `thread:${target.environmentId}:${target.threadId}`; } -function textAttachmentStorageCwd(projectCwd: string): string { - const separator = projectCwd.includes("\\") ? "\\" : "/"; - return `${projectCwd.replace(/[\\/]+$/, "")}${separator}..${separator}.t3${separator}attachments`; -} import { useMediaQuery } from "../../hooks/useMediaQuery"; import type { ReviewCommentContext } from "../../reviewCommentContext"; @@ -630,7 +626,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // Store subscriptions (prompt / images / terminal contexts) // ------------------------------------------------------------------ const composerDraft = useComposerThreadDraft(composerDraftTarget); - const writeProjectFile = useAtomCommand(projectEnvironment.writeFile, { reportFailure: false }); + const writeTextAttachment = useAtomCommand(assetEnvironment.writeTextAttachment, { + reportFailure: false, + }); const prompt = composerDraft.prompt; const composerImages = composerDraft.images; const composerTerminalContexts = composerDraft.terminalContexts; @@ -1797,18 +1795,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) if (file.size > TEXT_ATTACHMENT_MAX_BYTES) { return `'${file.name}' exceeds the 1 MB text attachment limit.`; } - const safeName = file.name.replace(/[^a-zA-Z0-9._-]+/g, "-") || "context.md"; - const attachmentCwd = textAttachmentStorageCwd(gitCwd); - const relativePath = `${randomUUID()}/${safeName}`; const result = await file .arrayBuffer() .then((buffer) => { const bytes = new Uint8Array(buffer); if (bytes.includes(0)) return null; const contents = new TextDecoder("utf-8", { fatal: true }).decode(bytes); - return writeProjectFile({ + return writeTextAttachment({ environmentId, - input: { cwd: attachmentCwd, relativePath, contents }, + input: { fileName: file.name || "context.txt", contents }, }); }) .catch(() => null); @@ -1820,7 +1815,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setComposerDraftPrompt( composerDraftTarget, `${currentPrompt}${separator}${serializeComposerFileLink( - resolvePathLinkTarget(relativePath, attachmentCwd), + resolvePathLinkTarget(result.value.path, gitCwd), )} `, ); return null; diff --git a/packages/client-runtime/src/state/assets.ts b/packages/client-runtime/src/state/assets.ts index e407f5d0028..a219f3e21c5 100644 --- a/packages/client-runtime/src/state/assets.ts +++ b/packages/client-runtime/src/state/assets.ts @@ -3,7 +3,7 @@ import * as Schema from "effect/Schema"; import { Atom } from "effect/unstable/reactivity"; import type { EnvironmentRegistry } from "../connection/registry.ts"; -import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; +import { createEnvironmentRpcCommand, createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; const ASSET_URL_REFRESH_INTERVAL_MS = 30 * 60_000; const ASSET_URL_STALE_TIME_MS = 5 * 60_000; @@ -72,6 +72,10 @@ export function createAssetEnvironmentAtoms( return { createUrl, + writeTextAttachment: createEnvironmentRpcCommand(runtime, { + label: "environment-data:assets:write-text-attachment", + tag: WS_METHODS.assetsWriteTextAttachment, + }), createUrls: (target: { readonly environmentId: EnvironmentId; readonly resources: ReadonlyArray; diff --git a/packages/contracts/src/assets.ts b/packages/contracts/src/assets.ts index 0dbe7d9fa25..3066e1106a2 100644 --- a/packages/contracts/src/assets.ts +++ b/packages/contracts/src/assets.ts @@ -3,6 +3,7 @@ import * as Schema from "effect/Schema"; import { ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; const ASSET_PATH_MAX_LENGTH = 1024; +const TEXT_ATTACHMENT_CONTENT_MAX_LENGTH = 1024 * 1024; export const AssetResource = Schema.Union([ Schema.TaggedStruct("workspace-file", { @@ -29,6 +30,29 @@ export const AssetCreateUrlResult = Schema.Struct({ }); export type AssetCreateUrlResult = typeof AssetCreateUrlResult.Type; +export const AssetWriteTextAttachmentInput = Schema.Struct({ + fileName: TrimmedNonEmptyString.check(Schema.isMaxLength(256)), + contents: Schema.String.check(Schema.isMaxLength(TEXT_ATTACHMENT_CONTENT_MAX_LENGTH)), +}); +export type AssetWriteTextAttachmentInput = typeof AssetWriteTextAttachmentInput.Type; + +export const AssetWriteTextAttachmentResult = Schema.Struct({ + path: TrimmedNonEmptyString.check(Schema.isMaxLength(4096)), +}); +export type AssetWriteTextAttachmentResult = typeof AssetWriteTextAttachmentResult.Type; + +export class AssetTextAttachmentWriteError extends Schema.TaggedErrorClass()( + "AssetTextAttachmentWriteError", + { + fileName: TrimmedNonEmptyString, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to store text attachment '${this.fileName}'.`; + } +} + export class AssetWorkspaceContextNotFoundError extends Schema.TaggedErrorClass()( "AssetWorkspaceContextNotFoundError", { diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index bbb4d934ca2..dcfdf2a7f0e 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -20,7 +20,12 @@ import type { } from "./git.ts"; import type { ReviewDiffPreviewInput, ReviewDiffPreviewResult } from "./review.ts"; import type { FilesystemBrowseInput, FilesystemBrowseResult } from "./filesystem.ts"; -import type { AssetCreateUrlInput, AssetCreateUrlResult } from "./assets.ts"; +import type { + AssetCreateUrlInput, + AssetCreateUrlResult, + AssetWriteTextAttachmentInput, + AssetWriteTextAttachmentResult, +} from "./assets.ts"; import type { ProjectListEntriesInput, ProjectListEntriesResult, @@ -1165,6 +1170,9 @@ export interface EnvironmentApi { }; assets: { createUrl: (input: AssetCreateUrlInput) => Promise; + writeTextAttachment: ( + input: AssetWriteTextAttachmentInput, + ) => Promise; }; sourceControl: { lookupRepository: ( diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 48c5d9a774d..dfa0ac938fe 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -13,7 +13,14 @@ import { FilesystemBrowseResult, FilesystemBrowseError, } from "./filesystem.ts"; -import { AssetAccessError, AssetCreateUrlInput, AssetCreateUrlResult } from "./assets.ts"; +import { + AssetAccessError, + AssetCreateUrlInput, + AssetCreateUrlResult, + AssetTextAttachmentWriteError, + AssetWriteTextAttachmentInput, + AssetWriteTextAttachmentResult, +} from "./assets.ts"; import { GitActionProgressEvent, VcsSwitchRefInput, @@ -160,6 +167,7 @@ export const WS_METHODS = { // Filesystem methods filesystemBrowse: "filesystem.browse", assetsCreateUrl: "assets.createUrl", + assetsWriteTextAttachment: "assets.writeTextAttachment", // VCS methods vcsPull: "vcs.pull", @@ -395,6 +403,12 @@ export const WsAssetsCreateUrlRpc = Rpc.make(WS_METHODS.assetsCreateUrl, { error: Schema.Union([AssetAccessError, EnvironmentAuthorizationError]), }); +export const WsAssetsWriteTextAttachmentRpc = Rpc.make(WS_METHODS.assetsWriteTextAttachment, { + payload: AssetWriteTextAttachmentInput, + success: AssetWriteTextAttachmentResult, + error: Schema.Union([AssetTextAttachmentWriteError, EnvironmentAuthorizationError]), +}); + export const WsSubscribeVcsStatusRpc = Rpc.make(WS_METHODS.subscribeVcsStatus, { payload: VcsStatusInput, success: VcsStatusStreamEvent, @@ -706,6 +720,7 @@ export const WsRpcGroup = RpcGroup.make( WsShellOpenInEditorRpc, WsFilesystemBrowseRpc, WsAssetsCreateUrlRpc, + WsAssetsWriteTextAttachmentRpc, WsSubscribeVcsStatusRpc, WsVcsPullRpc, WsVcsRefreshStatusRpc, From 86105fa93613b284b2017c4442f7bf5084c81cae Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 16:19:37 -0400 Subject: [PATCH 14/79] Synchronize attachment prompts before send --- apps/web/src/components/chat/ChatComposer.tsx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index eba1d012f3c..edf99aa70b0 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -921,6 +921,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const mobileComposerExpandInFlightRef = useRef(false); const dragDepthRef = useRef(0); const attachmentQueueRef = useRef>(Promise.resolve()); + const composerAttachmentKeyRef = useRef(composerAttachmentKey); + composerAttachmentKeyRef.current = composerAttachmentKey; // ------------------------------------------------------------------ // Derived: composer send state @@ -1812,12 +1814,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const currentPrompt = getComposerDraft(composerDraftTarget)?.prompt ?? ""; const separator = currentPrompt.length > 0 && !/\s$/.test(currentPrompt) ? " " : ""; - setComposerDraftPrompt( - composerDraftTarget, - `${currentPrompt}${separator}${serializeComposerFileLink( - resolvePathLinkTarget(result.value.path, gitCwd), - )} `, - ); + const nextPrompt = `${currentPrompt}${separator}${serializeComposerFileLink( + resolvePathLinkTarget(result.value.path, gitCwd), + )} `; + if (composerAttachmentKeyRef.current === composerAttachmentKey) { + promptRef.current = nextPrompt; + } + setComposerDraftPrompt(composerDraftTarget, nextPrompt); return null; }; From bc71d7b39e050fbf8bbd5e33c2ef7ee291a7e793 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 16:20:46 -0400 Subject: [PATCH 15/79] Recognize generated text attachment links --- apps/web/src/components/chat/FileTagChip.test.tsx | 5 +++++ apps/web/src/components/chat/FileTagChip.tsx | 5 ++--- apps/web/src/markdown-links.test.ts | 11 +++++++++++ apps/web/src/markdown-links.ts | 2 ++ apps/web/src/textAttachmentPaths.ts | 9 +++++++++ 5 files changed, 29 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/textAttachmentPaths.ts diff --git a/apps/web/src/components/chat/FileTagChip.test.tsx b/apps/web/src/components/chat/FileTagChip.test.tsx index 46e85c63369..8f347173ad7 100644 --- a/apps/web/src/components/chat/FileTagChip.test.tsx +++ b/apps/web/src/components/chat/FileTagChip.test.tsx @@ -9,6 +9,11 @@ describe("inferFileTagEntryKind", () => { "/Users/test/.t3/attachments/12345678-1234-1234-1234-123456789abc/extensionless-test", ), ).toBe("file"); + expect( + inferFileTagEntryKind( + "/var/t3-data/attachments/text/12345678-1234-1234-1234-123456789abc/extensionless-test", + ), + ).toBe("file"); }); it("preserves directory inference for ordinary extensionless paths", () => { diff --git a/apps/web/src/components/chat/FileTagChip.tsx b/apps/web/src/components/chat/FileTagChip.tsx index 49bf1058012..ded409b42bf 100644 --- a/apps/web/src/components/chat/FileTagChip.tsx +++ b/apps/web/src/components/chat/FileTagChip.tsx @@ -1,4 +1,5 @@ import { inferEntryKindFromPath } from "../../pierre-icons"; +import { isTextAttachmentPath } from "../../textAttachmentPaths"; import { CHAT_INLINE_CHIP_CLASS_NAME, CHAT_INLINE_CHIP_LABEL_CLASS_NAME, @@ -11,10 +12,8 @@ import { PierreEntryIcon } from "./PierreEntryIcon"; export const FILE_TAG_CHIP_CLASS_NAME = COMPOSER_INLINE_CHIP_CLASS_NAME; export const CHAT_FILE_TAG_CHIP_CLASS_NAME = CHAT_INLINE_CHIP_CLASS_NAME; -const TEXT_ATTACHMENT_PATH_PATTERN = /(?:^|[\\/])\.t3[\\/]attachments[\\/]/; - export function inferFileTagEntryKind(path: string): "file" | "directory" { - return TEXT_ATTACHMENT_PATH_PATTERN.test(path) ? "file" : inferEntryKindFromPath(path); + return isTextAttachmentPath(path) ? "file" : inferEntryKindFromPath(path); } export function FileTagChipContent(props: { diff --git a/apps/web/src/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts index 5691ffa8895..08dda95cd61 100644 --- a/apps/web/src/markdown-links.test.ts +++ b/apps/web/src/markdown-links.test.ts @@ -107,6 +107,17 @@ describe("resolveMarkdownFileLinkTarget", () => { }); }); + it("recognizes extensionless generated attachments outside standard posix roots", () => { + const attachmentPath = + "/srv/t3-data/attachments/text/12345678-1234-1234-1234-123456789abc/extensionless-test"; + + expect(resolveMarkdownFileLinkMeta(attachmentPath, "/repo/project")).toMatchObject({ + filePath: attachmentPath, + basename: "extensionless-test", + workspaceRelativePath: null, + }); + }); + it("normalizes slash-prefixed windows drive paths before resolving", () => { expect( resolveMarkdownFileLinkTarget( diff --git a/apps/web/src/markdown-links.ts b/apps/web/src/markdown-links.ts index 1e24de8bb1d..62dbfac9a71 100644 --- a/apps/web/src/markdown-links.ts +++ b/apps/web/src/markdown-links.ts @@ -1,5 +1,6 @@ import { formatWorkspaceRelativePath } from "./filePathDisplay"; import { resolvePathLinkTarget, splitPathAndPosition } from "./terminal-links"; +import { isTextAttachmentPath } from "./textAttachmentPaths"; const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/; const WINDOWS_UNC_PATH_PATTERN = /^\\\\/; @@ -94,6 +95,7 @@ export function rewriteMarkdownFileUriHref(href: string | undefined): string | n function looksLikePosixFilesystemPath(path: string): boolean { if (!path.startsWith("/")) return false; + if (isTextAttachmentPath(path)) return true; if (POSIX_FILE_ROOT_PREFIXES.some((prefix) => path.startsWith(prefix))) return true; if (POSITION_SUFFIX_PATTERN.test(path)) return true; const basename = path.slice(path.lastIndexOf("/") + 1); diff --git a/apps/web/src/textAttachmentPaths.ts b/apps/web/src/textAttachmentPaths.ts new file mode 100644 index 00000000000..edac41d4537 --- /dev/null +++ b/apps/web/src/textAttachmentPaths.ts @@ -0,0 +1,9 @@ +const UUID_PATH_SEGMENT = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"; +const TEXT_ATTACHMENT_PATH_PATTERN = new RegExp( + `(?:^|[\\\\/])(?:\\.t3[\\\\/]attachments|attachments[\\\\/]text)[\\\\/]${UUID_PATH_SEGMENT}[\\\\/]`, + "i", +); + +export function isTextAttachmentPath(path: string): boolean { + return TEXT_ATTACHMENT_PATH_PATTERN.test(path); +} From 18506086cd246b7550d86386d1cc3f38be929645 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 16:25:43 -0400 Subject: [PATCH 16/79] Open generated HTML attachments in editors --- apps/web/src/components/ChatMarkdown.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index b04e98a8a60..96046ae840e 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -65,6 +65,7 @@ import { resolveMarkdownFileLinkMeta, rewriteMarkdownFileUriHref, } from "../markdown-links"; +import { isTextAttachmentPath } from "../textAttachmentPaths"; import { readLocalApi } from "../localApi"; import { cn } from "../lib/utils"; import { useRightPanelStore } from "../rightPanelStore"; @@ -1476,6 +1477,7 @@ function ChatMarkdown({ onOpenInBrowser={ threadRef && isPreviewSupportedInRuntime() && + !isTextAttachmentPath(fileLinkMeta.filePath) && isBrowserPreviewFile(fileLinkMeta.filePath) ? () => openMarkdownFileInPreview(fileLinkMeta.filePath) : undefined From 2b59f2dff8388a562290cad4709d07bbead43a4b Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 16:26:20 -0400 Subject: [PATCH 17/79] Recognize legacy flat text attachments --- apps/web/src/components/chat/FileTagChip.test.tsx | 5 +++++ apps/web/src/markdown-links.test.ts | 9 +++++++++ apps/web/src/textAttachmentPaths.ts | 8 +++++++- 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/FileTagChip.test.tsx b/apps/web/src/components/chat/FileTagChip.test.tsx index 8f347173ad7..361744cf3c5 100644 --- a/apps/web/src/components/chat/FileTagChip.test.tsx +++ b/apps/web/src/components/chat/FileTagChip.test.tsx @@ -14,6 +14,11 @@ describe("inferFileTagEntryKind", () => { "/var/t3-data/attachments/text/12345678-1234-1234-1234-123456789abc/extensionless-test", ), ).toBe("file"); + expect( + inferFileTagEntryKind( + "/srv/project/.t3/attachments/12345678-1234-1234-1234-123456789abc-extensionless-test", + ), + ).toBe("file"); }); it("preserves directory inference for ordinary extensionless paths", () => { diff --git a/apps/web/src/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts index 08dda95cd61..ec03ae3f814 100644 --- a/apps/web/src/markdown-links.test.ts +++ b/apps/web/src/markdown-links.test.ts @@ -116,6 +116,15 @@ describe("resolveMarkdownFileLinkTarget", () => { basename: "extensionless-test", workspaceRelativePath: null, }); + expect( + resolveMarkdownFileLinkMeta( + "/srv/project/.t3/attachments/12345678-1234-1234-1234-123456789abc-extensionless-test", + "/repo/project", + ), + ).toMatchObject({ + basename: "12345678-1234-1234-1234-123456789abc-extensionless-test", + workspaceRelativePath: null, + }); }); it("normalizes slash-prefixed windows drive paths before resolving", () => { diff --git a/apps/web/src/textAttachmentPaths.ts b/apps/web/src/textAttachmentPaths.ts index edac41d4537..5f0c744dc17 100644 --- a/apps/web/src/textAttachmentPaths.ts +++ b/apps/web/src/textAttachmentPaths.ts @@ -3,7 +3,13 @@ const TEXT_ATTACHMENT_PATH_PATTERN = new RegExp( `(?:^|[\\\\/])(?:\\.t3[\\\\/]attachments|attachments[\\\\/]text)[\\\\/]${UUID_PATH_SEGMENT}[\\\\/]`, "i", ); +const LEGACY_FLAT_TEXT_ATTACHMENT_PATH_PATTERN = new RegExp( + `(?:^|[\\\\/])\\.t3[\\\\/]attachments[\\\\/]${UUID_PATH_SEGMENT}-[^\\\\/]+$`, + "i", +); export function isTextAttachmentPath(path: string): boolean { - return TEXT_ATTACHMENT_PATH_PATTERN.test(path); + return ( + TEXT_ATTACHMENT_PATH_PATTERN.test(path) || LEGACY_FLAT_TEXT_ATTACHMENT_PATH_PATTERN.test(path) + ); } From 633c913f0da5864174f97a7cddff75a7187e3153 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 16:29:07 -0400 Subject: [PATCH 18/79] Use portable text attachment filenames --- apps/server/src/attachmentStore.test.ts | 13 +++++++++++++ apps/server/src/attachmentStore.ts | 14 ++++++++++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/apps/server/src/attachmentStore.test.ts b/apps/server/src/attachmentStore.test.ts index 664535718ca..ba83d594c5d 100644 --- a/apps/server/src/attachmentStore.test.ts +++ b/apps/server/src/attachmentStore.test.ts @@ -91,4 +91,17 @@ describe("attachmentStore", () => { ); expect(attachmentPath).not.toContain(`${NodePath.sep}..${NodePath.sep}`); }); + + it("bounds text attachment basenames and avoids Windows reserved names", () => { + const attachmentsDir = NodePath.join(NodeOS.tmpdir(), "t3code-attachments"); + const reservedPath = createTextAttachmentPath({ attachmentsDir, fileName: "CON.ts" }); + const longPath = createTextAttachmentPath({ + attachmentsDir, + fileName: `${"a".repeat(300)}.tsx`, + }); + + expect(NodePath.basename(reservedPath)).toBe("_CON.ts"); + expect(NodePath.basename(longPath)).toHaveLength(120); + expect(NodePath.basename(longPath)).toMatch(/\.tsx$/); + }); }); diff --git a/apps/server/src/attachmentStore.ts b/apps/server/src/attachmentStore.ts index a591ff279c2..1a690d5ab4a 100644 --- a/apps/server/src/attachmentStore.ts +++ b/apps/server/src/attachmentStore.ts @@ -13,6 +13,8 @@ import { inferImageExtension, SAFE_IMAGE_FILE_EXTENSIONS } from "./imageMime.ts" const ATTACHMENT_FILENAME_EXTENSIONS = [...SAFE_IMAGE_FILE_EXTENSIONS, ".bin"]; const TEXT_ATTACHMENT_DIRECTORY = "text"; +const TEXT_ATTACHMENT_FILE_NAME_MAX_CHARS = 120; +const WINDOWS_RESERVED_FILE_NAME_PATTERN = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i; const ATTACHMENT_ID_THREAD_SEGMENT_MAX_CHARS = 80; const ATTACHMENT_ID_THREAD_SEGMENT_PATTERN = "[a-z0-9_]+(?:-[a-z0-9_]+)*"; const ATTACHMENT_ID_UUID_PATTERN = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"; @@ -115,11 +117,19 @@ export function createTextAttachmentPath(input: { readonly attachmentsDir: string; readonly fileName: string; }): string { - const sanitizedName = input.fileName.replace(/[^a-zA-Z0-9._-]+/g, "-"); - const safeName = + const sanitizedName = input.fileName.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/\.+$/, ""); + let safeName = sanitizedName.length > 0 && sanitizedName !== "." && sanitizedName !== ".." ? sanitizedName : "context.txt"; + if (WINDOWS_RESERVED_FILE_NAME_PATTERN.test(safeName)) { + safeName = `_${safeName}`; + } + if (safeName.length > TEXT_ATTACHMENT_FILE_NAME_MAX_CHARS) { + const extensionIndex = safeName.lastIndexOf("."); + const extension = extensionIndex > 0 ? safeName.slice(extensionIndex).slice(0, 20) : ""; + safeName = `${safeName.slice(0, TEXT_ATTACHMENT_FILE_NAME_MAX_CHARS - extension.length)}${extension}`; + } return NodePath.join( input.attachmentsDir, TEXT_ATTACHMENT_DIRECTORY, From c36a5281764d1340ff6e279dd5d9afdebbdbbb30 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 16:32:47 -0400 Subject: [PATCH 19/79] Clean up generated text attachments --- apps/server/src/attachmentStore.test.ts | 21 ++++++++ apps/server/src/attachmentStore.ts | 48 +++++++++++++++++ .../Layers/ProjectionPipeline.test.ts | 24 ++++++++- .../Layers/ProjectionPipeline.ts | 54 +++++++++++++++++++ apps/server/src/ws.ts | 24 ++++++++- apps/web/src/components/chat/ChatComposer.tsx | 21 +++++++- apps/web/src/textAttachmentPaths.test.ts | 21 ++++++++ apps/web/src/textAttachmentPaths.ts | 13 +++++ packages/client-runtime/src/state/assets.ts | 4 ++ packages/contracts/src/assets.ts | 22 ++++++++ packages/contracts/src/ipc.ts | 5 ++ packages/contracts/src/rpc.ts | 11 ++++ 12 files changed, 265 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/textAttachmentPaths.test.ts diff --git a/apps/server/src/attachmentStore.test.ts b/apps/server/src/attachmentStore.test.ts index ba83d594c5d..32ffc334aa0 100644 --- a/apps/server/src/attachmentStore.test.ts +++ b/apps/server/src/attachmentStore.test.ts @@ -6,10 +6,12 @@ import * as NodePath from "node:path"; import { describe, expect, it } from "vite-plus/test"; import { + collectTextAttachmentRelativePaths, createAttachmentId, createTextAttachmentPath, parseThreadSegmentFromAttachmentId, resolveAttachmentPathById, + textAttachmentDirectory, } from "./attachmentStore.ts"; describe("attachmentStore", () => { @@ -104,4 +106,23 @@ describe("attachmentStore", () => { expect(NodePath.basename(longPath)).toHaveLength(120); expect(NodePath.basename(longPath)).toMatch(/\.tsx$/); }); + + it("validates and collects server-owned text attachment paths", () => { + const attachmentsDir = NodePath.join(NodeOS.tmpdir(), "t3code-attachments"); + const attachmentPath = createTextAttachmentPath({ attachmentsDir, fileName: "notes.txt" }); + const encodedPath = encodeURI(attachmentPath).replaceAll("\\", "%5C"); + + expect(textAttachmentDirectory({ attachmentsDir, path: attachmentPath })).toBe( + NodePath.dirname(attachmentPath), + ); + expect( + collectTextAttachmentRelativePaths({ + attachmentsDir, + text: `[notes.txt](${encodedPath})`, + }), + ).toEqual(new Set([NodePath.relative(attachmentsDir, attachmentPath).replaceAll("\\", "/")])); + expect( + textAttachmentDirectory({ attachmentsDir, path: NodePath.join(attachmentsDir, "../nope") }), + ).toBeNull(); + }); }); diff --git a/apps/server/src/attachmentStore.ts b/apps/server/src/attachmentStore.ts index 1a690d5ab4a..6f62dd5365c 100644 --- a/apps/server/src/attachmentStore.ts +++ b/apps/server/src/attachmentStore.ts @@ -137,3 +137,51 @@ export function createTextAttachmentPath(input: { safeName, ); } + +export function textAttachmentRelativePath(input: { + readonly attachmentsDir: string; + readonly path: string; +}): string | null { + const relativePath = NodePath.relative( + NodePath.resolve(input.attachmentsDir), + NodePath.resolve(input.path), + ).replaceAll("\\", "/"); + return /^text\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\/[^/]+$/i.test( + relativePath, + ) + ? relativePath + : null; +} + +export function textAttachmentDirectory(input: { + readonly attachmentsDir: string; + readonly path: string; +}): string | null { + const relativePath = textAttachmentRelativePath(input); + return relativePath ? NodePath.join(input.attachmentsDir, NodePath.dirname(relativePath)) : null; +} + +const MARKDOWN_LINK_DESTINATION_PATTERN = /\]\(([^)\s]+)\)/g; + +export function collectTextAttachmentRelativePaths(input: { + readonly attachmentsDir: string; + readonly text: string; +}): Set { + const paths = new Set(); + for (const match of input.text.matchAll(MARKDOWN_LINK_DESTINATION_PATTERN)) { + const encodedPath = match[1]; + if (!encodedPath) continue; + let path = encodedPath; + try { + path = decodeURIComponent(encodedPath); + } catch { + continue; + } + const relativePath = textAttachmentRelativePath({ + attachmentsDir: input.attachmentsDir, + path, + }); + if (relativePath) paths.add(relativePath); + } + return paths; +} diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 0999000ed4f..8341a676baa 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -943,6 +943,18 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta const attachmentId = "thread-delete-files-00000000-0000-4000-8000-000000000001"; const otherThreadAttachmentId = "thread-delete-files-extra-00000000-0000-4000-8000-000000000002"; + const textAttachmentPath = path.join( + attachmentsDir, + "text", + "00000000-0000-4000-8000-000000000003", + "notes.txt", + ); + const unrelatedTextAttachmentPath = path.join( + attachmentsDir, + "text", + "00000000-0000-4000-8000-000000000004", + "other.txt", + ); const appendAndProject = (event: Parameters[0]) => eventStore @@ -1010,7 +1022,7 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta threadId, messageId: MessageId.make("message-delete-files"), role: "user", - text: "Delete", + text: `Delete [notes.txt](${encodeURI(textAttachmentPath)})`, attachments: [ { type: "image", @@ -1035,8 +1047,16 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta yield* fileSystem.makeDirectory(attachmentsDir, { recursive: true }); yield* fileSystem.writeFileString(threadAttachmentPath, "delete"); yield* fileSystem.writeFileString(otherThreadAttachmentPath, "other-thread"); + yield* fileSystem.makeDirectory(path.dirname(textAttachmentPath), { recursive: true }); + yield* fileSystem.makeDirectory(path.dirname(unrelatedTextAttachmentPath), { + recursive: true, + }); + yield* fileSystem.writeFileString(textAttachmentPath, "delete text"); + yield* fileSystem.writeFileString(unrelatedTextAttachmentPath, "keep text"); assert.isTrue(yield* exists(threadAttachmentPath)); assert.isTrue(yield* exists(otherThreadAttachmentPath)); + assert.isTrue(yield* exists(textAttachmentPath)); + assert.isTrue(yield* exists(unrelatedTextAttachmentPath)); yield* appendAndProject({ type: "thread.deleted", @@ -1056,6 +1076,8 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta assert.isFalse(yield* exists(threadAttachmentPath)); assert.isTrue(yield* exists(otherThreadAttachmentPath)); + assert.isFalse(yield* exists(textAttachmentPath)); + assert.isTrue(yield* exists(unrelatedTextAttachmentPath)); }), ); }, diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index f12df850941..b3b011486b6 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -50,6 +50,7 @@ import { } from "../Services/ProjectionPipeline.ts"; import { attachmentRelativePath, + collectTextAttachmentRelativePaths, parseAttachmentIdFromRelativePath, parseThreadSegmentFromAttachmentId, toSafeThreadAttachmentSegment, @@ -104,6 +105,7 @@ interface ProjectorDefinition { interface AttachmentSideEffects { readonly deletedThreadIds: Set; readonly prunedThreadRelativePaths: Map>; + readonly textAttachmentRelativePathsToRemove: Set; } const materializeAttachmentsForProjection = Effect.fn("materializeAttachmentsForProjection")( @@ -351,6 +353,22 @@ function collectThreadAttachmentRelativePaths( return relativePaths; } +function collectThreadTextAttachmentRelativePaths( + attachmentsDir: string, + messages: ReadonlyArray, +): Set { + const relativePaths = new Set(); + for (const message of messages) { + for (const relativePath of collectTextAttachmentRelativePaths({ + attachmentsDir, + text: message.text, + })) { + relativePaths.add(relativePath); + } + } + return relativePaths; +} + const runAttachmentSideEffects = Effect.fn("runAttachmentSideEffects")(function* ( sideEffects: AttachmentSideEffects, ) { @@ -465,6 +483,16 @@ const runAttachmentSideEffects = Effect.fn("runAttachmentSideEffects")(function* pruneThreadAttachments(threadId, keptThreadRelativePaths), { concurrency: 1 }, ); + + yield* Effect.forEach( + sideEffects.textAttachmentRelativePathsToRemove, + (relativePath) => + fileSystem.remove(path.join(attachmentsRootDir, path.dirname(relativePath)), { + recursive: true, + force: true, + }), + { concurrency: 1 }, + ); }); const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjectionPipeline")( @@ -811,6 +839,19 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti "applyThreadMessagesProjection", )(function* (event, attachmentSideEffects) { switch (event.type) { + case "thread.deleted": { + const existingRows = yield* projectionThreadMessageRepository.listByThreadId({ + threadId: event.payload.threadId, + }); + for (const relativePath of collectThreadTextAttachmentRelativePaths( + serverConfig.attachmentsDir, + existingRows, + )) { + attachmentSideEffects.textAttachmentRelativePathsToRemove.add(relativePath); + } + return; + } + case "thread.message-sent": { const existingMessage = yield* projectionThreadMessageRepository.getByMessageId({ messageId: event.payload.messageId, @@ -864,6 +905,18 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti existingTurns, event.payload.turnCount, ); + const keptTextAttachmentPaths = collectThreadTextAttachmentRelativePaths( + serverConfig.attachmentsDir, + keptRows, + ); + for (const relativePath of collectThreadTextAttachmentRelativePaths( + serverConfig.attachmentsDir, + existingRows, + )) { + if (!keptTextAttachmentPaths.has(relativePath)) { + attachmentSideEffects.textAttachmentRelativePathsToRemove.add(relativePath); + } + } if (keptRows.length === existingRows.length) { return; } @@ -1505,6 +1558,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti const attachmentSideEffects: AttachmentSideEffects = { deletedThreadIds: new Set(), prunedThreadRelativePaths: new Map>(), + textAttachmentRelativePathsToRemove: new Set(), }; yield* sql.withTransaction( diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index f56449c1cea..089ba4d9992 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -53,6 +53,7 @@ import { AssetWorkspaceContextNotFoundError, AssetWorkspaceContextResolutionError, AssetTextAttachmentWriteError, + AssetTextAttachmentDeleteError, EnvironmentAuthorizationError, ThreadId, type TerminalAttachStreamEvent, @@ -87,7 +88,7 @@ import * as TerminalManager from "./terminal/Manager.ts"; import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; import * as PreviewManager from "./preview/Manager.ts"; import { issueAssetUrl } from "./assets/AssetAccess.ts"; -import { createTextAttachmentPath } from "./attachmentStore.ts"; +import { createTextAttachmentPath, textAttachmentDirectory } from "./attachmentStore.ts"; import * as PortScanner from "./preview/PortScanner.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; @@ -313,6 +314,7 @@ const RPC_REQUIRED_SCOPE = new Map([ [WS_METHODS.filesystemBrowse, AuthOrchestrationReadScope], [WS_METHODS.assetsCreateUrl, AuthOrchestrationReadScope], [WS_METHODS.assetsWriteTextAttachment, AuthOrchestrationOperateScope], + [WS_METHODS.assetsDeleteTextAttachment, AuthOrchestrationOperateScope], [WS_METHODS.subscribeVcsStatus, AuthOrchestrationReadScope], [WS_METHODS.vcsRefreshStatus, AuthOrchestrationReadScope], [WS_METHODS.vcsPull, AuthOrchestrationOperateScope], @@ -1550,6 +1552,26 @@ const makeWsRpcLayer = ( }), { "rpc.aggregate": "workspace" }, ), + [WS_METHODS.assetsDeleteTextAttachment]: (input) => + observeRpcEffect( + WS_METHODS.assetsDeleteTextAttachment, + Effect.gen(function* () { + const directory = textAttachmentDirectory({ + attachmentsDir: config.attachmentsDir, + path: input.path, + }); + if (!directory) return { removed: false }; + yield* fileSystem + .remove(directory, { recursive: true, force: true }) + .pipe( + Effect.mapError( + (cause) => new AssetTextAttachmentDeleteError({ path: input.path, cause }), + ), + ); + return { removed: true }; + }), + { "rpc.aggregate": "workspace" }, + ), [WS_METHODS.subscribeVcsStatus]: (input) => observeRpcStream( WS_METHODS.subscribeVcsStatus, diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index edf99aa70b0..d02e80991ea 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -127,6 +127,7 @@ import { searchProviderSkills } from "../../providerSkillSearch"; import { assetEnvironment } from "~/state/assets"; import { useAtomCommand } from "~/state/use-atom-command"; import { resolvePathLinkTarget } from "~/terminal-links"; +import { removedTextAttachmentPaths } from "~/textAttachmentPaths"; const TEXT_ATTACHMENT_MAX_BYTES = 1024 * 1024; @@ -629,6 +630,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const writeTextAttachment = useAtomCommand(assetEnvironment.writeTextAttachment, { reportFailure: false, }); + const deleteTextAttachment = useAtomCommand(assetEnvironment.deleteTextAttachment, { + reportFailure: false, + }); const prompt = composerDraft.prompt; const composerImages = composerDraft.images; const composerTerminalContexts = composerDraft.terminalContexts; @@ -1113,6 +1117,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) scheduleComposerFocus(); return; } + for (const path of removedTextAttachmentPaths(promptRef.current, nextPrompt)) { + void deleteTextAttachment({ environmentId, input: { path } }); + } promptRef.current = nextPrompt; setComposerDraftPrompt(composerDraftTarget, nextPrompt); const nextCursor = collapseExpandedComposerCursor(nextPrompt, nextPrompt.length); @@ -1120,7 +1127,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setComposerTrigger(detectComposerTrigger(nextPrompt, nextPrompt.length)); scheduleComposerFocus(); }, - [composerDraftTarget, promptRef, scheduleComposerFocus, setComposerDraftPrompt], + [ + composerDraftTarget, + deleteTextAttachment, + environmentId, + promptRef, + scheduleComposerFocus, + setComposerDraftPrompt, + ], ); const providerTraitsMenuContent = renderProviderTraitsMenuContent({ @@ -1466,6 +1480,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); return; } + for (const path of removedTextAttachmentPaths(promptRef.current, nextPrompt)) { + void deleteTextAttachment({ environmentId, input: { path } }); + } promptRef.current = nextPrompt; setPrompt(nextPrompt); if (!terminalContextIdListsEqual(composerTerminalContexts, terminalContextIds)) { @@ -1481,6 +1498,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }, [ activePendingProgress?.activeQuestion, + deleteTextAttachment, + environmentId, pendingUserInputs.length, onChangeActivePendingUserInputCustomAnswer, promptRef, diff --git a/apps/web/src/textAttachmentPaths.test.ts b/apps/web/src/textAttachmentPaths.test.ts new file mode 100644 index 00000000000..0ea771364d7 --- /dev/null +++ b/apps/web/src/textAttachmentPaths.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { removedTextAttachmentPaths } from "./textAttachmentPaths"; + +const attachmentPath = + "/var/t3-data/attachments/text/12345678-1234-1234-1234-123456789abc/notes.txt"; +const attachmentLink = `[notes.txt](${attachmentPath})`; + +describe("removedTextAttachmentPaths", () => { + it("returns generated attachments removed from the prompt", () => { + expect(removedTextAttachmentPaths(`${attachmentLink} keep `, "keep ")).toEqual([ + attachmentPath, + ]); + }); + + it("retains attachments that remain in the prompt", () => { + expect( + removedTextAttachmentPaths(`${attachmentLink} keep `, `${attachmentLink} next `), + ).toEqual([]); + }); +}); diff --git a/apps/web/src/textAttachmentPaths.ts b/apps/web/src/textAttachmentPaths.ts index 5f0c744dc17..42b079f28d9 100644 --- a/apps/web/src/textAttachmentPaths.ts +++ b/apps/web/src/textAttachmentPaths.ts @@ -13,3 +13,16 @@ export function isTextAttachmentPath(path: string): boolean { TEXT_ATTACHMENT_PATH_PATTERN.test(path) || LEGACY_FLAT_TEXT_ATTACHMENT_PATH_PATTERN.test(path) ); } + +export function removedTextAttachmentPaths(previousPrompt: string, nextPrompt: string): string[] { + const collect = (prompt: string) => + new Set( + collectComposerInlineTokens(prompt).flatMap((token) => + token.type === "mention" && isTextAttachmentPath(token.value) ? [token.value] : [], + ), + ); + const previousPaths = collect(previousPrompt); + const nextPaths = collect(nextPrompt); + return [...previousPaths].filter((path) => !nextPaths.has(path)); +} +import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; diff --git a/packages/client-runtime/src/state/assets.ts b/packages/client-runtime/src/state/assets.ts index a219f3e21c5..bfb8f912c22 100644 --- a/packages/client-runtime/src/state/assets.ts +++ b/packages/client-runtime/src/state/assets.ts @@ -76,6 +76,10 @@ export function createAssetEnvironmentAtoms( label: "environment-data:assets:write-text-attachment", tag: WS_METHODS.assetsWriteTextAttachment, }), + deleteTextAttachment: createEnvironmentRpcCommand(runtime, { + label: "environment-data:assets:delete-text-attachment", + tag: WS_METHODS.assetsDeleteTextAttachment, + }), createUrls: (target: { readonly environmentId: EnvironmentId; readonly resources: ReadonlyArray; diff --git a/packages/contracts/src/assets.ts b/packages/contracts/src/assets.ts index 3066e1106a2..063742836ce 100644 --- a/packages/contracts/src/assets.ts +++ b/packages/contracts/src/assets.ts @@ -41,6 +41,16 @@ export const AssetWriteTextAttachmentResult = Schema.Struct({ }); export type AssetWriteTextAttachmentResult = typeof AssetWriteTextAttachmentResult.Type; +export const AssetDeleteTextAttachmentInput = Schema.Struct({ + path: TrimmedNonEmptyString.check(Schema.isMaxLength(4096)), +}); +export type AssetDeleteTextAttachmentInput = typeof AssetDeleteTextAttachmentInput.Type; + +export const AssetDeleteTextAttachmentResult = Schema.Struct({ + removed: Schema.Boolean, +}); +export type AssetDeleteTextAttachmentResult = typeof AssetDeleteTextAttachmentResult.Type; + export class AssetTextAttachmentWriteError extends Schema.TaggedErrorClass()( "AssetTextAttachmentWriteError", { @@ -53,6 +63,18 @@ export class AssetTextAttachmentWriteError extends Schema.TaggedErrorClass()( + "AssetTextAttachmentDeleteError", + { + path: TrimmedNonEmptyString, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to delete text attachment."; + } +} + export class AssetWorkspaceContextNotFoundError extends Schema.TaggedErrorClass()( "AssetWorkspaceContextNotFoundError", { diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index dcfdf2a7f0e..86da628dff6 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -23,6 +23,8 @@ import type { FilesystemBrowseInput, FilesystemBrowseResult } from "./filesystem import type { AssetCreateUrlInput, AssetCreateUrlResult, + AssetDeleteTextAttachmentInput, + AssetDeleteTextAttachmentResult, AssetWriteTextAttachmentInput, AssetWriteTextAttachmentResult, } from "./assets.ts"; @@ -1173,6 +1175,9 @@ export interface EnvironmentApi { writeTextAttachment: ( input: AssetWriteTextAttachmentInput, ) => Promise; + deleteTextAttachment: ( + input: AssetDeleteTextAttachmentInput, + ) => Promise; }; sourceControl: { lookupRepository: ( diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index dfa0ac938fe..8190fdfb6ac 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -17,6 +17,9 @@ import { AssetAccessError, AssetCreateUrlInput, AssetCreateUrlResult, + AssetDeleteTextAttachmentInput, + AssetDeleteTextAttachmentResult, + AssetTextAttachmentDeleteError, AssetTextAttachmentWriteError, AssetWriteTextAttachmentInput, AssetWriteTextAttachmentResult, @@ -168,6 +171,7 @@ export const WS_METHODS = { filesystemBrowse: "filesystem.browse", assetsCreateUrl: "assets.createUrl", assetsWriteTextAttachment: "assets.writeTextAttachment", + assetsDeleteTextAttachment: "assets.deleteTextAttachment", // VCS methods vcsPull: "vcs.pull", @@ -409,6 +413,12 @@ export const WsAssetsWriteTextAttachmentRpc = Rpc.make(WS_METHODS.assetsWriteTex error: Schema.Union([AssetTextAttachmentWriteError, EnvironmentAuthorizationError]), }); +export const WsAssetsDeleteTextAttachmentRpc = Rpc.make(WS_METHODS.assetsDeleteTextAttachment, { + payload: AssetDeleteTextAttachmentInput, + success: AssetDeleteTextAttachmentResult, + error: Schema.Union([AssetTextAttachmentDeleteError, EnvironmentAuthorizationError]), +}); + export const WsSubscribeVcsStatusRpc = Rpc.make(WS_METHODS.subscribeVcsStatus, { payload: VcsStatusInput, success: VcsStatusStreamEvent, @@ -721,6 +731,7 @@ export const WsRpcGroup = RpcGroup.make( WsFilesystemBrowseRpc, WsAssetsCreateUrlRpc, WsAssetsWriteTextAttachmentRpc, + WsAssetsDeleteTextAttachmentRpc, WsSubscribeVcsStatusRpc, WsVcsPullRpc, WsVcsRefreshStatusRpc, From 6ea5b18224dcfbf11bedeb480a5fdab2bd8dada7 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 16:42:43 -0400 Subject: [PATCH 20/79] Protect retained text attachments from deletion --- .../Layers/ProjectionPipeline.ts | 19 ++++++++++-- .../Layers/ProjectionThreadMessages.ts | 30 +++++++++++++++++++ .../Services/ProjectionThreadMessages.ts | 6 ++++ apps/server/src/ws.ts | 28 ++++++++++++++++- apps/web/src/components/chat/ChatComposer.tsx | 21 +------------ apps/web/src/textAttachmentPaths.test.ts | 21 ------------- apps/web/src/textAttachmentPaths.ts | 13 -------- 7 files changed, 81 insertions(+), 57 deletions(-) delete mode 100644 apps/web/src/textAttachmentPaths.test.ts diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index b3b011486b6..9fbfb545fe2 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -371,6 +371,7 @@ function collectThreadTextAttachmentRelativePaths( const runAttachmentSideEffects = Effect.fn("runAttachmentSideEffects")(function* ( sideEffects: AttachmentSideEffects, + projectionThreadMessageRepository: ProjectionThreadMessageRepository["Service"], ) { const serverConfig = yield* Effect.service(ServerConfig); const fileSystem = yield* Effect.service(FileSystem.FileSystem); @@ -484,8 +485,16 @@ const runAttachmentSideEffects = Effect.fn("runAttachmentSideEffects")(function* { concurrency: 1 }, ); + const retainedTextAttachmentPaths = collectThreadTextAttachmentRelativePaths( + attachmentsRootDir, + (yield* projectionThreadMessageRepository.listRetained()).filter( + (message) => !sideEffects.deletedThreadIds.has(message.threadId), + ), + ); yield* Effect.forEach( - sideEffects.textAttachmentRelativePathsToRemove, + [...sideEffects.textAttachmentRelativePathsToRemove].filter( + (relativePath) => !retainedTextAttachmentPaths.has(relativePath), + ), (relativePath) => fileSystem.remove(path.join(attachmentsRootDir, path.dirname(relativePath)), { recursive: true, @@ -840,6 +849,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti )(function* (event, attachmentSideEffects) { switch (event.type) { case "thread.deleted": { + attachmentSideEffects.deletedThreadIds.add(event.payload.threadId); const existingRows = yield* projectionThreadMessageRepository.listByThreadId({ threadId: event.payload.threadId, }); @@ -1573,7 +1583,10 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ), ); - yield* runAttachmentSideEffects(attachmentSideEffects).pipe( + yield* runAttachmentSideEffects( + attachmentSideEffects, + projectionThreadMessageRepository, + ).pipe( Effect.catch((cause) => Effect.logWarning("failed to apply projected attachment side-effects", { projector: projector.name, @@ -1608,6 +1621,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), Effect.provideService(ServerConfig, serverConfig), + Effect.provideService(ProjectionThreadMessageRepository, projectionThreadMessageRepository), Effect.asVoid, Effect.catchTag("SqlError", (sqlError) => Effect.fail(toPersistenceSqlError("ProjectionPipeline.projectEvent:query")(sqlError)), @@ -1622,6 +1636,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), Effect.provideService(ServerConfig, serverConfig), + Effect.provideService(ProjectionThreadMessageRepository, projectionThreadMessageRepository), Effect.asVoid, Effect.tap(() => Effect.logDebug("orchestration projection pipeline bootstrapped").pipe( diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts index 71919166886..0c1f988c3c7 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts @@ -137,6 +137,27 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { `, }); + const listRetainedProjectionThreadMessageRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionThreadMessageDbRowSchema, + execute: () => sql` + SELECT + messages.message_id AS "messageId", + messages.thread_id AS "threadId", + messages.turn_id AS "turnId", + messages.role, + messages.text, + messages.attachments_json AS "attachments", + messages.is_streaming AS "isStreaming", + messages.created_at AS "createdAt", + messages.updated_at AS "updatedAt" + FROM projection_thread_messages AS messages + INNER JOIN projection_threads AS threads ON threads.thread_id = messages.thread_id + WHERE threads.deleted_at IS NULL + ORDER BY messages.created_at ASC, messages.message_id ASC + `, + }); + const deleteProjectionThreadMessageRows = SqlSchema.void({ Request: DeleteProjectionThreadMessagesInput, execute: ({ threadId }) => @@ -167,6 +188,14 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { Effect.map((rows) => rows.map(toProjectionThreadMessage)), ); + const listRetained: ProjectionThreadMessageRepositoryShape["listRetained"] = () => + listRetainedProjectionThreadMessageRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadMessageRepository.listRetained:query"), + ), + Effect.map((rows) => rows.map(toProjectionThreadMessage)), + ); + const deleteByThreadId: ProjectionThreadMessageRepositoryShape["deleteByThreadId"] = (input) => deleteProjectionThreadMessageRows(input).pipe( Effect.mapError( @@ -178,6 +207,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { upsert, getByMessageId, listByThreadId, + listRetained, deleteByThreadId, } satisfies ProjectionThreadMessageRepositoryShape; }); diff --git a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts index d50ff320256..f8a30146e3f 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts @@ -78,6 +78,12 @@ export interface ProjectionThreadMessageRepositoryShape { input: ListProjectionThreadMessagesInput, ) => Effect.Effect, ProjectionRepositoryError>; + /** List messages belonging to threads that have not been deleted. */ + readonly listRetained: () => Effect.Effect< + ReadonlyArray, + ProjectionRepositoryError + >; + /** * Delete projected thread messages by thread. */ diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 089ba4d9992..4b6ff550b13 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -88,7 +88,12 @@ import * as TerminalManager from "./terminal/Manager.ts"; import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; import * as PreviewManager from "./preview/Manager.ts"; import { issueAssetUrl } from "./assets/AssetAccess.ts"; -import { createTextAttachmentPath, textAttachmentDirectory } from "./attachmentStore.ts"; +import { + collectTextAttachmentRelativePaths, + createTextAttachmentPath, + textAttachmentDirectory, + textAttachmentRelativePath, +} from "./attachmentStore.ts"; import * as PortScanner from "./preview/PortScanner.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; @@ -1561,6 +1566,27 @@ const makeWsRpcLayer = ( path: input.path, }); if (!directory) return { removed: false }; + const relativePath = textAttachmentRelativePath({ + attachmentsDir: config.attachmentsDir, + path: input.path, + }); + if (!relativePath) return { removed: false }; + const retainedSnapshot = yield* projectionSnapshotQuery + .getSnapshot() + .pipe( + Effect.mapError( + (cause) => new AssetTextAttachmentDeleteError({ path: input.path, cause }), + ), + ); + const isDurable = retainedSnapshot.threads.some((thread) => + thread.messages.some((message) => + collectTextAttachmentRelativePaths({ + attachmentsDir: config.attachmentsDir, + text: message.text, + }).has(relativePath), + ), + ); + if (isDurable) return { removed: false }; yield* fileSystem .remove(directory, { recursive: true, force: true }) .pipe( diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index d02e80991ea..edf99aa70b0 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -127,7 +127,6 @@ import { searchProviderSkills } from "../../providerSkillSearch"; import { assetEnvironment } from "~/state/assets"; import { useAtomCommand } from "~/state/use-atom-command"; import { resolvePathLinkTarget } from "~/terminal-links"; -import { removedTextAttachmentPaths } from "~/textAttachmentPaths"; const TEXT_ATTACHMENT_MAX_BYTES = 1024 * 1024; @@ -630,9 +629,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const writeTextAttachment = useAtomCommand(assetEnvironment.writeTextAttachment, { reportFailure: false, }); - const deleteTextAttachment = useAtomCommand(assetEnvironment.deleteTextAttachment, { - reportFailure: false, - }); const prompt = composerDraft.prompt; const composerImages = composerDraft.images; const composerTerminalContexts = composerDraft.terminalContexts; @@ -1117,9 +1113,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) scheduleComposerFocus(); return; } - for (const path of removedTextAttachmentPaths(promptRef.current, nextPrompt)) { - void deleteTextAttachment({ environmentId, input: { path } }); - } promptRef.current = nextPrompt; setComposerDraftPrompt(composerDraftTarget, nextPrompt); const nextCursor = collapseExpandedComposerCursor(nextPrompt, nextPrompt.length); @@ -1127,14 +1120,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setComposerTrigger(detectComposerTrigger(nextPrompt, nextPrompt.length)); scheduleComposerFocus(); }, - [ - composerDraftTarget, - deleteTextAttachment, - environmentId, - promptRef, - scheduleComposerFocus, - setComposerDraftPrompt, - ], + [composerDraftTarget, promptRef, scheduleComposerFocus, setComposerDraftPrompt], ); const providerTraitsMenuContent = renderProviderTraitsMenuContent({ @@ -1480,9 +1466,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); return; } - for (const path of removedTextAttachmentPaths(promptRef.current, nextPrompt)) { - void deleteTextAttachment({ environmentId, input: { path } }); - } promptRef.current = nextPrompt; setPrompt(nextPrompt); if (!terminalContextIdListsEqual(composerTerminalContexts, terminalContextIds)) { @@ -1498,8 +1481,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }, [ activePendingProgress?.activeQuestion, - deleteTextAttachment, - environmentId, pendingUserInputs.length, onChangeActivePendingUserInputCustomAnswer, promptRef, diff --git a/apps/web/src/textAttachmentPaths.test.ts b/apps/web/src/textAttachmentPaths.test.ts deleted file mode 100644 index 0ea771364d7..00000000000 --- a/apps/web/src/textAttachmentPaths.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { removedTextAttachmentPaths } from "./textAttachmentPaths"; - -const attachmentPath = - "/var/t3-data/attachments/text/12345678-1234-1234-1234-123456789abc/notes.txt"; -const attachmentLink = `[notes.txt](${attachmentPath})`; - -describe("removedTextAttachmentPaths", () => { - it("returns generated attachments removed from the prompt", () => { - expect(removedTextAttachmentPaths(`${attachmentLink} keep `, "keep ")).toEqual([ - attachmentPath, - ]); - }); - - it("retains attachments that remain in the prompt", () => { - expect( - removedTextAttachmentPaths(`${attachmentLink} keep `, `${attachmentLink} next `), - ).toEqual([]); - }); -}); diff --git a/apps/web/src/textAttachmentPaths.ts b/apps/web/src/textAttachmentPaths.ts index 42b079f28d9..5f0c744dc17 100644 --- a/apps/web/src/textAttachmentPaths.ts +++ b/apps/web/src/textAttachmentPaths.ts @@ -13,16 +13,3 @@ export function isTextAttachmentPath(path: string): boolean { TEXT_ATTACHMENT_PATH_PATTERN.test(path) || LEGACY_FLAT_TEXT_ATTACHMENT_PATH_PATTERN.test(path) ); } - -export function removedTextAttachmentPaths(previousPrompt: string, nextPrompt: string): string[] { - const collect = (prompt: string) => - new Set( - collectComposerInlineTokens(prompt).flatMap((token) => - token.type === "mention" && isTextAttachmentPath(token.value) ? [token.value] : [], - ), - ); - const previousPaths = collect(previousPrompt); - const nextPaths = collect(nextPrompt); - return [...previousPaths].filter((path) => !nextPaths.has(path)); -} -import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; From 703f4083294efe634528ba380cfff251a707eab8 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 16:49:25 -0400 Subject: [PATCH 21/79] Discard unsent text attachments with drafts --- apps/web/src/components/Sidebar.tsx | 22 +++++++++++++++++++--- apps/web/src/hooks/useThreadActions.ts | 16 ++++++++++++++++ apps/web/src/textAttachmentPaths.test.ts | 12 ++++++++++++ apps/web/src/textAttachmentPaths.ts | 12 ++++++++++++ 4 files changed, 59 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/textAttachmentPaths.test.ts diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 21525b56b77..56a8b24add1 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -114,10 +114,12 @@ import { useDesktopUpdateState } from "../state/desktopUpdate"; import { useThreadActions } from "../hooks/useThreadActions"; import { projectEnvironment } from "../state/projects"; +import { assetEnvironment } from "../state/assets"; import { useEnvironmentQuery } from "../state/query"; import { threadEnvironment, useEnvironmentThread } from "../state/threads"; import { vcsEnvironment } from "../state/vcs"; import { useEnvironment, useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; +import { textAttachmentPaths } from "../textAttachmentPaths"; import { buildThreadRouteParams, resolveThreadRouteRef, @@ -1113,6 +1115,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false, }); + const deleteTextAttachment = useAtomCommand(assetEnvironment.deleteTextAttachment, { + reportFailure: false, + }); const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false, }); @@ -1437,6 +1442,11 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const removeProject = useCallback( async (member: SidebarProjectGroupMember, options: { force?: boolean } = {}) => { const memberProjectRef = scopeProjectRef(member.environmentId, member.id); + const draftStore = useComposerDraftStore.getState(); + const projectDraftThread = draftStore.getDraftThreadByProjectRef(memberProjectRef); + const discardedPrompt = projectDraftThread + ? (draftStore.getComposerDraft(projectDraftThread.draftId)?.prompt ?? "") + : ""; const result = await deleteProject({ environmentId: member.environmentId, input: { @@ -1447,15 +1457,21 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec if (result._tag === "Failure") { return result; } - const draftStore = useComposerDraftStore.getState(); - const projectDraftThread = draftStore.getDraftThreadByProjectRef(memberProjectRef); if (projectDraftThread) { + await Promise.all( + textAttachmentPaths(discardedPrompt).map((path) => + deleteTextAttachment({ + environmentId: member.environmentId, + input: { path }, + }), + ), + ); draftStore.clearDraftThread(projectDraftThread.draftId); } draftStore.clearProjectDraftThreadId(memberProjectRef); return result; }, - [deleteProject], + [deleteProject, deleteTextAttachment], ); const handleRemoveProject = useCallback( diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 07655ad30d7..5cf7e828642 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -15,6 +15,7 @@ import { getFallbackThreadIdAfterDelete } from "../components/Sidebar.logic"; import { useComposerDraftStore } from "../composerDraftStore"; import { terminalEnvironment } from "../state/terminal"; import { threadEnvironment } from "../state/threads"; +import { assetEnvironment } from "../state/assets"; import { vcsEnvironment } from "../state/vcs"; import { useNewThreadHandler } from "./useHandleNewThread"; import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsState"; @@ -26,6 +27,7 @@ import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from " import { stackedThreadToast, toastManager } from "../components/ui/toast"; import { useClientSettings } from "./useSettings"; import { useAtomCommand } from "../state/use-atom-command"; +import { textAttachmentPaths } from "../textAttachmentPaths"; export class ThreadArchiveBlockedError extends Schema.TaggedErrorClass()( "ThreadArchiveBlockedError", @@ -50,6 +52,9 @@ export function useThreadActions() { const deleteThreadMutation = useAtomCommand(threadEnvironment.delete, { reportFailure: false, }); + const deleteTextAttachment = useAtomCommand(assetEnvironment.deleteTextAttachment, { + reportFailure: false, + }); const stopThreadSession = useAtomCommand(threadEnvironment.stopSession); const removeWorktree = useAtomCommand(vcsEnvironment.removeWorktree, { reportFailure: false, @@ -233,6 +238,8 @@ export function useThreadActions() { deletedThreadIds, sortOrder: sidebarThreadSortOrder, }); + const discardedPrompt = + useComposerDraftStore.getState().getComposerDraft(threadRef)?.prompt ?? ""; const deleteResult = await deleteThreadMutation({ environmentId: threadRef.environmentId, input: { threadId: threadRef.threadId }, @@ -240,6 +247,14 @@ export function useThreadActions() { if (deleteResult._tag === "Failure") { return deleteResult; } + await Promise.all( + textAttachmentPaths(discardedPrompt).map((path) => + deleteTextAttachment({ + environmentId: threadRef.environmentId, + input: { path }, + }), + ), + ); refreshArchivedThreadsForEnvironment(threadRef.environmentId); clearComposerDraftForThread(threadRef); clearProjectDraftThreadById( @@ -334,6 +349,7 @@ export function useThreadActions() { clearProjectDraftThreadById, clearTerminalUiState, closeTerminal, + deleteTextAttachment, deleteThreadMutation, getCurrentRouteThreadRef, refreshVcsStatus, diff --git a/apps/web/src/textAttachmentPaths.test.ts b/apps/web/src/textAttachmentPaths.test.ts new file mode 100644 index 00000000000..9493252e0ee --- /dev/null +++ b/apps/web/src/textAttachmentPaths.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { textAttachmentPaths } from "./textAttachmentPaths"; + +describe("textAttachmentPaths", () => { + it("collects unique generated attachment links from a discarded draft", () => { + const path = "/var/t3-data/attachments/text/12345678-1234-1234-1234-123456789abc/notes.txt"; + + expect(textAttachmentPaths(`[notes.txt](${path}) keep [notes.txt](${path})`)).toEqual([path]); + expect(textAttachmentPaths("ordinary prompt")).toEqual([]); + }); +}); diff --git a/apps/web/src/textAttachmentPaths.ts b/apps/web/src/textAttachmentPaths.ts index 5f0c744dc17..3e8efa4f7d2 100644 --- a/apps/web/src/textAttachmentPaths.ts +++ b/apps/web/src/textAttachmentPaths.ts @@ -13,3 +13,15 @@ export function isTextAttachmentPath(path: string): boolean { TEXT_ATTACHMENT_PATH_PATTERN.test(path) || LEGACY_FLAT_TEXT_ATTACHMENT_PATH_PATTERN.test(path) ); } + +export function textAttachmentPaths(prompt: string): string[] { + return [ + ...new Set( + collectComposerInlineTokens(prompt).flatMap((token) => + token.type === "mention" && isTextAttachmentPath(token.value) ? [token.value] : [], + ), + ), + ]; +} + +import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; From 0269668e98ad443ac86b8741fcd3194592b25d90 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 16:54:48 -0400 Subject: [PATCH 22/79] Discard attachments before removing environments --- apps/web/src/composerDraftStore.test.ts | 3 ++ apps/web/src/composerDraftStore.ts | 50 ++++++++++++------- apps/web/src/connection/platform.ts | 28 +++++++++-- .../client-runtime/src/connection/registry.ts | 6 ++- .../src/platform/persistence.ts | 6 ++- 5 files changed, 67 insertions(+), 26 deletions(-) diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index bc1b7107306..c5a31b54ec0 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -60,6 +60,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" import { COMPOSER_DRAFT_STORAGE_KEY, clearComposerDraftsEnvironment, + composerDraftPromptsEnvironment, finalizePromotedDraftThreadByRef, markPromotedDraftThread, markPromotedDraftThreadByRef, @@ -716,6 +717,8 @@ describe("composerDraftStore project draft thread mapping", () => { store.setPrompt(localThreadRef, "local thread draft"); store.setPrompt(remoteThreadRef, "remote thread draft"); + expect(composerDraftPromptsEnvironment(TEST_ENVIRONMENT_ID)).toEqual(["local thread draft"]); + clearComposerDraftsEnvironment(TEST_ENVIRONMENT_ID); const next = useComposerDraftStore.getState(); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index fdb8bfe7b18..dfe15e324fe 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -3374,27 +3374,39 @@ const composerDraftStore = create()( export const useComposerDraftStore = composerDraftStore; +function composerDraftKeysEnvironment( + state: ComposerDraftStoreState, + environmentId: EnvironmentId, +): Set { + const keys = new Set(); + for (const [threadKey, draftThread] of Object.entries(state.draftThreadsByThreadKey)) { + if (draftThread.environmentId === environmentId) keys.add(threadKey); + } + for (const threadKey of Object.keys(state.draftsByThreadKey)) { + if (parseScopedThreadKey(threadKey)?.environmentId === environmentId) keys.add(threadKey); + } + for (const [logicalProjectKey, threadKey] of Object.entries( + state.logicalProjectDraftThreadKeyByLogicalProjectKey, + )) { + if (parseScopedProjectKey(logicalProjectKey)?.environmentId === environmentId) { + keys.add(threadKey); + } + } + return keys; +} + +export function composerDraftPromptsEnvironment(environmentId: EnvironmentId): string[] { + const state = useComposerDraftStore.getState(); + const keys = composerDraftKeysEnvironment(state, environmentId); + return [...keys].flatMap((key) => { + const draft = state.draftsByThreadKey[key]; + return draft ? [draft.prompt] : []; + }); +} + export function clearComposerDraftsEnvironment(environmentId: EnvironmentId): void { useComposerDraftStore.setState((state) => { - const removedThreadKeys = new Set(); - - for (const [threadKey, draftThread] of Object.entries(state.draftThreadsByThreadKey)) { - if (draftThread.environmentId === environmentId) { - removedThreadKeys.add(threadKey); - } - } - for (const threadKey of Object.keys(state.draftsByThreadKey)) { - if (parseScopedThreadKey(threadKey)?.environmentId === environmentId) { - removedThreadKeys.add(threadKey); - } - } - for (const [logicalProjectKey, threadKey] of Object.entries( - state.logicalProjectDraftThreadKeyByLogicalProjectKey, - )) { - if (parseScopedProjectKey(logicalProjectKey)?.environmentId === environmentId) { - removedThreadKeys.add(threadKey); - } - } + const removedThreadKeys = composerDraftKeysEnvironment(state, environmentId); const nextLogicalMappings = Object.fromEntries( Object.entries(state.logicalProjectDraftThreadKeyByLogicalProjectKey).filter( diff --git a/apps/web/src/connection/platform.ts b/apps/web/src/connection/platform.ts index 56d25fa142e..6fbeda34496 100644 --- a/apps/web/src/connection/platform.ts +++ b/apps/web/src/connection/platform.ts @@ -20,17 +20,19 @@ import { PrimaryConnectionRegistration, PrimaryConnectionTarget, Wakeups, + EnvironmentSupervisor, } from "@t3tools/client-runtime/connection"; import { bootstrapRemoteBearerSession } from "@t3tools/client-runtime/authorization"; import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment"; import { managedRelayAccountChanges, managedRelaySessionAtom } from "@t3tools/client-runtime/relay"; -import { EnvironmentRpcRequestObserver } from "@t3tools/client-runtime/rpc"; +import { EnvironmentRpcRequestObserver, request } from "@t3tools/client-runtime/rpc"; import { AuthStandardClientScopes, type DesktopBridge, type DesktopEnvironmentBootstrap, type DesktopSshEnvironmentTarget, PRIMARY_LOCAL_ENVIRONMENT_ID, + WS_METHODS, } from "@t3tools/contracts"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; @@ -48,7 +50,11 @@ import { readPrimaryEnvironmentTarget, type PrimaryEnvironmentTarget, } from "../environments/primary/target"; -import { clearComposerDraftsEnvironment } from "../composerDraftStore"; +import { + clearComposerDraftsEnvironment, + composerDraftPromptsEnvironment, +} from "../composerDraftStore"; +import { textAttachmentPaths } from "../textAttachmentPaths"; import { isHostedStaticApp } from "../hostedPairing"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { acknowledgeRpcRequest, trackRpcRequestSent } from "../rpc/requestLatencyState"; @@ -576,8 +582,22 @@ const platformConnectionSourceLayer = Layer.effect( const environmentOwnedDataCleanupLayer = Layer.succeed( EnvironmentOwnedDataCleanup, EnvironmentOwnedDataCleanup.of({ - clear: (environmentId) => - Effect.sync(() => { + clear: (environmentId, supervisor) => + Effect.gen(function* () { + if (supervisor) { + const paths = [ + ...new Set(composerDraftPromptsEnvironment(environmentId).flatMap(textAttachmentPaths)), + ]; + yield* Effect.forEach( + paths, + (path) => + request(WS_METHODS.assetsDeleteTextAttachment, { path }).pipe( + Effect.provideService(EnvironmentSupervisor, supervisor), + Effect.ignoreCause({ log: true }), + ), + { concurrency: 1, discard: true }, + ); + } clearComposerDraftsEnvironment(environmentId); }), }), diff --git a/packages/client-runtime/src/connection/registry.ts b/packages/client-runtime/src/connection/registry.ts index a3a36272f32..960c5afb9f3 100644 --- a/packages/client-runtime/src/connection/registry.ts +++ b/packages/client-runtime/src/connection/registry.ts @@ -481,6 +481,8 @@ export const make = Effect.gen(function* () { next.delete(environmentId); return next; }); + const serviceScope = (yield* SubscriptionRef.get(serviceScopes)).get(environmentId); + yield* ownedDataCleanup.clear(environmentId, serviceScope?.supervisor); yield* closeServiceScope(environmentId); yield* SubscriptionRef.update(entries, (current) => { const next = new Map(current); @@ -507,7 +509,6 @@ export const make = Effect.gen(function* () { }), ), ), - ownedDataCleanup.clear(environmentId), ], { concurrency: "unbounded", discard: true }, ); @@ -562,6 +563,8 @@ export const make = Effect.gen(function* () { next.delete(environmentId); return next; }); + const serviceScope = (yield* SubscriptionRef.get(serviceScopes)).get(environmentId); + yield* ownedDataCleanup.clear(environmentId, serviceScope?.supervisor); yield* closeServiceScope(environmentId); yield* SubscriptionRef.update(entries, (current) => { const next = new Map(current); @@ -578,7 +581,6 @@ export const make = Effect.gen(function* () { }), ), ), - ownedDataCleanup.clear(environmentId), ], { concurrency: "unbounded", discard: true }, ); diff --git a/packages/client-runtime/src/platform/persistence.ts b/packages/client-runtime/src/platform/persistence.ts index 8723ac06939..8486855f218 100644 --- a/packages/client-runtime/src/platform/persistence.ts +++ b/packages/client-runtime/src/platform/persistence.ts @@ -13,6 +13,7 @@ import * as Schema from "effect/Schema"; import type { ConnectionRegistration } from "../connection/catalog.ts"; import type { ConnectionTarget } from "../connection/model.ts"; +import type { EnvironmentSupervisor } from "../connection/supervisor.ts"; export class ConnectionPersistenceError extends Schema.TaggedErrorClass()( "ConnectionPersistenceError", @@ -109,7 +110,10 @@ export class EnvironmentCacheStore extends Context.Service< >()("@t3tools/client-runtime/platform/persistence/EnvironmentCacheStore") {} export class EnvironmentOwnedDataCleanup extends Context.Reference<{ - readonly clear: (environmentId: EnvironmentId) => Effect.Effect; + readonly clear: ( + environmentId: EnvironmentId, + supervisor: EnvironmentSupervisor["Service"] | undefined, + ) => Effect.Effect; }>("@t3tools/client-runtime/platform/persistence/EnvironmentOwnedDataCleanup", { defaultValue: () => ({ clear: () => Effect.void, From 831d861a0604e735ca7e0a3c53a3863a66db05c4 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 16:59:41 -0400 Subject: [PATCH 23/79] Skip unused attachment retention scans --- .../Layers/ProjectionPipeline.ts | 36 ++++++++++--------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 9fbfb545fe2..5fb38065cfd 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -485,23 +485,25 @@ const runAttachmentSideEffects = Effect.fn("runAttachmentSideEffects")(function* { concurrency: 1 }, ); - const retainedTextAttachmentPaths = collectThreadTextAttachmentRelativePaths( - attachmentsRootDir, - (yield* projectionThreadMessageRepository.listRetained()).filter( - (message) => !sideEffects.deletedThreadIds.has(message.threadId), - ), - ); - yield* Effect.forEach( - [...sideEffects.textAttachmentRelativePathsToRemove].filter( - (relativePath) => !retainedTextAttachmentPaths.has(relativePath), - ), - (relativePath) => - fileSystem.remove(path.join(attachmentsRootDir, path.dirname(relativePath)), { - recursive: true, - force: true, - }), - { concurrency: 1 }, - ); + if (sideEffects.textAttachmentRelativePathsToRemove.size > 0) { + const retainedTextAttachmentPaths = collectThreadTextAttachmentRelativePaths( + attachmentsRootDir, + (yield* projectionThreadMessageRepository.listRetained()).filter( + (message) => !sideEffects.deletedThreadIds.has(message.threadId), + ), + ); + yield* Effect.forEach( + [...sideEffects.textAttachmentRelativePathsToRemove].filter( + (relativePath) => !retainedTextAttachmentPaths.has(relativePath), + ), + (relativePath) => + fileSystem.remove(path.join(attachmentsRootDir, path.dirname(relativePath)), { + recursive: true, + force: true, + }), + { concurrency: 1 }, + ); + } }); const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjectionPipeline")( From 761394fd7fd2150040d078077d2a42dbed020dca Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 17:01:41 -0400 Subject: [PATCH 24/79] Keep attachment cleanup out of projection replay --- .../Layers/ProjectionPipeline.test.ts | 121 ++++++++++++++++++ .../Layers/ProjectionPipeline.ts | 33 ++--- 2 files changed, 139 insertions(+), 15 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 8341a676baa..c5ddfde357f 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -1083,6 +1083,127 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta }, ); +it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-attachments-replay-")))( + "OrchestrationProjectionPipeline", + (it) => { + it.effect("does not delete shared text attachments while rebuilding projections", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const { attachmentsDir } = yield* ServerConfig; + const now = "2026-01-01T00:00:00.000Z"; + const projectId = ProjectId.make("project-attachment-replay"); + const deletedThreadId = ThreadId.make("thread-attachment-replay-deleted"); + const retainedThreadId = ThreadId.make("thread-attachment-replay-retained"); + const textAttachmentPath = path.join( + attachmentsDir, + "text", + "00000000-0000-4000-8000-000000000005", + "shared.txt", + ); + const attachmentLink = `[shared.txt](${encodeURI(textAttachmentPath)})`; + const append = (event: Parameters[0]) => + eventStore.append(event).pipe(Effect.asVoid); + + yield* append({ + type: "project.created", + eventId: EventId.make("evt-attachment-replay-1"), + aggregateKind: "project", + aggregateId: projectId, + occurredAt: now, + commandId: CommandId.make("cmd-attachment-replay-1"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-attachment-replay-1"), + metadata: {}, + payload: { + projectId, + title: "Attachment Replay", + workspaceRoot: "/tmp/project-attachment-replay", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); + + for (const [index, threadId] of [deletedThreadId, retainedThreadId].entries()) { + yield* append({ + type: "thread.created", + eventId: EventId.make(`evt-attachment-replay-thread-${index}`), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.make(`cmd-attachment-replay-thread-${index}`), + causationEventId: null, + correlationId: CorrelationId.make(`cmd-attachment-replay-thread-${index}`), + metadata: {}, + payload: { + threadId, + projectId, + title: `Attachment Replay ${index}`, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }); + yield* append({ + type: "thread.message-sent", + eventId: EventId.make(`evt-attachment-replay-message-${index}`), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.make(`cmd-attachment-replay-message-${index}`), + causationEventId: null, + correlationId: CorrelationId.make(`cmd-attachment-replay-message-${index}`), + metadata: {}, + payload: { + threadId, + messageId: MessageId.make(`message-attachment-replay-${index}`), + role: "user", + text: attachmentLink, + turnId: null, + streaming: false, + createdAt: now, + updatedAt: now, + }, + }); + } + + yield* append({ + type: "thread.deleted", + eventId: EventId.make("evt-attachment-replay-delete"), + aggregateKind: "thread", + aggregateId: deletedThreadId, + occurredAt: now, + commandId: CommandId.make("cmd-attachment-replay-delete"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-attachment-replay-delete"), + metadata: {}, + payload: { + threadId: deletedThreadId, + deletedAt: now, + }, + }); + + yield* fileSystem.makeDirectory(path.dirname(textAttachmentPath), { recursive: true }); + yield* fileSystem.writeFileString(textAttachmentPath, "shared attachment"); + + yield* projectionPipeline.bootstrap; + + assert.isTrue(yield* exists(textAttachmentPath)); + }), + ); + }, +); + it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-attachments-delete-")))( "OrchestrationProjectionPipeline", (it) => { diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 5fb38065cfd..aab10061cc8 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -1566,6 +1566,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti const runProjectorForEvent = Effect.fn("runProjectorForEvent")(function* ( projector: ProjectorDefinition, event: OrchestrationEvent, + applyAttachmentSideEffects: boolean, ) { const attachmentSideEffects: AttachmentSideEffects = { deletedThreadIds: new Set(), @@ -1585,19 +1586,21 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ), ); - yield* runAttachmentSideEffects( - attachmentSideEffects, - projectionThreadMessageRepository, - ).pipe( - Effect.catch((cause) => - Effect.logWarning("failed to apply projected attachment side-effects", { - projector: projector.name, - sequence: event.sequence, - eventType: event.type, - cause, - }), - ), - ); + if (applyAttachmentSideEffects) { + yield* runAttachmentSideEffects( + attachmentSideEffects, + projectionThreadMessageRepository, + ).pipe( + Effect.catch((cause) => + Effect.logWarning("failed to apply projected attachment side-effects", { + projector: projector.name, + sequence: event.sequence, + eventType: event.type, + cause, + }), + ), + ); + } }); const bootstrapProjector = (projector: ProjectorDefinition) => @@ -1611,13 +1614,13 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti eventStore.readFromSequence( Option.isSome(stateRow) ? stateRow.value.lastAppliedSequence : 0, ), - (event) => runProjectorForEvent(projector, event), + (event) => runProjectorForEvent(projector, event, false), ), ), ); const projectEvent: OrchestrationProjectionPipelineShape["projectEvent"] = (event) => - Effect.forEach(projectors, (projector) => runProjectorForEvent(projector, event), { + Effect.forEach(projectors, (projector) => runProjectorForEvent(projector, event, true), { concurrency: 1, }).pipe( Effect.provideService(FileSystem.FileSystem, fileSystem), From d048f3c1b223ca9459f7630b854b66f0f6eab2cb Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 17:01:57 -0400 Subject: [PATCH 25/79] Clean up removed draft text attachments --- apps/web/src/components/chat/ChatComposer.tsx | 29 ++++++++++++++++++- apps/web/src/textAttachmentPaths.test.ts | 27 ++++++++++++++++- apps/web/src/textAttachmentPaths.ts | 11 +++++++ 3 files changed, 65 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index edf99aa70b0..0845c28be13 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -87,6 +87,7 @@ import { import { ContextWindowMeter } from "./ContextWindowMeter"; import { buildExpandedImagePreview, type ExpandedImagePreview } from "./ExpandedImagePreview"; import { basenameOfPath } from "../../pierre-icons"; +import { removedOwnedTextAttachmentPaths, textAttachmentPaths } from "../../textAttachmentPaths"; import { cn, randomUUID } from "~/lib/utils"; import { Separator } from "../ui/separator"; import { Button } from "../ui/button"; @@ -629,6 +630,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const writeTextAttachment = useAtomCommand(assetEnvironment.writeTextAttachment, { reportFailure: false, }); + const deleteTextAttachment = useAtomCommand(assetEnvironment.deleteTextAttachment, { + reportFailure: false, + }); const prompt = composerDraft.prompt; const composerImages = composerDraft.images; const composerTerminalContexts = composerDraft.terminalContexts; @@ -923,6 +927,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const attachmentQueueRef = useRef>(Promise.resolve()); const composerAttachmentKeyRef = useRef(composerAttachmentKey); composerAttachmentKeyRef.current = composerAttachmentKey; + const ownedTextAttachmentPathsRef = useRef(new Set(textAttachmentPaths(prompt))); + const ownedTextAttachmentTargetKeyRef = useRef(composerAttachmentKey); + if (ownedTextAttachmentTargetKeyRef.current !== composerAttachmentKey) { + ownedTextAttachmentTargetKeyRef.current = composerAttachmentKey; + ownedTextAttachmentPathsRef.current = new Set(textAttachmentPaths(prompt)); + } // ------------------------------------------------------------------ // Derived: composer send state @@ -1173,9 +1183,25 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // ------------------------------------------------------------------ const setPrompt = useCallback( (nextPrompt: string) => { + const previousPrompt = getComposerDraft(composerDraftTarget)?.prompt ?? ""; + const removedPaths = removedOwnedTextAttachmentPaths( + previousPrompt, + nextPrompt, + ownedTextAttachmentPathsRef.current, + ); + for (const path of removedPaths) { + ownedTextAttachmentPathsRef.current.delete(path); + void deleteTextAttachment({ environmentId, input: { path } }); + } setComposerDraftPrompt(composerDraftTarget, nextPrompt); }, - [composerDraftTarget, setComposerDraftPrompt], + [ + composerDraftTarget, + deleteTextAttachment, + environmentId, + getComposerDraft, + setComposerDraftPrompt, + ], ); const addComposerImage = useCallback( @@ -1812,6 +1838,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) if (result === null) return `'${file.name}' is not a supported text file.`; if (result._tag === "Failure") return `Could not attach '${file.name}'.`; + ownedTextAttachmentPathsRef.current.add(result.value.path); const currentPrompt = getComposerDraft(composerDraftTarget)?.prompt ?? ""; const separator = currentPrompt.length > 0 && !/\s$/.test(currentPrompt) ? " " : ""; const nextPrompt = `${currentPrompt}${separator}${serializeComposerFileLink( diff --git a/apps/web/src/textAttachmentPaths.test.ts b/apps/web/src/textAttachmentPaths.test.ts index 9493252e0ee..51aa65ce463 100644 --- a/apps/web/src/textAttachmentPaths.test.ts +++ b/apps/web/src/textAttachmentPaths.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { textAttachmentPaths } from "./textAttachmentPaths"; +import { removedOwnedTextAttachmentPaths, textAttachmentPaths } from "./textAttachmentPaths"; describe("textAttachmentPaths", () => { it("collects unique generated attachment links from a discarded draft", () => { @@ -10,3 +10,28 @@ describe("textAttachmentPaths", () => { expect(textAttachmentPaths("ordinary prompt")).toEqual([]); }); }); + +describe("removedOwnedTextAttachmentPaths", () => { + it("collects an owned attachment after its generated link is removed", () => { + const ownedPath = + "/var/t3-data/attachments/text/12345678-1234-1234-1234-123456789abc/notes.txt"; + const unrelatedPath = + "/var/t3-data/attachments/text/87654321-4321-4321-4321-cba987654321/copied.txt"; + + expect( + removedOwnedTextAttachmentPaths( + `[notes.txt](${ownedPath}) [copied.txt](${unrelatedPath})`, + `[copied.txt](${unrelatedPath})`, + new Set([ownedPath]), + ), + ).toEqual([ownedPath]); + }); + + it("keeps an owned attachment while its link remains", () => { + const path = "/var/t3-data/attachments/text/12345678-1234-1234-1234-123456789abc/notes.txt"; + + expect( + removedOwnedTextAttachmentPaths(`[notes.txt](${path})`, `[notes](${path})`, new Set([path])), + ).toEqual([]); + }); +}); diff --git a/apps/web/src/textAttachmentPaths.ts b/apps/web/src/textAttachmentPaths.ts index 3e8efa4f7d2..78611a67e3b 100644 --- a/apps/web/src/textAttachmentPaths.ts +++ b/apps/web/src/textAttachmentPaths.ts @@ -24,4 +24,15 @@ export function textAttachmentPaths(prompt: string): string[] { ]; } +export function removedOwnedTextAttachmentPaths( + previousPrompt: string, + nextPrompt: string, + ownedPaths: ReadonlySet, +): string[] { + const nextPaths = new Set(textAttachmentPaths(nextPrompt)); + return textAttachmentPaths(previousPrompt).filter( + (path) => ownedPaths.has(path) && !nextPaths.has(path), + ); +} + import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; From 0f1e5b8209a005952672b776211a06425837515a Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 17:05:22 -0400 Subject: [PATCH 26/79] Preserve shared draft text attachments --- apps/web/src/components/Sidebar.tsx | 48 +++++++++++++++--------- apps/web/src/composerDraftStore.test.ts | 29 ++++++++++++++ apps/web/src/composerDraftStore.ts | 42 ++++++++++++++++++++- apps/web/src/hooks/useThreadActions.ts | 12 ++++-- apps/web/src/textAttachmentPaths.test.ts | 22 ++++++++++- apps/web/src/textAttachmentPaths.ts | 12 ++++++ 6 files changed, 143 insertions(+), 22 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 56a8b24add1..93bb613e607 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -108,7 +108,11 @@ import { import { isModelPickerOpen } from "../modelPickerVisibility"; import { useShortcutModifierState } from "../shortcutModifierState"; import { readLocalApi } from "../localApi"; -import { useComposerDraftStore } from "../composerDraftStore"; +import { + composerDraftPromptsEnvironmentExcept, + composerDraftTargetsProject, + useComposerDraftStore, +} from "../composerDraftStore"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { useDesktopUpdateState } from "../state/desktopUpdate"; @@ -119,7 +123,7 @@ import { useEnvironmentQuery } from "../state/query"; import { threadEnvironment, useEnvironmentThread } from "../state/threads"; import { vcsEnvironment } from "../state/vcs"; import { useEnvironment, useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; -import { textAttachmentPaths } from "../textAttachmentPaths"; +import { unreferencedTextAttachmentPaths } from "../textAttachmentPaths"; import { buildThreadRouteParams, resolveThreadRouteRef, @@ -1443,10 +1447,20 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec async (member: SidebarProjectGroupMember, options: { force?: boolean } = {}) => { const memberProjectRef = scopeProjectRef(member.environmentId, member.id); const draftStore = useComposerDraftStore.getState(); - const projectDraftThread = draftStore.getDraftThreadByProjectRef(memberProjectRef); - const discardedPrompt = projectDraftThread - ? (draftStore.getComposerDraft(projectDraftThread.draftId)?.prompt ?? "") - : ""; + const projectThreadRefs = projectThreads + .filter( + (thread) => + thread.environmentId === member.environmentId && thread.projectId === member.id, + ) + .map((thread) => scopeThreadRef(thread.environmentId, thread.id)); + const discardedTargets = composerDraftTargetsProject(memberProjectRef, projectThreadRefs); + const discardedPrompts = discardedTargets.map( + (target) => draftStore.getComposerDraft(target)?.prompt ?? "", + ); + const retainedPrompts = composerDraftPromptsEnvironmentExcept( + member.environmentId, + discardedTargets, + ); const result = await deleteProject({ environmentId: member.environmentId, input: { @@ -1457,21 +1471,21 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec if (result._tag === "Failure") { return result; } - if (projectDraftThread) { - await Promise.all( - textAttachmentPaths(discardedPrompt).map((path) => - deleteTextAttachment({ - environmentId: member.environmentId, - input: { path }, - }), - ), - ); - draftStore.clearDraftThread(projectDraftThread.draftId); + await Promise.all( + unreferencedTextAttachmentPaths(discardedPrompts, retainedPrompts).map((path) => + deleteTextAttachment({ + environmentId: member.environmentId, + input: { path }, + }), + ), + ); + for (const target of discardedTargets) { + draftStore.clearDraftThread(target); } draftStore.clearProjectDraftThreadId(memberProjectRef); return result; }, - [deleteProject, deleteTextAttachment], + [deleteProject, deleteTextAttachment, projectThreads], ); const handleRemoveProject = useCallback( diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index c5a31b54ec0..72345a2ee1c 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -61,6 +61,8 @@ import { COMPOSER_DRAFT_STORAGE_KEY, clearComposerDraftsEnvironment, composerDraftPromptsEnvironment, + composerDraftPromptsEnvironmentExcept, + composerDraftTargetsProject, finalizePromotedDraftThreadByRef, markPromotedDraftThread, markPromotedDraftThreadByRef, @@ -734,6 +736,33 @@ describe("composerDraftStore project draft thread mapping", () => { } }); + it("collects every draft target removed with a forced project deletion", () => { + const store = useComposerDraftStore.getState(); + const projectThreadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); + const retainedThreadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, otherThreadId); + const projectDraftThreadId = ThreadId.make("project-draft-thread"); + + store.setPrompt(projectThreadRef, "project server-thread draft"); + store.setProjectDraftThreadId(projectRef, draftId, { threadId: projectDraftThreadId }); + store.setPrompt(draftId, "project new-thread draft"); + store.setPrompt(retainedThreadRef, "other project draft"); + + const discardedTargets = composerDraftTargetsProject(projectRef, [projectThreadRef]); + expect(discardedTargets).toEqual([projectThreadRef, draftId]); + expect(discardedTargets.map((target) => store.getComposerDraft(target)?.prompt)).toEqual([ + "project server-thread draft", + "project new-thread draft", + ]); + expect(composerDraftPromptsEnvironmentExcept(TEST_ENVIRONMENT_ID, discardedTargets)).toEqual([ + "other project draft", + ]); + + for (const target of discardedTargets) store.clearDraftThread(target); + expect(store.getComposerDraft(projectThreadRef)).toBeNull(); + expect(store.getComposerDraft(draftId)).toBeNull(); + expect(store.getComposerDraft(retainedThreadRef)?.prompt).toBe("other project draft"); + }); + it("stores and reads project draft thread ids via actions", () => { const store = useComposerDraftStore.getState(); expect(store.getDraftThreadByProjectRef(projectRef)).toBeNull(); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index dfe15e324fe..d4a68c93ef7 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -315,7 +315,7 @@ interface ProjectDraftSession extends DraftSessionState { * Raw `ThreadId` is intentionally excluded so callers cannot drop environment * identity for real threads. */ -type ComposerThreadTarget = ScopedThreadRef | DraftId; +export type ComposerThreadTarget = ScopedThreadRef | DraftId; /** * Persisted store for composer content plus draft-session metadata. @@ -3404,6 +3404,46 @@ export function composerDraftPromptsEnvironment(environmentId: EnvironmentId): s }); } +export function composerDraftPromptsEnvironmentExcept( + environmentId: EnvironmentId, + excludedTargets: ReadonlyArray, +): string[] { + const state = useComposerDraftStore.getState(); + const excludedKeys = new Set( + excludedTargets.flatMap((target) => { + const key = resolveComposerDraftKey(state, target); + return key ? [key] : []; + }), + ); + return [...composerDraftKeysEnvironment(state, environmentId)].flatMap((key) => { + const draft = state.draftsByThreadKey[key]; + return draft && !excludedKeys.has(key) ? [draft.prompt] : []; + }); +} + +export function composerDraftTargetsProject( + projectRef: ScopedProjectRef, + threadRefs: ReadonlyArray, +): ComposerThreadTarget[] { + const state = useComposerDraftStore.getState(); + const targets: ComposerThreadTarget[] = [...threadRefs]; + for (const [draftId, draftThread] of Object.entries(state.draftThreadsByThreadKey)) { + if ( + draftThread.environmentId === projectRef.environmentId && + draftThread.projectId === projectRef.projectId + ) { + targets.push(DraftId.make(draftId)); + } + } + const seenKeys = new Set(); + return targets.filter((target) => { + const key = resolveComposerDraftKey(state, target); + if (!key || seenKeys.has(key)) return false; + seenKeys.add(key); + return true; + }); +} + export function clearComposerDraftsEnvironment(environmentId: EnvironmentId): void { useComposerDraftStore.setState((state) => { const removedThreadKeys = composerDraftKeysEnvironment(state, environmentId); diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 5cf7e828642..a72a684f1a8 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -12,7 +12,10 @@ import { useRouter } from "@tanstack/react-router"; import { useCallback, useMemo, useRef } from "react"; import { getFallbackThreadIdAfterDelete } from "../components/Sidebar.logic"; -import { useComposerDraftStore } from "../composerDraftStore"; +import { + composerDraftPromptsEnvironmentExcept, + useComposerDraftStore, +} from "../composerDraftStore"; import { terminalEnvironment } from "../state/terminal"; import { threadEnvironment } from "../state/threads"; import { assetEnvironment } from "../state/assets"; @@ -27,7 +30,7 @@ import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from " import { stackedThreadToast, toastManager } from "../components/ui/toast"; import { useClientSettings } from "./useSettings"; import { useAtomCommand } from "../state/use-atom-command"; -import { textAttachmentPaths } from "../textAttachmentPaths"; +import { unreferencedTextAttachmentPaths } from "../textAttachmentPaths"; export class ThreadArchiveBlockedError extends Schema.TaggedErrorClass()( "ThreadArchiveBlockedError", @@ -240,6 +243,9 @@ export function useThreadActions() { }); const discardedPrompt = useComposerDraftStore.getState().getComposerDraft(threadRef)?.prompt ?? ""; + const retainedPrompts = composerDraftPromptsEnvironmentExcept(threadRef.environmentId, [ + threadRef, + ]); const deleteResult = await deleteThreadMutation({ environmentId: threadRef.environmentId, input: { threadId: threadRef.threadId }, @@ -248,7 +254,7 @@ export function useThreadActions() { return deleteResult; } await Promise.all( - textAttachmentPaths(discardedPrompt).map((path) => + unreferencedTextAttachmentPaths([discardedPrompt], retainedPrompts).map((path) => deleteTextAttachment({ environmentId: threadRef.environmentId, input: { path }, diff --git a/apps/web/src/textAttachmentPaths.test.ts b/apps/web/src/textAttachmentPaths.test.ts index 51aa65ce463..60a260fd2c7 100644 --- a/apps/web/src/textAttachmentPaths.test.ts +++ b/apps/web/src/textAttachmentPaths.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vite-plus/test"; -import { removedOwnedTextAttachmentPaths, textAttachmentPaths } from "./textAttachmentPaths"; +import { + removedOwnedTextAttachmentPaths, + textAttachmentPaths, + unreferencedTextAttachmentPaths, +} from "./textAttachmentPaths"; describe("textAttachmentPaths", () => { it("collects unique generated attachment links from a discarded draft", () => { @@ -35,3 +39,19 @@ describe("removedOwnedTextAttachmentPaths", () => { ).toEqual([]); }); }); + +describe("unreferencedTextAttachmentPaths", () => { + it("protects an attachment referenced by another unsent draft", () => { + const sharedPath = + "/var/t3-data/attachments/text/12345678-1234-1234-1234-123456789abc/shared.txt"; + const uniquePath = + "/var/t3-data/attachments/text/87654321-4321-4321-4321-cba987654321/unique.txt"; + + expect( + unreferencedTextAttachmentPaths( + [`[shared.txt](${sharedPath}) [unique.txt](${uniquePath}) `], + [`Still using [shared.txt](${sharedPath}) `], + ), + ).toEqual([uniquePath]); + }); +}); diff --git a/apps/web/src/textAttachmentPaths.ts b/apps/web/src/textAttachmentPaths.ts index 78611a67e3b..f669ff6287a 100644 --- a/apps/web/src/textAttachmentPaths.ts +++ b/apps/web/src/textAttachmentPaths.ts @@ -35,4 +35,16 @@ export function removedOwnedTextAttachmentPaths( ); } +export function unreferencedTextAttachmentPaths( + discardedPrompts: ReadonlyArray, + retainedPrompts: ReadonlyArray, +): string[] { + const retainedPaths = new Set(retainedPrompts.flatMap(textAttachmentPaths)); + return [ + ...new Set( + discardedPrompts.flatMap(textAttachmentPaths).filter((path) => !retainedPaths.has(path)), + ), + ]; +} + import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; From 773675d1e2bc5643d9628b402a7f9eb0d78c3ad6 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 17:09:44 -0400 Subject: [PATCH 27/79] Parse generated attachment links at end of prompt --- apps/web/src/textAttachmentPaths.test.ts | 26 ++++++++++++++++++++++++ apps/web/src/textAttachmentPaths.ts | 23 +++++++++++++-------- 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/apps/web/src/textAttachmentPaths.test.ts b/apps/web/src/textAttachmentPaths.test.ts index 60a260fd2c7..4019f06dc2a 100644 --- a/apps/web/src/textAttachmentPaths.test.ts +++ b/apps/web/src/textAttachmentPaths.test.ts @@ -13,6 +13,12 @@ describe("textAttachmentPaths", () => { expect(textAttachmentPaths(`[notes.txt](${path}) keep [notes.txt](${path})`)).toEqual([path]); expect(textAttachmentPaths("ordinary prompt")).toEqual([]); }); + + it("collects a generated attachment whose Markdown link ends at EOF", () => { + const path = "/var/t3-data/attachments/text/12345678-1234-1234-1234-123456789abc/notes.txt"; + + expect(textAttachmentPaths(`[notes.txt](${path})`)).toEqual([path]); + }); }); describe("removedOwnedTextAttachmentPaths", () => { @@ -38,6 +44,26 @@ describe("removedOwnedTextAttachmentPaths", () => { removedOwnedTextAttachmentPaths(`[notes.txt](${path})`, `[notes](${path})`, new Set([path])), ).toEqual([]); }); + + it("detects removal when the previous attachment link ended at EOF", () => { + const path = "/var/t3-data/attachments/text/12345678-1234-1234-1234-123456789abc/notes.txt"; + + expect(removedOwnedTextAttachmentPaths(`[notes.txt](${path})`, "", new Set([path]))).toEqual([ + path, + ]); + }); + + it("preserves an owned attachment whose next link ends at EOF", () => { + const path = "/var/t3-data/attachments/text/12345678-1234-1234-1234-123456789abc/notes.txt"; + + expect( + removedOwnedTextAttachmentPaths( + `[notes.txt](${path}) `, + `[notes.txt](${path})`, + new Set([path]), + ), + ).toEqual([]); + }); }); describe("unreferencedTextAttachmentPaths", () => { diff --git a/apps/web/src/textAttachmentPaths.ts b/apps/web/src/textAttachmentPaths.ts index f669ff6287a..85c649bb0de 100644 --- a/apps/web/src/textAttachmentPaths.ts +++ b/apps/web/src/textAttachmentPaths.ts @@ -7,6 +7,7 @@ const LEGACY_FLAT_TEXT_ATTACHMENT_PATH_PATTERN = new RegExp( `(?:^|[\\\\/])\\.t3[\\\\/]attachments[\\\\/]${UUID_PATH_SEGMENT}-[^\\\\/]+$`, "i", ); +const MARKDOWN_LINK_DESTINATION_PATTERN = /(?:^|\s)\[(?:\\.|[^\]\\])*\]\(([^)\s]+)\)(?=\s|$)/g; export function isTextAttachmentPath(path: string): boolean { return ( @@ -15,13 +16,19 @@ export function isTextAttachmentPath(path: string): boolean { } export function textAttachmentPaths(prompt: string): string[] { - return [ - ...new Set( - collectComposerInlineTokens(prompt).flatMap((token) => - token.type === "mention" && isTextAttachmentPath(token.value) ? [token.value] : [], - ), - ), - ]; + const paths = new Set(); + for (const match of prompt.matchAll(MARKDOWN_LINK_DESTINATION_PATTERN)) { + const encodedPath = match[1]; + if (!encodedPath) continue; + let path = encodedPath; + try { + path = decodeURIComponent(encodedPath); + } catch { + // Preserve malformed source rather than dropping a generated path. + } + if (isTextAttachmentPath(path)) paths.add(path); + } + return [...paths]; } export function removedOwnedTextAttachmentPaths( @@ -46,5 +53,3 @@ export function unreferencedTextAttachmentPaths( ), ]; } - -import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; From cf43261ed79437e6337297c9a8b8f08223d2a1bc Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 17:11:15 -0400 Subject: [PATCH 28/79] Reconcile attachment cleanup after projection replay --- .../Layers/ProjectionPipeline.test.ts | 71 ++++++++++++++- .../Layers/ProjectionPipeline.ts | 88 ++++++++++++++++--- 2 files changed, 147 insertions(+), 12 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index c5ddfde357f..c9c48b4ae50 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -735,6 +735,12 @@ it.layer( const removeAttachmentId = "thread-revert-files-00000000-0000-4000-8000-000000000002"; const otherThreadAttachmentId = "thread-revert-files-extra-00000000-0000-4000-8000-000000000003"; + const removeTextAttachmentPath = path.join( + attachmentsDir, + "text", + "00000000-0000-4000-8000-000000000008", + "remove.txt", + ); const appendAndProject = (event: Parameters[0]) => eventStore @@ -877,7 +883,7 @@ it.layer( threadId, messageId: MessageId.make("message-remove"), role: "assistant", - text: "Remove", + text: `[remove.txt](${encodeURI(removeTextAttachmentPath)})`, attachments: [ { type: "image", @@ -924,6 +930,21 @@ it.layer( assert.isTrue(yield* exists(keepPath)); assert.isFalse(yield* exists(removePath)); assert.isTrue(yield* exists(otherThreadPath)); + + // Simulate a crash after the projection transaction commits but before + // its filesystem cleanup completes. + yield* fileSystem.writeFileString(removePath, "remove after restart"); + yield* fileSystem.makeDirectory(path.dirname(removeTextAttachmentPath), { + recursive: true, + }); + yield* fileSystem.writeFileString(removeTextAttachmentPath, "remove after restart"); + + yield* projectionPipeline.bootstrap; + + assert.isTrue(yield* exists(keepPath)); + assert.isFalse(yield* exists(removePath)); + assert.isFalse(yield* exists(removeTextAttachmentPath)); + assert.isTrue(yield* exists(otherThreadPath)); }), ); }); @@ -1078,6 +1099,17 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta assert.isTrue(yield* exists(otherThreadAttachmentPath)); assert.isFalse(yield* exists(textAttachmentPath)); assert.isTrue(yield* exists(unrelatedTextAttachmentPath)); + + yield* fileSystem.writeFileString(threadAttachmentPath, "delete after restart"); + yield* fileSystem.makeDirectory(path.dirname(textAttachmentPath), { recursive: true }); + yield* fileSystem.writeFileString(textAttachmentPath, "delete after restart"); + + yield* projectionPipeline.bootstrap; + + assert.isFalse(yield* exists(threadAttachmentPath)); + assert.isTrue(yield* exists(otherThreadAttachmentPath)); + assert.isFalse(yield* exists(textAttachmentPath)); + assert.isTrue(yield* exists(unrelatedTextAttachmentPath)); }), ); }, @@ -1103,6 +1135,18 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta "00000000-0000-4000-8000-000000000005", "shared.txt", ); + const deletedTextAttachmentPath = path.join( + attachmentsDir, + "text", + "00000000-0000-4000-8000-000000000006", + "deleted.txt", + ); + const deletedImageAttachmentId = + "thread-attachment-replay-deleted-00000000-0000-4000-8000-000000000007"; + const deletedImageAttachmentPath = path.join( + attachmentsDir, + `${deletedImageAttachmentId}.png`, + ); const attachmentLink = `[shared.txt](${encodeURI(textAttachmentPath)})`; const append = (event: Parameters[0]) => eventStore.append(event).pipe(Effect.asVoid); @@ -1168,7 +1212,23 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta threadId, messageId: MessageId.make(`message-attachment-replay-${index}`), role: "user", - text: attachmentLink, + text: + index === 0 + ? `${attachmentLink} [deleted.txt](${encodeURI(deletedTextAttachmentPath)})` + : attachmentLink, + ...(index === 0 + ? { + attachments: [ + { + type: "image" as const, + id: deletedImageAttachmentId, + name: "deleted.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ], + } + : {}), turnId: null, streaming: false, createdAt: now, @@ -1194,11 +1254,18 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta }); yield* fileSystem.makeDirectory(path.dirname(textAttachmentPath), { recursive: true }); + yield* fileSystem.makeDirectory(path.dirname(deletedTextAttachmentPath), { + recursive: true, + }); yield* fileSystem.writeFileString(textAttachmentPath, "shared attachment"); + yield* fileSystem.writeFileString(deletedTextAttachmentPath, "deleted attachment"); + yield* fileSystem.writeFileString(deletedImageAttachmentPath, "deleted image"); yield* projectionPipeline.bootstrap; assert.isTrue(yield* exists(textAttachmentPath)); + assert.isFalse(yield* exists(deletedTextAttachmentPath)); + assert.isFalse(yield* exists(deletedImageAttachmentPath)); }), ); }, diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index aab10061cc8..be5236e737b 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -108,6 +108,14 @@ interface AttachmentSideEffects { readonly textAttachmentRelativePathsToRemove: Set; } +function makeAttachmentSideEffects(): AttachmentSideEffects { + return { + deletedThreadIds: new Set(), + prunedThreadRelativePaths: new Map>(), + textAttachmentRelativePathsToRemove: new Set(), + }; +} + const materializeAttachmentsForProjection = Effect.fn("materializeAttachmentsForProjection")( (input: { readonly attachments: ReadonlyArray }) => Effect.succeed(input.attachments.length === 0 ? [] : input.attachments), @@ -1568,11 +1576,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti event: OrchestrationEvent, applyAttachmentSideEffects: boolean, ) { - const attachmentSideEffects: AttachmentSideEffects = { - deletedThreadIds: new Set(), - prunedThreadRelativePaths: new Map>(), - textAttachmentRelativePathsToRemove: new Set(), - }; + const attachmentSideEffects = makeAttachmentSideEffects(); yield* sql.withTransaction( projector.apply(event, attachmentSideEffects).pipe( @@ -1619,6 +1623,63 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ), ); + const collectBootstrapAttachmentSideEffects = Effect.fn( + "collectBootstrapAttachmentSideEffects", + )(function* () { + const attachmentSideEffects = makeAttachmentSideEffects(); + const messageTextsByThread = new Map>(); + + yield* Stream.runForEach(eventStore.readFromSequence(0), (event) => + Effect.sync(() => { + if (event.type === "thread.message-sent") { + const messageTexts = + messageTextsByThread.get(event.payload.threadId) ?? new Map(); + const previousText = messageTexts.get(event.payload.messageId); + const nextText = + previousText === undefined + ? event.payload.text + : event.payload.streaming + ? `${previousText}${event.payload.text}` + : event.payload.text.length === 0 + ? previousText + : event.payload.text; + messageTexts.set(event.payload.messageId, nextText); + messageTextsByThread.set(event.payload.threadId, messageTexts); + return; + } + + if (event.type !== "thread.deleted" && event.type !== "thread.reverted") { + return; + } + if (event.type === "thread.deleted") { + attachmentSideEffects.deletedThreadIds.add(event.payload.threadId); + } else { + attachmentSideEffects.prunedThreadRelativePaths.set(event.payload.threadId, new Set()); + } + for (const text of messageTextsByThread.get(event.payload.threadId)?.values() ?? []) { + for (const relativePath of collectTextAttachmentRelativePaths({ + attachmentsDir: serverConfig.attachmentsDir, + text, + })) { + attachmentSideEffects.textAttachmentRelativePathsToRemove.add(relativePath); + } + } + }), + ); + + for (const threadId of attachmentSideEffects.prunedThreadRelativePaths.keys()) { + const messages = yield* projectionThreadMessageRepository.listByThreadId({ + threadId: ThreadId.make(threadId), + }); + attachmentSideEffects.prunedThreadRelativePaths.set( + threadId, + collectThreadAttachmentRelativePaths(threadId, messages), + ); + } + + return attachmentSideEffects; + }); + const projectEvent: OrchestrationProjectionPipelineShape["projectEvent"] = (event) => Effect.forEach(projectors, (projector) => runProjectorForEvent(projector, event, true), { concurrency: 1, @@ -1633,11 +1694,18 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ), ); - const bootstrap: OrchestrationProjectionPipelineShape["bootstrap"] = Effect.forEach( - projectors, - bootstrapProjector, - { concurrency: 1 }, - ).pipe( + const bootstrap: OrchestrationProjectionPipelineShape["bootstrap"] = Effect.gen(function* () { + yield* Effect.forEach(projectors, bootstrapProjector, { concurrency: 1 }); + const attachmentSideEffects = yield* collectBootstrapAttachmentSideEffects(); + yield* runAttachmentSideEffects( + attachmentSideEffects, + projectionThreadMessageRepository, + ).pipe( + Effect.catch((cause) => + Effect.logWarning("failed to reconcile projected attachment side-effects", { cause }), + ), + ); + }).pipe( Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), Effect.provideService(ServerConfig, serverConfig), From 5dfdae1fabe78b34623fd93ed8391dad6fd7b98d Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 17:11:33 -0400 Subject: [PATCH 29/79] Defer cleanup of removed text attachments --- apps/web/src/components/chat/ChatComposer.tsx | 30 +++++++---- .../src/deferredTextAttachmentCleanup.test.ts | 52 +++++++++++++++++++ apps/web/src/deferredTextAttachmentCleanup.ts | 36 +++++++++++++ 3 files changed, 109 insertions(+), 9 deletions(-) create mode 100644 apps/web/src/deferredTextAttachmentCleanup.test.ts create mode 100644 apps/web/src/deferredTextAttachmentCleanup.ts diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 0845c28be13..c42e5a76a08 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -44,6 +44,7 @@ import { } from "../../composer-logic"; import { deriveComposerSendState, readFileAsDataUrl } from "../ChatView.logic"; import { + composerDraftPromptsEnvironment, type ComposerImageAttachment, type DraftId, type PersistedComposerImageAttachment, @@ -88,6 +89,10 @@ import { ContextWindowMeter } from "./ContextWindowMeter"; import { buildExpandedImagePreview, type ExpandedImagePreview } from "./ExpandedImagePreview"; import { basenameOfPath } from "../../pierre-icons"; import { removedOwnedTextAttachmentPaths, textAttachmentPaths } from "../../textAttachmentPaths"; +import { + DeferredTextAttachmentCleanup, + isTextAttachmentReferenced, +} from "../../deferredTextAttachmentCleanup"; import { cn, randomUUID } from "~/lib/utils"; import { Separator } from "../ui/separator"; import { Button } from "../ui/button"; @@ -927,11 +932,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const attachmentQueueRef = useRef>(Promise.resolve()); const composerAttachmentKeyRef = useRef(composerAttachmentKey); composerAttachmentKeyRef.current = composerAttachmentKey; - const ownedTextAttachmentPathsRef = useRef(new Set(textAttachmentPaths(prompt))); + const ownedTextAttachmentPathsRef = useRef(new Set()); + const textAttachmentCleanupRef = useRef(new DeferredTextAttachmentCleanup()); const ownedTextAttachmentTargetKeyRef = useRef(composerAttachmentKey); if (ownedTextAttachmentTargetKeyRef.current !== composerAttachmentKey) { ownedTextAttachmentTargetKeyRef.current = composerAttachmentKey; - ownedTextAttachmentPathsRef.current = new Set(textAttachmentPaths(prompt)); + ownedTextAttachmentPathsRef.current = new Set(); } // ------------------------------------------------------------------ @@ -1184,14 +1190,20 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const setPrompt = useCallback( (nextPrompt: string) => { const previousPrompt = getComposerDraft(composerDraftTarget)?.prompt ?? ""; - const removedPaths = removedOwnedTextAttachmentPaths( - previousPrompt, - nextPrompt, - ownedTextAttachmentPathsRef.current, - ); + for (const path of textAttachmentPaths(nextPrompt)) { + textAttachmentCleanupRef.current.cancel(path); + } + const ownedPaths = ownedTextAttachmentPathsRef.current; + const removedPaths = removedOwnedTextAttachmentPaths(previousPrompt, nextPrompt, ownedPaths); for (const path of removedPaths) { - ownedTextAttachmentPathsRef.current.delete(path); - void deleteTextAttachment({ environmentId, input: { path } }); + textAttachmentCleanupRef.current.schedule(path, { + isReferenced: () => + isTextAttachmentReferenced(path, composerDraftPromptsEnvironment(environmentId)), + deletePath: async () => { + const result = await deleteTextAttachment({ environmentId, input: { path } }); + if (result._tag === "Success") ownedPaths.delete(path); + }, + }); } setComposerDraftPrompt(composerDraftTarget, nextPrompt); }, diff --git a/apps/web/src/deferredTextAttachmentCleanup.test.ts b/apps/web/src/deferredTextAttachmentCleanup.test.ts new file mode 100644 index 00000000000..ae5a8a628a2 --- /dev/null +++ b/apps/web/src/deferredTextAttachmentCleanup.test.ts @@ -0,0 +1,52 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { + DeferredTextAttachmentCleanup, + isTextAttachmentReferenced, +} from "./deferredTextAttachmentCleanup"; + +const PATH = "/var/t3-data/attachments/text/12345678-1234-1234-1234-123456789abc/shared.txt"; + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("DeferredTextAttachmentCleanup", () => { + it("cancels deletion when a removed link is rapidly restored", async () => { + vi.useFakeTimers(); + const cleanup = new DeferredTextAttachmentCleanup(1_000); + const deletePath = vi.fn(); + + cleanup.schedule(PATH, { isReferenced: () => false, deletePath }); + cleanup.cancel(PATH); + await vi.advanceTimersByTimeAsync(1_000); + + expect(deletePath).not.toHaveBeenCalled(); + }); + + it("rechecks retained drafts before deleting", async () => { + vi.useFakeTimers(); + const cleanup = new DeferredTextAttachmentCleanup(1_000); + const deletePath = vi.fn(); + const retainedPrompts = [`Shared [shared.txt](${PATH})`]; + + cleanup.schedule(PATH, { + isReferenced: () => isTextAttachmentReferenced(PATH, retainedPrompts), + deletePath, + }); + await vi.advanceTimersByTimeAsync(1_000); + + expect(deletePath).not.toHaveBeenCalled(); + }); + + it("deletes after the deferral when no draft retains the path", async () => { + vi.useFakeTimers(); + const cleanup = new DeferredTextAttachmentCleanup(1_000); + const deletePath = vi.fn(); + + cleanup.schedule(PATH, { isReferenced: () => false, deletePath }); + await vi.advanceTimersByTimeAsync(1_000); + + expect(deletePath).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/src/deferredTextAttachmentCleanup.ts b/apps/web/src/deferredTextAttachmentCleanup.ts new file mode 100644 index 00000000000..6245c20a89c --- /dev/null +++ b/apps/web/src/deferredTextAttachmentCleanup.ts @@ -0,0 +1,36 @@ +import { textAttachmentPaths } from "./textAttachmentPaths"; + +export function isTextAttachmentReferenced(path: string, prompts: ReadonlyArray): boolean { + return prompts.some((prompt) => textAttachmentPaths(prompt).includes(path)); +} + +export class DeferredTextAttachmentCleanup { + readonly #delayMs: number; + readonly #pending = new Map>(); + + constructor(delayMs = 1_000) { + this.#delayMs = delayMs; + } + + schedule( + path: string, + options: { + isReferenced: () => boolean; + deletePath: () => void | Promise; + }, + ): void { + this.cancel(path); + const timeout = setTimeout(() => { + this.#pending.delete(path); + if (!options.isReferenced()) void options.deletePath(); + }, this.#delayMs); + this.#pending.set(path, timeout); + } + + cancel(path: string): void { + const timeout = this.#pending.get(path); + if (timeout === undefined) return; + clearTimeout(timeout); + this.#pending.delete(path); + } +} From 1e7a2edb8676898ad909f4bf6e26386514dd278b Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 17:16:32 -0400 Subject: [PATCH 30/79] Sweep orphaned attachments after projection replay --- .../Layers/ProjectionPipeline.test.ts | 17 +- .../Layers/ProjectionPipeline.ts | 148 ++++++++++-------- 2 files changed, 99 insertions(+), 66 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index c9c48b4ae50..08355ef6f4b 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -944,7 +944,7 @@ it.layer( assert.isTrue(yield* exists(keepPath)); assert.isFalse(yield* exists(removePath)); assert.isFalse(yield* exists(removeTextAttachmentPath)); - assert.isTrue(yield* exists(otherThreadPath)); + assert.isFalse(yield* exists(otherThreadPath)); }), ); }); @@ -1107,9 +1107,9 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta yield* projectionPipeline.bootstrap; assert.isFalse(yield* exists(threadAttachmentPath)); - assert.isTrue(yield* exists(otherThreadAttachmentPath)); + assert.isFalse(yield* exists(otherThreadAttachmentPath)); assert.isFalse(yield* exists(textAttachmentPath)); - assert.isTrue(yield* exists(unrelatedTextAttachmentPath)); + assert.isFalse(yield* exists(unrelatedTextAttachmentPath)); }), ); }, @@ -1283,9 +1283,18 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta const now = "2026-01-01T00:00:00.000Z"; const { attachmentsDir: attachmentsRootDir, stateDir } = yield* ServerConfig; const attachmentsSentinelPath = path.join(attachmentsRootDir, "sentinel.txt"); + const unknownAttachmentPath = path.join( + attachmentsRootDir, + "unknown-00000000-0000-4000-8000-000000000009.txt", + ); + const unknownTextDirectoryPath = path.join(attachmentsRootDir, "text", "manual"); + const unknownTextPath = path.join(unknownTextDirectoryPath, "notes.txt"); const stateDirSentinelPath = path.join(stateDir, "state-sentinel.txt"); yield* fileSystem.makeDirectory(attachmentsRootDir, { recursive: true }); + yield* fileSystem.makeDirectory(unknownTextDirectoryPath, { recursive: true }); yield* fileSystem.writeFileString(attachmentsSentinelPath, "keep-attachments-root"); + yield* fileSystem.writeFileString(unknownAttachmentPath, "keep-unknown-attachment"); + yield* fileSystem.writeFileString(unknownTextPath, "keep-unknown-text"); yield* fileSystem.writeFileString(stateDirSentinelPath, "keep-state-dir"); yield* eventStore.append({ @@ -1308,6 +1317,8 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta assert.isTrue(yield* exists(attachmentsRootDir)); assert.isTrue(yield* exists(attachmentsSentinelPath)); + assert.isTrue(yield* exists(unknownAttachmentPath)); + assert.isTrue(yield* exists(unknownTextPath)); assert.isTrue(yield* exists(stateDirSentinelPath)); }), ); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index be5236e737b..7bdb40409c6 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -44,6 +44,7 @@ import { ProjectionThreadSessionRepositoryLive } from "../../persistence/Layers/ import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts"; import { ProjectionThreadRepositoryLive } from "../../persistence/Layers/ProjectionThreads.ts"; import { ServerConfig } from "../../config.ts"; +import { SAFE_IMAGE_FILE_EXTENSIONS } from "../../imageMime.ts"; import { OrchestrationProjectionPipeline, type OrchestrationProjectionPipelineShape, @@ -514,6 +515,88 @@ const runAttachmentSideEffects = Effect.fn("runAttachmentSideEffects")(function* } }); +const GENERATED_IMAGE_ATTACHMENT_EXTENSIONS = new Set([...SAFE_IMAGE_FILE_EXTENSIONS, ".bin"]); +const GENERATED_TEXT_ATTACHMENT_DIRECTORY_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function isGeneratedImageAttachmentEntry(entry: string): boolean { + const normalizedEntry = entry.replace(/^[/\\]+/, "").replace(/\\/g, "/"); + if (normalizedEntry.length === 0 || normalizedEntry.includes("/")) { + return false; + } + const attachmentId = parseAttachmentIdFromRelativePath(normalizedEntry); + if (!attachmentId || !parseThreadSegmentFromAttachmentId(attachmentId)) { + return false; + } + const extension = normalizedEntry.slice(attachmentId.length).toLowerCase(); + return GENERATED_IMAGE_ATTACHMENT_EXTENSIONS.has(extension); +} + +const reconcileGeneratedAttachments = Effect.fn("reconcileGeneratedAttachments")(function* ( + projectionThreadMessageRepository: ProjectionThreadMessageRepository["Service"], +) { + const serverConfig = yield* Effect.service(ServerConfig); + const fileSystem = yield* Effect.service(FileSystem.FileSystem); + const path = yield* Effect.service(Path.Path); + const attachmentsRootDir = serverConfig.attachmentsDir; + const retainedMessages = yield* projectionThreadMessageRepository.listRetained(); + const retainedImagePaths = new Set(); + for (const message of retainedMessages) { + for (const attachment of message.attachments ?? []) { + if (attachment.type === "image") { + retainedImagePaths.add(attachmentRelativePath(attachment)); + } + } + } + const retainedTextDirectories = new Set( + [...collectThreadTextAttachmentRelativePaths(attachmentsRootDir, retainedMessages)].map( + (relativePath) => path.dirname(relativePath).replace(/\\/g, "/"), + ), + ); + const rootEntries = yield* fileSystem + .readDirectory(attachmentsRootDir, { recursive: false }) + .pipe(Effect.orElseSucceed(() => [] as Array)); + + for (const entry of rootEntries) { + const normalizedEntry = entry.replace(/^[/\\]+/, "").replace(/\\/g, "/"); + if (normalizedEntry === "text") { + const textRoot = path.join(attachmentsRootDir, normalizedEntry); + const textEntries = yield* fileSystem + .readDirectory(textRoot, { recursive: false }) + .pipe(Effect.orElseSucceed(() => [] as Array)); + for (const textEntry of textEntries) { + const normalizedTextEntry = textEntry.replace(/^[/\\]+/, "").replace(/\\/g, "/"); + if ( + normalizedTextEntry.includes("/") || + !GENERATED_TEXT_ATTACHMENT_DIRECTORY_PATTERN.test(normalizedTextEntry) + ) { + continue; + } + const relativeDirectory = `text/${normalizedTextEntry}`; + if (retainedTextDirectories.has(relativeDirectory)) { + continue; + } + const absoluteDirectory = path.join(textRoot, normalizedTextEntry); + const fileInfo = yield* fileSystem + .stat(absoluteDirectory) + .pipe(Effect.orElseSucceed(() => null)); + if (fileInfo?.type === "Directory") { + yield* fileSystem.remove(absoluteDirectory, { recursive: true, force: true }); + } + } + continue; + } + if (!isGeneratedImageAttachmentEntry(normalizedEntry)) { + continue; + } + const absolutePath = path.join(attachmentsRootDir, normalizedEntry); + const fileInfo = yield* fileSystem.stat(absolutePath).pipe(Effect.orElseSucceed(() => null)); + if (fileInfo?.type === "File" && !retainedImagePaths.has(normalizedEntry)) { + yield* fileSystem.remove(absolutePath, { force: true }); + } + } +}); + const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjectionPipeline")( function* () { const sql = yield* SqlClient.SqlClient; @@ -1623,63 +1706,6 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ), ); - const collectBootstrapAttachmentSideEffects = Effect.fn( - "collectBootstrapAttachmentSideEffects", - )(function* () { - const attachmentSideEffects = makeAttachmentSideEffects(); - const messageTextsByThread = new Map>(); - - yield* Stream.runForEach(eventStore.readFromSequence(0), (event) => - Effect.sync(() => { - if (event.type === "thread.message-sent") { - const messageTexts = - messageTextsByThread.get(event.payload.threadId) ?? new Map(); - const previousText = messageTexts.get(event.payload.messageId); - const nextText = - previousText === undefined - ? event.payload.text - : event.payload.streaming - ? `${previousText}${event.payload.text}` - : event.payload.text.length === 0 - ? previousText - : event.payload.text; - messageTexts.set(event.payload.messageId, nextText); - messageTextsByThread.set(event.payload.threadId, messageTexts); - return; - } - - if (event.type !== "thread.deleted" && event.type !== "thread.reverted") { - return; - } - if (event.type === "thread.deleted") { - attachmentSideEffects.deletedThreadIds.add(event.payload.threadId); - } else { - attachmentSideEffects.prunedThreadRelativePaths.set(event.payload.threadId, new Set()); - } - for (const text of messageTextsByThread.get(event.payload.threadId)?.values() ?? []) { - for (const relativePath of collectTextAttachmentRelativePaths({ - attachmentsDir: serverConfig.attachmentsDir, - text, - })) { - attachmentSideEffects.textAttachmentRelativePathsToRemove.add(relativePath); - } - } - }), - ); - - for (const threadId of attachmentSideEffects.prunedThreadRelativePaths.keys()) { - const messages = yield* projectionThreadMessageRepository.listByThreadId({ - threadId: ThreadId.make(threadId), - }); - attachmentSideEffects.prunedThreadRelativePaths.set( - threadId, - collectThreadAttachmentRelativePaths(threadId, messages), - ); - } - - return attachmentSideEffects; - }); - const projectEvent: OrchestrationProjectionPipelineShape["projectEvent"] = (event) => Effect.forEach(projectors, (projector) => runProjectorForEvent(projector, event, true), { concurrency: 1, @@ -1696,13 +1722,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti const bootstrap: OrchestrationProjectionPipelineShape["bootstrap"] = Effect.gen(function* () { yield* Effect.forEach(projectors, bootstrapProjector, { concurrency: 1 }); - const attachmentSideEffects = yield* collectBootstrapAttachmentSideEffects(); - yield* runAttachmentSideEffects( - attachmentSideEffects, - projectionThreadMessageRepository, - ).pipe( + yield* reconcileGeneratedAttachments(projectionThreadMessageRepository).pipe( Effect.catch((cause) => - Effect.logWarning("failed to reconcile projected attachment side-effects", { cause }), + Effect.logWarning("failed to reconcile generated attachments", { cause }), ), ); }).pipe( From 0570f05161d921e13e281e4cbacb258baa46f5b4 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 17:16:39 -0400 Subject: [PATCH 31/79] Clean up attachments after composer remounts --- apps/web/src/components/chat/ChatComposer.tsx | 14 ++----- .../src/deferredTextAttachmentCleanup.test.ts | 24 +++++++++++ apps/web/src/deferredTextAttachmentCleanup.ts | 31 ++++++++++++-- apps/web/src/textAttachmentPaths.test.ts | 41 +++++++------------ apps/web/src/textAttachmentPaths.ts | 10 +---- 5 files changed, 71 insertions(+), 49 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index c42e5a76a08..3098f434b79 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -88,7 +88,7 @@ import { import { ContextWindowMeter } from "./ContextWindowMeter"; import { buildExpandedImagePreview, type ExpandedImagePreview } from "./ExpandedImagePreview"; import { basenameOfPath } from "../../pierre-icons"; -import { removedOwnedTextAttachmentPaths, textAttachmentPaths } from "../../textAttachmentPaths"; +import { removedTextAttachmentPaths, textAttachmentPaths } from "../../textAttachmentPaths"; import { DeferredTextAttachmentCleanup, isTextAttachmentReferenced, @@ -932,13 +932,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const attachmentQueueRef = useRef>(Promise.resolve()); const composerAttachmentKeyRef = useRef(composerAttachmentKey); composerAttachmentKeyRef.current = composerAttachmentKey; - const ownedTextAttachmentPathsRef = useRef(new Set()); const textAttachmentCleanupRef = useRef(new DeferredTextAttachmentCleanup()); - const ownedTextAttachmentTargetKeyRef = useRef(composerAttachmentKey); - if (ownedTextAttachmentTargetKeyRef.current !== composerAttachmentKey) { - ownedTextAttachmentTargetKeyRef.current = composerAttachmentKey; - ownedTextAttachmentPathsRef.current = new Set(); - } // ------------------------------------------------------------------ // Derived: composer send state @@ -1193,15 +1187,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) for (const path of textAttachmentPaths(nextPrompt)) { textAttachmentCleanupRef.current.cancel(path); } - const ownedPaths = ownedTextAttachmentPathsRef.current; - const removedPaths = removedOwnedTextAttachmentPaths(previousPrompt, nextPrompt, ownedPaths); + const removedPaths = removedTextAttachmentPaths(previousPrompt, nextPrompt); for (const path of removedPaths) { textAttachmentCleanupRef.current.schedule(path, { isReferenced: () => isTextAttachmentReferenced(path, composerDraftPromptsEnvironment(environmentId)), deletePath: async () => { const result = await deleteTextAttachment({ environmentId, input: { path } }); - if (result._tag === "Success") ownedPaths.delete(path); + return result._tag === "Success"; }, }); } @@ -1850,7 +1843,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) if (result === null) return `'${file.name}' is not a supported text file.`; if (result._tag === "Failure") return `Could not attach '${file.name}'.`; - ownedTextAttachmentPathsRef.current.add(result.value.path); const currentPrompt = getComposerDraft(composerDraftTarget)?.prompt ?? ""; const separator = currentPrompt.length > 0 && !/\s$/.test(currentPrompt) ? " " : ""; const nextPrompt = `${currentPrompt}${separator}${serializeComposerFileLink( diff --git a/apps/web/src/deferredTextAttachmentCleanup.test.ts b/apps/web/src/deferredTextAttachmentCleanup.test.ts index ae5a8a628a2..068c14c21fb 100644 --- a/apps/web/src/deferredTextAttachmentCleanup.test.ts +++ b/apps/web/src/deferredTextAttachmentCleanup.test.ts @@ -24,6 +24,19 @@ describe("DeferredTextAttachmentCleanup", () => { expect(deletePath).not.toHaveBeenCalled(); }); + it("finishes a scheduled cleanup after the composer remounts", async () => { + vi.useFakeTimers(); + let cleanup = new DeferredTextAttachmentCleanup(1_000); + const deletePath = vi.fn(); + + cleanup.schedule(PATH, { isReferenced: () => false, deletePath }); + cleanup = new DeferredTextAttachmentCleanup(1_000); + await vi.advanceTimersByTimeAsync(1_000); + + expect(cleanup).toBeInstanceOf(DeferredTextAttachmentCleanup); + expect(deletePath).toHaveBeenCalledOnce(); + }); + it("rechecks retained drafts before deleting", async () => { vi.useFakeTimers(); const cleanup = new DeferredTextAttachmentCleanup(1_000); @@ -49,4 +62,15 @@ describe("DeferredTextAttachmentCleanup", () => { expect(deletePath).toHaveBeenCalledOnce(); }); + + it("retries a transient delete failure once", async () => { + vi.useFakeTimers(); + const cleanup = new DeferredTextAttachmentCleanup(1_000, 1); + const deletePath = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true); + + cleanup.schedule(PATH, { isReferenced: () => false, deletePath }); + await vi.advanceTimersByTimeAsync(2_000); + + expect(deletePath).toHaveBeenCalledTimes(2); + }); }); diff --git a/apps/web/src/deferredTextAttachmentCleanup.ts b/apps/web/src/deferredTextAttachmentCleanup.ts index 6245c20a89c..39bcde35482 100644 --- a/apps/web/src/deferredTextAttachmentCleanup.ts +++ b/apps/web/src/deferredTextAttachmentCleanup.ts @@ -6,23 +6,46 @@ export function isTextAttachmentReferenced(path: string, prompts: ReadonlyArray< export class DeferredTextAttachmentCleanup { readonly #delayMs: number; + readonly #maxRetries: number; readonly #pending = new Map>(); - constructor(delayMs = 1_000) { + constructor(delayMs = 1_000, maxRetries = 1) { this.#delayMs = delayMs; + this.#maxRetries = maxRetries; } schedule( path: string, options: { isReferenced: () => boolean; - deletePath: () => void | Promise; + deletePath: () => boolean | void | Promise; }, ): void { this.cancel(path); - const timeout = setTimeout(() => { + this.#scheduleAttempt(path, options, this.#maxRetries); + } + + #scheduleAttempt( + path: string, + options: { + isReferenced: () => boolean; + deletePath: () => boolean | void | Promise; + }, + retriesRemaining: number, + ): void { + const timeout = setTimeout(async () => { this.#pending.delete(path); - if (!options.isReferenced()) void options.deletePath(); + if (options.isReferenced()) return; + try { + const deleted = await options.deletePath(); + if (deleted === false && retriesRemaining > 0) { + this.#scheduleAttempt(path, options, retriesRemaining - 1); + } + } catch { + if (retriesRemaining > 0) { + this.#scheduleAttempt(path, options, retriesRemaining - 1); + } + } }, this.#delayMs); this.#pending.set(path, timeout); } diff --git a/apps/web/src/textAttachmentPaths.test.ts b/apps/web/src/textAttachmentPaths.test.ts index 4019f06dc2a..88b8e943cce 100644 --- a/apps/web/src/textAttachmentPaths.test.ts +++ b/apps/web/src/textAttachmentPaths.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import { - removedOwnedTextAttachmentPaths, + removedTextAttachmentPaths, textAttachmentPaths, unreferencedTextAttachmentPaths, } from "./textAttachmentPaths"; @@ -21,48 +21,37 @@ describe("textAttachmentPaths", () => { }); }); -describe("removedOwnedTextAttachmentPaths", () => { - it("collects an owned attachment after its generated link is removed", () => { - const ownedPath = +describe("removedTextAttachmentPaths", () => { + it("collects a generated attachment after its link is removed", () => { + const removedPath = "/var/t3-data/attachments/text/12345678-1234-1234-1234-123456789abc/notes.txt"; - const unrelatedPath = + const retainedPath = "/var/t3-data/attachments/text/87654321-4321-4321-4321-cba987654321/copied.txt"; expect( - removedOwnedTextAttachmentPaths( - `[notes.txt](${ownedPath}) [copied.txt](${unrelatedPath})`, - `[copied.txt](${unrelatedPath})`, - new Set([ownedPath]), + removedTextAttachmentPaths( + `[notes.txt](${removedPath}) [copied.txt](${retainedPath})`, + `[copied.txt](${retainedPath})`, ), - ).toEqual([ownedPath]); + ).toEqual([removedPath]); }); - it("keeps an owned attachment while its link remains", () => { + it("keeps a copied attachment while its link remains", () => { const path = "/var/t3-data/attachments/text/12345678-1234-1234-1234-123456789abc/notes.txt"; - expect( - removedOwnedTextAttachmentPaths(`[notes.txt](${path})`, `[notes](${path})`, new Set([path])), - ).toEqual([]); + expect(removedTextAttachmentPaths(`[notes.txt](${path})`, `[notes](${path})`)).toEqual([]); }); - it("detects removal when the previous attachment link ended at EOF", () => { + it("detects EOF removal after ownership state is lost on remount", () => { const path = "/var/t3-data/attachments/text/12345678-1234-1234-1234-123456789abc/notes.txt"; - expect(removedOwnedTextAttachmentPaths(`[notes.txt](${path})`, "", new Set([path]))).toEqual([ - path, - ]); + expect(removedTextAttachmentPaths(`[notes.txt](${path})`, "")).toEqual([path]); }); - it("preserves an owned attachment whose next link ends at EOF", () => { + it("preserves an attachment whose next link ends at EOF", () => { const path = "/var/t3-data/attachments/text/12345678-1234-1234-1234-123456789abc/notes.txt"; - expect( - removedOwnedTextAttachmentPaths( - `[notes.txt](${path}) `, - `[notes.txt](${path})`, - new Set([path]), - ), - ).toEqual([]); + expect(removedTextAttachmentPaths(`[notes.txt](${path}) `, `[notes.txt](${path})`)).toEqual([]); }); }); diff --git a/apps/web/src/textAttachmentPaths.ts b/apps/web/src/textAttachmentPaths.ts index 85c649bb0de..bb4de284ec1 100644 --- a/apps/web/src/textAttachmentPaths.ts +++ b/apps/web/src/textAttachmentPaths.ts @@ -31,15 +31,9 @@ export function textAttachmentPaths(prompt: string): string[] { return [...paths]; } -export function removedOwnedTextAttachmentPaths( - previousPrompt: string, - nextPrompt: string, - ownedPaths: ReadonlySet, -): string[] { +export function removedTextAttachmentPaths(previousPrompt: string, nextPrompt: string): string[] { const nextPaths = new Set(textAttachmentPaths(nextPrompt)); - return textAttachmentPaths(previousPrompt).filter( - (path) => ownedPaths.has(path) && !nextPaths.has(path), - ); + return textAttachmentPaths(previousPrompt).filter((path) => !nextPaths.has(path)); } export function unreferencedTextAttachmentPaths( From 5a7c007b6adef31d0a2bcf70d3df9e723484787f Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 17:26:29 -0400 Subject: [PATCH 32/79] Persist text attachment draft claims --- apps/server/src/attachmentStore.test.ts | 92 ++++++++++++ apps/server/src/attachmentStore.ts | 133 ++++++++++++++++++ .../Layers/ProjectionPipeline.test.ts | 10 +- .../Layers/ProjectionPipeline.ts | 74 +++++----- apps/server/src/ws.ts | 84 +++++------ packages/client-runtime/src/state/assets.ts | 10 +- packages/contracts/src/assets.ts | 43 ++++-- packages/contracts/src/ipc.ts | 15 +- packages/contracts/src/rpc.ts | 29 ++-- 9 files changed, 378 insertions(+), 112 deletions(-) diff --git a/apps/server/src/attachmentStore.test.ts b/apps/server/src/attachmentStore.test.ts index 32ffc334aa0..f69f70ad6ce 100644 --- a/apps/server/src/attachmentStore.test.ts +++ b/apps/server/src/attachmentStore.test.ts @@ -7,10 +7,14 @@ import { describe, expect, it } from "vite-plus/test"; import { collectTextAttachmentRelativePaths, + claimTextAttachment, createAttachmentId, createTextAttachmentPath, parseThreadSegmentFromAttachmentId, + reconcileTextAttachments, + releaseTextAttachment, resolveAttachmentPathById, + TEXT_ATTACHMENT_DELETE_GRACE_MS, textAttachmentDirectory, } from "./attachmentStore.ts"; @@ -125,4 +129,92 @@ describe("attachmentStore", () => { textAttachmentDirectory({ attachmentsDir, path: NodePath.join(attachmentsDir, "../nope") }), ).toBeNull(); }); + + it("persists draft claims across reconciliation and restart-style reloads", () => { + const attachmentsDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3code-text-claims-"), + ); + try { + const attachmentPath = createTextAttachmentPath({ attachmentsDir, fileName: "draft.md" }); + NodeFS.mkdirSync(NodePath.dirname(attachmentPath), { recursive: true }); + NodeFS.writeFileSync(attachmentPath, "draft"); + expect( + claimTextAttachment({ + attachmentsDir, + path: attachmentPath, + draftOwnerId: "draft-owner", + }), + ).toBe(true); + + reconcileTextAttachments({ + attachmentsDir, + retainedRelativePaths: new Set(), + nowMs: TEXT_ATTACHMENT_DELETE_GRACE_MS * 10, + }); + + expect(NodeFS.existsSync(attachmentPath)).toBe(true); + } finally { + NodeFS.rmSync(attachmentsDir, { recursive: true, force: true }); + } + }); + + it("cancels pending deletion when a copied draft reclaims an attachment", () => { + const attachmentsDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3code-text-reclaim-"), + ); + try { + const attachmentPath = createTextAttachmentPath({ attachmentsDir, fileName: "draft.md" }); + NodeFS.mkdirSync(NodePath.dirname(attachmentPath), { recursive: true }); + NodeFS.writeFileSync(attachmentPath, "draft"); + claimTextAttachment({ attachmentsDir, path: attachmentPath, draftOwnerId: "original" }); + expect( + releaseTextAttachment({ + attachmentsDir, + path: attachmentPath, + draftOwnerId: "original", + nowMs: 1_000, + }), + ).toBe(true); + expect( + claimTextAttachment({ attachmentsDir, path: attachmentPath, draftOwnerId: "copy" }), + ).toBe(true); + + reconcileTextAttachments({ + attachmentsDir, + retainedRelativePaths: new Set(), + nowMs: 1_000 + TEXT_ATTACHMENT_DELETE_GRACE_MS + 1, + }); + + expect(NodeFS.existsSync(attachmentPath)).toBe(true); + } finally { + NodeFS.rmSync(attachmentsDir, { recursive: true, force: true }); + } + }); + + it("deletes only expired unclaimed and unreferenced text attachments", () => { + const attachmentsDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3code-text-expiry-"), + ); + try { + const attachmentPath = createTextAttachmentPath({ attachmentsDir, fileName: "orphan.md" }); + NodeFS.mkdirSync(NodePath.dirname(attachmentPath), { recursive: true }); + NodeFS.writeFileSync(attachmentPath, "orphan"); + + reconcileTextAttachments({ + attachmentsDir, + retainedRelativePaths: new Set(), + nowMs: 1_000, + }); + expect(NodeFS.existsSync(attachmentPath)).toBe(true); + + reconcileTextAttachments({ + attachmentsDir, + retainedRelativePaths: new Set(), + nowMs: 1_000 + TEXT_ATTACHMENT_DELETE_GRACE_MS + 1, + }); + expect(NodeFS.existsSync(attachmentPath)).toBe(false); + } finally { + NodeFS.rmSync(attachmentsDir, { recursive: true, force: true }); + } + }); }); diff --git a/apps/server/src/attachmentStore.ts b/apps/server/src/attachmentStore.ts index 6f62dd5365c..d71c3d20b72 100644 --- a/apps/server/src/attachmentStore.ts +++ b/apps/server/src/attachmentStore.ts @@ -13,6 +13,8 @@ import { inferImageExtension, SAFE_IMAGE_FILE_EXTENSIONS } from "./imageMime.ts" const ATTACHMENT_FILENAME_EXTENSIONS = [...SAFE_IMAGE_FILE_EXTENSIONS, ".bin"]; const TEXT_ATTACHMENT_DIRECTORY = "text"; +const TEXT_ATTACHMENT_METADATA_FILE = ".t3-attachment.json"; +export const TEXT_ATTACHMENT_DELETE_GRACE_MS = 60_000; const TEXT_ATTACHMENT_FILE_NAME_MAX_CHARS = 120; const WINDOWS_RESERVED_FILE_NAME_PATTERN = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i; const ATTACHMENT_ID_THREAD_SEGMENT_MAX_CHARS = 80; @@ -185,3 +187,134 @@ export function collectTextAttachmentRelativePaths(input: { } return paths; } + +interface TextAttachmentMetadata { + readonly version: 1; + readonly claims: ReadonlyArray; + readonly deleteAfter: number | null; +} + +const emptyTextAttachmentMetadata = (): TextAttachmentMetadata => ({ + version: 1, + claims: [], + deleteAfter: null, +}); + +function readTextAttachmentMetadata(directory: string): TextAttachmentMetadata { + try { + const parsed = JSON.parse( + NodeFS.readFileSync(NodePath.join(directory, TEXT_ATTACHMENT_METADATA_FILE), "utf8"), + ) as Partial; + if (parsed.version !== 1 || !Array.isArray(parsed.claims)) { + return emptyTextAttachmentMetadata(); + } + return { + version: 1, + claims: [ + ...new Set(parsed.claims.filter((claim): claim is string => typeof claim === "string")), + ], + deleteAfter: typeof parsed.deleteAfter === "number" ? parsed.deleteAfter : null, + }; + } catch { + return emptyTextAttachmentMetadata(); + } +} + +function writeTextAttachmentMetadata(directory: string, metadata: TextAttachmentMetadata): void { + const metadataPath = NodePath.join(directory, TEXT_ATTACHMENT_METADATA_FILE); + const temporaryPath = `${metadataPath}.${NodeCrypto.randomUUID()}.tmp`; + NodeFS.writeFileSync(temporaryPath, JSON.stringify(metadata)); + NodeFS.renameSync(temporaryPath, metadataPath); +} + +export function claimTextAttachment(input: { + readonly attachmentsDir: string; + readonly path: string; + readonly draftOwnerId: string; +}): boolean { + const directory = textAttachmentDirectory(input); + if (!directory || !NodeFS.existsSync(input.path)) return false; + const metadata = readTextAttachmentMetadata(directory); + writeTextAttachmentMetadata(directory, { + version: 1, + claims: [...new Set([...metadata.claims, input.draftOwnerId])], + deleteAfter: null, + }); + return true; +} + +export function releaseTextAttachment(input: { + readonly attachmentsDir: string; + readonly path: string; + readonly draftOwnerId: string; + readonly nowMs?: number; +}): boolean { + const directory = textAttachmentDirectory(input); + if (!directory || !NodeFS.existsSync(input.path)) return false; + const metadata = readTextAttachmentMetadata(directory); + if (!metadata.claims.includes(input.draftOwnerId)) return false; + const claims = metadata.claims.filter((claim) => claim !== input.draftOwnerId); + writeTextAttachmentMetadata(directory, { + version: 1, + claims, + deleteAfter: + claims.length === 0 ? (input.nowMs ?? Date.now()) + TEXT_ATTACHMENT_DELETE_GRACE_MS : null, + }); + return true; +} + +export function reconcileTextAttachments(input: { + readonly attachmentsDir: string; + readonly retainedRelativePaths: ReadonlySet; + readonly nowMs?: number; +}): { readonly pending: number; readonly removed: number } { + const textRoot = NodePath.join(input.attachmentsDir, TEXT_ATTACHMENT_DIRECTORY); + let pending = 0; + let removed = 0; + let entries: ReadonlyArray; + try { + entries = NodeFS.readdirSync(textRoot); + } catch { + return { pending, removed }; + } + const retainedDirectories = new Set( + [...input.retainedRelativePaths].map((relativePath) => NodePath.dirname(relativePath)), + ); + const nowMs = input.nowMs ?? Date.now(); + for (const entry of entries) { + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(entry)) { + continue; + } + const directory = NodePath.join(textRoot, entry); + let stat: NodeFS.Stats; + try { + stat = NodeFS.statSync(directory); + } catch { + continue; + } + if (!stat.isDirectory()) continue; + const metadata = readTextAttachmentMetadata(directory); + const relativeDirectory = `${TEXT_ATTACHMENT_DIRECTORY}/${entry}`; + if (retainedDirectories.has(relativeDirectory) || metadata.claims.length > 0) { + if (metadata.deleteAfter !== null) { + writeTextAttachmentMetadata(directory, { ...metadata, deleteAfter: null }); + } + continue; + } + if (metadata.deleteAfter === null) { + writeTextAttachmentMetadata(directory, { + ...metadata, + deleteAfter: nowMs + TEXT_ATTACHMENT_DELETE_GRACE_MS, + }); + pending += 1; + continue; + } + if (metadata.deleteAfter > nowMs) { + pending += 1; + continue; + } + NodeFS.rmSync(directory, { recursive: true, force: true }); + removed += 1; + } + return { pending, removed }; +} diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 08355ef6f4b..c33595d72e7 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -943,7 +943,7 @@ it.layer( assert.isTrue(yield* exists(keepPath)); assert.isFalse(yield* exists(removePath)); - assert.isFalse(yield* exists(removeTextAttachmentPath)); + assert.isTrue(yield* exists(removeTextAttachmentPath)); assert.isFalse(yield* exists(otherThreadPath)); }), ); @@ -1097,7 +1097,7 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta assert.isFalse(yield* exists(threadAttachmentPath)); assert.isTrue(yield* exists(otherThreadAttachmentPath)); - assert.isFalse(yield* exists(textAttachmentPath)); + assert.isTrue(yield* exists(textAttachmentPath)); assert.isTrue(yield* exists(unrelatedTextAttachmentPath)); yield* fileSystem.writeFileString(threadAttachmentPath, "delete after restart"); @@ -1108,8 +1108,8 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta assert.isFalse(yield* exists(threadAttachmentPath)); assert.isFalse(yield* exists(otherThreadAttachmentPath)); - assert.isFalse(yield* exists(textAttachmentPath)); - assert.isFalse(yield* exists(unrelatedTextAttachmentPath)); + assert.isTrue(yield* exists(textAttachmentPath)); + assert.isTrue(yield* exists(unrelatedTextAttachmentPath)); }), ); }, @@ -1264,7 +1264,7 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta yield* projectionPipeline.bootstrap; assert.isTrue(yield* exists(textAttachmentPath)); - assert.isFalse(yield* exists(deletedTextAttachmentPath)); + assert.isTrue(yield* exists(deletedTextAttachmentPath)); assert.isFalse(yield* exists(deletedImageAttachmentPath)); }), ); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 7bdb40409c6..4e1d4de1e2c 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -54,6 +54,7 @@ import { collectTextAttachmentRelativePaths, parseAttachmentIdFromRelativePath, parseThreadSegmentFromAttachmentId, + reconcileTextAttachments, toSafeThreadAttachmentSegment, } from "../../attachmentStore.ts"; @@ -501,23 +502,16 @@ const runAttachmentSideEffects = Effect.fn("runAttachmentSideEffects")(function* (message) => !sideEffects.deletedThreadIds.has(message.threadId), ), ); - yield* Effect.forEach( - [...sideEffects.textAttachmentRelativePathsToRemove].filter( - (relativePath) => !retainedTextAttachmentPaths.has(relativePath), - ), - (relativePath) => - fileSystem.remove(path.join(attachmentsRootDir, path.dirname(relativePath)), { - recursive: true, - force: true, - }), - { concurrency: 1 }, + yield* Effect.sync(() => + reconcileTextAttachments({ + attachmentsDir: attachmentsRootDir, + retainedRelativePaths: retainedTextAttachmentPaths, + }), ); } }); const GENERATED_IMAGE_ATTACHMENT_EXTENSIONS = new Set([...SAFE_IMAGE_FILE_EXTENSIONS, ".bin"]); -const GENERATED_TEXT_ATTACHMENT_DIRECTORY_PATTERN = - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; function isGeneratedImageAttachmentEntry(entry: string): boolean { const normalizedEntry = entry.replace(/^[/\\]+/, "").replace(/\\/g, "/"); @@ -548,10 +542,9 @@ const reconcileGeneratedAttachments = Effect.fn("reconcileGeneratedAttachments") } } } - const retainedTextDirectories = new Set( - [...collectThreadTextAttachmentRelativePaths(attachmentsRootDir, retainedMessages)].map( - (relativePath) => path.dirname(relativePath).replace(/\\/g, "/"), - ), + const retainedTextAttachmentPaths = collectThreadTextAttachmentRelativePaths( + attachmentsRootDir, + retainedMessages, ); const rootEntries = yield* fileSystem .readDirectory(attachmentsRootDir, { recursive: false }) @@ -560,30 +553,6 @@ const reconcileGeneratedAttachments = Effect.fn("reconcileGeneratedAttachments") for (const entry of rootEntries) { const normalizedEntry = entry.replace(/^[/\\]+/, "").replace(/\\/g, "/"); if (normalizedEntry === "text") { - const textRoot = path.join(attachmentsRootDir, normalizedEntry); - const textEntries = yield* fileSystem - .readDirectory(textRoot, { recursive: false }) - .pipe(Effect.orElseSucceed(() => [] as Array)); - for (const textEntry of textEntries) { - const normalizedTextEntry = textEntry.replace(/^[/\\]+/, "").replace(/\\/g, "/"); - if ( - normalizedTextEntry.includes("/") || - !GENERATED_TEXT_ATTACHMENT_DIRECTORY_PATTERN.test(normalizedTextEntry) - ) { - continue; - } - const relativeDirectory = `text/${normalizedTextEntry}`; - if (retainedTextDirectories.has(relativeDirectory)) { - continue; - } - const absoluteDirectory = path.join(textRoot, normalizedTextEntry); - const fileInfo = yield* fileSystem - .stat(absoluteDirectory) - .pipe(Effect.orElseSucceed(() => null)); - if (fileInfo?.type === "Directory") { - yield* fileSystem.remove(absoluteDirectory, { recursive: true, force: true }); - } - } continue; } if (!isGeneratedImageAttachmentEntry(normalizedEntry)) { @@ -595,6 +564,12 @@ const reconcileGeneratedAttachments = Effect.fn("reconcileGeneratedAttachments") yield* fileSystem.remove(absolutePath, { force: true }); } } + yield* Effect.sync(() => + reconcileTextAttachments({ + attachmentsDir: attachmentsRootDir, + retainedRelativePaths: retainedTextAttachmentPaths, + }), + ); }); const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjectionPipeline")( @@ -615,6 +590,25 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti const path = yield* Path.Path; const serverConfig = yield* ServerConfig; + const reconcileTextAttachmentStore = Effect.gen(function* () { + const retainedMessages = yield* projectionThreadMessageRepository.listRetained(); + const retainedRelativePaths = collectThreadTextAttachmentRelativePaths( + serverConfig.attachmentsDir, + retainedMessages, + ); + yield* Effect.sync(() => + reconcileTextAttachments({ + attachmentsDir: serverConfig.attachmentsDir, + retainedRelativePaths, + }), + ); + }).pipe( + Effect.catch((cause) => Effect.logWarning("failed to reconcile text attachments", { cause })), + ); + yield* Effect.forkScoped( + Effect.sleep("30 seconds").pipe(Effect.andThen(reconcileTextAttachmentStore), Effect.forever), + ); + const applyProjectsProjection: ProjectorDefinition["apply"] = Effect.fn( "applyProjectsProjection", )(function* (event, _attachmentSideEffects) { diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 4b6ff550b13..92fa345b083 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -53,7 +53,8 @@ import { AssetWorkspaceContextNotFoundError, AssetWorkspaceContextResolutionError, AssetTextAttachmentWriteError, - AssetTextAttachmentDeleteError, + AssetTextAttachmentClaimError, + AssetTextAttachmentReleaseError, EnvironmentAuthorizationError, ThreadId, type TerminalAttachStreamEvent, @@ -89,10 +90,9 @@ import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; import * as PreviewManager from "./preview/Manager.ts"; import { issueAssetUrl } from "./assets/AssetAccess.ts"; import { - collectTextAttachmentRelativePaths, + claimTextAttachment, createTextAttachmentPath, - textAttachmentDirectory, - textAttachmentRelativePath, + releaseTextAttachment, } from "./attachmentStore.ts"; import * as PortScanner from "./preview/PortScanner.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; @@ -319,7 +319,8 @@ const RPC_REQUIRED_SCOPE = new Map([ [WS_METHODS.filesystemBrowse, AuthOrchestrationReadScope], [WS_METHODS.assetsCreateUrl, AuthOrchestrationReadScope], [WS_METHODS.assetsWriteTextAttachment, AuthOrchestrationOperateScope], - [WS_METHODS.assetsDeleteTextAttachment, AuthOrchestrationOperateScope], + [WS_METHODS.assetsClaimTextAttachment, AuthOrchestrationOperateScope], + [WS_METHODS.assetsReleaseTextAttachment, AuthOrchestrationOperateScope], [WS_METHODS.subscribeVcsStatus, AuthOrchestrationReadScope], [WS_METHODS.vcsRefreshStatus, AuthOrchestrationReadScope], [WS_METHODS.vcsPull, AuthOrchestrationOperateScope], @@ -1553,48 +1554,47 @@ const makeWsRpcLayer = ( }), ), ); + yield* Effect.try({ + try: () => + claimTextAttachment({ + attachmentsDir: config.attachmentsDir, + path: attachmentPath, + draftOwnerId: input.draftOwnerId, + }), + catch: (cause) => + new AssetTextAttachmentWriteError({ fileName: input.fileName, cause }), + }); return { path: attachmentPath }; }), { "rpc.aggregate": "workspace" }, ), - [WS_METHODS.assetsDeleteTextAttachment]: (input) => + [WS_METHODS.assetsClaimTextAttachment]: (input) => observeRpcEffect( - WS_METHODS.assetsDeleteTextAttachment, - Effect.gen(function* () { - const directory = textAttachmentDirectory({ - attachmentsDir: config.attachmentsDir, - path: input.path, - }); - if (!directory) return { removed: false }; - const relativePath = textAttachmentRelativePath({ - attachmentsDir: config.attachmentsDir, - path: input.path, - }); - if (!relativePath) return { removed: false }; - const retainedSnapshot = yield* projectionSnapshotQuery - .getSnapshot() - .pipe( - Effect.mapError( - (cause) => new AssetTextAttachmentDeleteError({ path: input.path, cause }), - ), - ); - const isDurable = retainedSnapshot.threads.some((thread) => - thread.messages.some((message) => - collectTextAttachmentRelativePaths({ - attachmentsDir: config.attachmentsDir, - text: message.text, - }).has(relativePath), - ), - ); - if (isDurable) return { removed: false }; - yield* fileSystem - .remove(directory, { recursive: true, force: true }) - .pipe( - Effect.mapError( - (cause) => new AssetTextAttachmentDeleteError({ path: input.path, cause }), - ), - ); - return { removed: true }; + WS_METHODS.assetsClaimTextAttachment, + Effect.try({ + try: () => ({ + claimed: claimTextAttachment({ + attachmentsDir: config.attachmentsDir, + path: input.path, + draftOwnerId: input.draftOwnerId, + }), + }), + catch: (cause) => new AssetTextAttachmentClaimError({ path: input.path, cause }), + }), + { "rpc.aggregate": "workspace" }, + ), + [WS_METHODS.assetsReleaseTextAttachment]: (input) => + observeRpcEffect( + WS_METHODS.assetsReleaseTextAttachment, + Effect.try({ + try: () => ({ + released: releaseTextAttachment({ + attachmentsDir: config.attachmentsDir, + path: input.path, + draftOwnerId: input.draftOwnerId, + }), + }), + catch: (cause) => new AssetTextAttachmentReleaseError({ path: input.path, cause }), }), { "rpc.aggregate": "workspace" }, ), diff --git a/packages/client-runtime/src/state/assets.ts b/packages/client-runtime/src/state/assets.ts index bfb8f912c22..cdc2cff38cf 100644 --- a/packages/client-runtime/src/state/assets.ts +++ b/packages/client-runtime/src/state/assets.ts @@ -76,9 +76,13 @@ export function createAssetEnvironmentAtoms( label: "environment-data:assets:write-text-attachment", tag: WS_METHODS.assetsWriteTextAttachment, }), - deleteTextAttachment: createEnvironmentRpcCommand(runtime, { - label: "environment-data:assets:delete-text-attachment", - tag: WS_METHODS.assetsDeleteTextAttachment, + claimTextAttachment: createEnvironmentRpcCommand(runtime, { + label: "environment-data:assets:claim-text-attachment", + tag: WS_METHODS.assetsClaimTextAttachment, + }), + releaseTextAttachment: createEnvironmentRpcCommand(runtime, { + label: "environment-data:assets:release-text-attachment", + tag: WS_METHODS.assetsReleaseTextAttachment, }), createUrls: (target: { readonly environmentId: EnvironmentId; diff --git a/packages/contracts/src/assets.ts b/packages/contracts/src/assets.ts index 063742836ce..34323491bf1 100644 --- a/packages/contracts/src/assets.ts +++ b/packages/contracts/src/assets.ts @@ -4,6 +4,11 @@ import { ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; const ASSET_PATH_MAX_LENGTH = 1024; const TEXT_ATTACHMENT_CONTENT_MAX_LENGTH = 1024 * 1024; +const TEXT_ATTACHMENT_DRAFT_OWNER_ID_MAX_LENGTH = 256; + +const TextAttachmentDraftOwnerId = TrimmedNonEmptyString.check( + Schema.isMaxLength(TEXT_ATTACHMENT_DRAFT_OWNER_ID_MAX_LENGTH), +); export const AssetResource = Schema.Union([ Schema.TaggedStruct("workspace-file", { @@ -33,6 +38,7 @@ export type AssetCreateUrlResult = typeof AssetCreateUrlResult.Type; export const AssetWriteTextAttachmentInput = Schema.Struct({ fileName: TrimmedNonEmptyString.check(Schema.isMaxLength(256)), contents: Schema.String.check(Schema.isMaxLength(TEXT_ATTACHMENT_CONTENT_MAX_LENGTH)), + draftOwnerId: TextAttachmentDraftOwnerId, }); export type AssetWriteTextAttachmentInput = typeof AssetWriteTextAttachmentInput.Type; @@ -41,15 +47,24 @@ export const AssetWriteTextAttachmentResult = Schema.Struct({ }); export type AssetWriteTextAttachmentResult = typeof AssetWriteTextAttachmentResult.Type; -export const AssetDeleteTextAttachmentInput = Schema.Struct({ +export const AssetClaimTextAttachmentInput = Schema.Struct({ path: TrimmedNonEmptyString.check(Schema.isMaxLength(4096)), + draftOwnerId: TextAttachmentDraftOwnerId, +}); +export type AssetClaimTextAttachmentInput = typeof AssetClaimTextAttachmentInput.Type; + +export const AssetClaimTextAttachmentResult = Schema.Struct({ + claimed: Schema.Boolean, }); -export type AssetDeleteTextAttachmentInput = typeof AssetDeleteTextAttachmentInput.Type; +export type AssetClaimTextAttachmentResult = typeof AssetClaimTextAttachmentResult.Type; + +export const AssetReleaseTextAttachmentInput = AssetClaimTextAttachmentInput; +export type AssetReleaseTextAttachmentInput = typeof AssetReleaseTextAttachmentInput.Type; -export const AssetDeleteTextAttachmentResult = Schema.Struct({ - removed: Schema.Boolean, +export const AssetReleaseTextAttachmentResult = Schema.Struct({ + released: Schema.Boolean, }); -export type AssetDeleteTextAttachmentResult = typeof AssetDeleteTextAttachmentResult.Type; +export type AssetReleaseTextAttachmentResult = typeof AssetReleaseTextAttachmentResult.Type; export class AssetTextAttachmentWriteError extends Schema.TaggedErrorClass()( "AssetTextAttachmentWriteError", @@ -63,15 +78,27 @@ export class AssetTextAttachmentWriteError extends Schema.TaggedErrorClass()( - "AssetTextAttachmentDeleteError", +export class AssetTextAttachmentClaimError extends Schema.TaggedErrorClass()( + "AssetTextAttachmentClaimError", + { + path: TrimmedNonEmptyString, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to claim text attachment."; + } +} + +export class AssetTextAttachmentReleaseError extends Schema.TaggedErrorClass()( + "AssetTextAttachmentReleaseError", { path: TrimmedNonEmptyString, cause: Schema.Defect(), }, ) { override get message(): string { - return "Failed to delete text attachment."; + return "Failed to release text attachment."; } } diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 86da628dff6..9d86e4ae09b 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -23,8 +23,10 @@ import type { FilesystemBrowseInput, FilesystemBrowseResult } from "./filesystem import type { AssetCreateUrlInput, AssetCreateUrlResult, - AssetDeleteTextAttachmentInput, - AssetDeleteTextAttachmentResult, + AssetClaimTextAttachmentInput, + AssetClaimTextAttachmentResult, + AssetReleaseTextAttachmentInput, + AssetReleaseTextAttachmentResult, AssetWriteTextAttachmentInput, AssetWriteTextAttachmentResult, } from "./assets.ts"; @@ -1175,9 +1177,12 @@ export interface EnvironmentApi { writeTextAttachment: ( input: AssetWriteTextAttachmentInput, ) => Promise; - deleteTextAttachment: ( - input: AssetDeleteTextAttachmentInput, - ) => Promise; + claimTextAttachment: ( + input: AssetClaimTextAttachmentInput, + ) => Promise; + releaseTextAttachment: ( + input: AssetReleaseTextAttachmentInput, + ) => Promise; }; sourceControl: { lookupRepository: ( diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 8190fdfb6ac..15d60e40a4d 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -17,9 +17,12 @@ import { AssetAccessError, AssetCreateUrlInput, AssetCreateUrlResult, - AssetDeleteTextAttachmentInput, - AssetDeleteTextAttachmentResult, - AssetTextAttachmentDeleteError, + AssetClaimTextAttachmentInput, + AssetClaimTextAttachmentResult, + AssetReleaseTextAttachmentInput, + AssetReleaseTextAttachmentResult, + AssetTextAttachmentClaimError, + AssetTextAttachmentReleaseError, AssetTextAttachmentWriteError, AssetWriteTextAttachmentInput, AssetWriteTextAttachmentResult, @@ -171,7 +174,8 @@ export const WS_METHODS = { filesystemBrowse: "filesystem.browse", assetsCreateUrl: "assets.createUrl", assetsWriteTextAttachment: "assets.writeTextAttachment", - assetsDeleteTextAttachment: "assets.deleteTextAttachment", + assetsClaimTextAttachment: "assets.claimTextAttachment", + assetsReleaseTextAttachment: "assets.releaseTextAttachment", // VCS methods vcsPull: "vcs.pull", @@ -413,10 +417,16 @@ export const WsAssetsWriteTextAttachmentRpc = Rpc.make(WS_METHODS.assetsWriteTex error: Schema.Union([AssetTextAttachmentWriteError, EnvironmentAuthorizationError]), }); -export const WsAssetsDeleteTextAttachmentRpc = Rpc.make(WS_METHODS.assetsDeleteTextAttachment, { - payload: AssetDeleteTextAttachmentInput, - success: AssetDeleteTextAttachmentResult, - error: Schema.Union([AssetTextAttachmentDeleteError, EnvironmentAuthorizationError]), +export const WsAssetsClaimTextAttachmentRpc = Rpc.make(WS_METHODS.assetsClaimTextAttachment, { + payload: AssetClaimTextAttachmentInput, + success: AssetClaimTextAttachmentResult, + error: Schema.Union([AssetTextAttachmentClaimError, EnvironmentAuthorizationError]), +}); + +export const WsAssetsReleaseTextAttachmentRpc = Rpc.make(WS_METHODS.assetsReleaseTextAttachment, { + payload: AssetReleaseTextAttachmentInput, + success: AssetReleaseTextAttachmentResult, + error: Schema.Union([AssetTextAttachmentReleaseError, EnvironmentAuthorizationError]), }); export const WsSubscribeVcsStatusRpc = Rpc.make(WS_METHODS.subscribeVcsStatus, { @@ -731,7 +741,8 @@ export const WsRpcGroup = RpcGroup.make( WsFilesystemBrowseRpc, WsAssetsCreateUrlRpc, WsAssetsWriteTextAttachmentRpc, - WsAssetsDeleteTextAttachmentRpc, + WsAssetsClaimTextAttachmentRpc, + WsAssetsReleaseTextAttachmentRpc, WsSubscribeVcsStatusRpc, WsVcsPullRpc, WsVcsRefreshStatusRpc, From 03c707d3c7d086c65ac39ce87442c7126ea39556 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 17:27:26 -0400 Subject: [PATCH 33/79] Cover claimed drafts during projection cleanup --- .../Layers/ProjectionPipeline.test.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index c33595d72e7..88df461a316 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -34,6 +34,12 @@ import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQu import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts"; import { ServerConfig } from "../../config.ts"; +import { + claimTextAttachment, + reconcileTextAttachments, + textAttachmentRelativePath, + TEXT_ATTACHMENT_DELETE_GRACE_MS, +} from "../../attachmentStore.ts"; const makeProjectionPipelinePrefixedTestLayer = (prefix: string) => OrchestrationProjectionPipelineLive.pipe( @@ -938,8 +944,18 @@ it.layer( recursive: true, }); yield* fileSystem.writeFileString(removeTextAttachmentPath, "remove after restart"); + claimTextAttachment({ + attachmentsDir, + path: removeTextAttachmentPath, + draftOwnerId: "copied-revert-draft", + }); yield* projectionPipeline.bootstrap; + reconcileTextAttachments({ + attachmentsDir, + retainedRelativePaths: new Set(), + nowMs: Date.now() + TEXT_ATTACHMENT_DELETE_GRACE_MS + 1, + }); assert.isTrue(yield* exists(keepPath)); assert.isFalse(yield* exists(removePath)); @@ -1260,8 +1276,23 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta yield* fileSystem.writeFileString(textAttachmentPath, "shared attachment"); yield* fileSystem.writeFileString(deletedTextAttachmentPath, "deleted attachment"); yield* fileSystem.writeFileString(deletedImageAttachmentPath, "deleted image"); + claimTextAttachment({ + attachmentsDir, + path: deletedTextAttachmentPath, + draftOwnerId: "copied-delete-draft", + }); yield* projectionPipeline.bootstrap; + const retainedSharedPath = textAttachmentRelativePath({ + attachmentsDir, + path: textAttachmentPath, + }); + assert.isNotNull(retainedSharedPath); + reconcileTextAttachments({ + attachmentsDir, + retainedRelativePaths: new Set(retainedSharedPath ? [retainedSharedPath] : []), + nowMs: Date.now() + TEXT_ATTACHMENT_DELETE_GRACE_MS + 1, + }); assert.isTrue(yield* exists(textAttachmentPath)); assert.isTrue(yield* exists(deletedTextAttachmentPath)); From 11357b9f288529e7d00fa12f5f237d2702e1272d Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 17:28:11 -0400 Subject: [PATCH 34/79] Mark deleted thread attachments after projection --- apps/server/src/orchestration/Layers/ProjectionPipeline.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 4e1d4de1e2c..9a341cbde7c 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -495,7 +495,10 @@ const runAttachmentSideEffects = Effect.fn("runAttachmentSideEffects")(function* { concurrency: 1 }, ); - if (sideEffects.textAttachmentRelativePathsToRemove.size > 0) { + if ( + sideEffects.textAttachmentRelativePathsToRemove.size > 0 || + sideEffects.deletedThreadIds.size > 0 + ) { const retainedTextAttachmentPaths = collectThreadTextAttachmentRelativePaths( attachmentsRootDir, (yield* projectionThreadMessageRepository.listRetained()).filter( From 1fa960c5c27d2520831c71ed9494461b5a782001 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 17:30:27 -0400 Subject: [PATCH 35/79] Delay attachment sweeps until projections bootstrap --- .../src/orchestration/Layers/ProjectionPipeline.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 9a341cbde7c..09ac7a4bb71 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -10,6 +10,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import * as SqlClient from "effect/unstable/sql/SqlClient"; @@ -592,6 +593,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const serverConfig = yield* ServerConfig; + const bootstrapComplete = yield* Ref.make(false); const reconcileTextAttachmentStore = Effect.gen(function* () { const retainedMessages = yield* projectionThreadMessageRepository.listRetained(); @@ -609,7 +611,16 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti Effect.catch((cause) => Effect.logWarning("failed to reconcile text attachments", { cause })), ); yield* Effect.forkScoped( - Effect.sleep("30 seconds").pipe(Effect.andThen(reconcileTextAttachmentStore), Effect.forever), + Effect.sleep("30 seconds").pipe( + Effect.andThen( + Ref.get(bootstrapComplete).pipe( + Effect.flatMap((isComplete) => + isComplete ? reconcileTextAttachmentStore : Effect.void, + ), + ), + ), + Effect.forever, + ), ); const applyProjectsProjection: ProjectorDefinition["apply"] = Effect.fn( @@ -1719,6 +1730,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti const bootstrap: OrchestrationProjectionPipelineShape["bootstrap"] = Effect.gen(function* () { yield* Effect.forEach(projectors, bootstrapProjector, { concurrency: 1 }); + yield* Ref.set(bootstrapComplete, true); yield* reconcileGeneratedAttachments(projectionThreadMessageRepository).pipe( Effect.catch((cause) => Effect.logWarning("failed to reconcile generated attachments", { cause }), From 78480818a035a794148a7ce90ca2387f40ba9020 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 17:33:05 -0400 Subject: [PATCH 36/79] Use the Effect clock for attachment expiry --- apps/server/src/attachmentStore.ts | 9 ++++---- .../Layers/ProjectionPipeline.test.ts | 5 ++--- .../Layers/ProjectionPipeline.ts | 7 +++++++ apps/server/src/ws.ts | 21 ++++++++++++------- 4 files changed, 26 insertions(+), 16 deletions(-) diff --git a/apps/server/src/attachmentStore.ts b/apps/server/src/attachmentStore.ts index d71c3d20b72..b6a8c3a916a 100644 --- a/apps/server/src/attachmentStore.ts +++ b/apps/server/src/attachmentStore.ts @@ -247,7 +247,7 @@ export function releaseTextAttachment(input: { readonly attachmentsDir: string; readonly path: string; readonly draftOwnerId: string; - readonly nowMs?: number; + readonly nowMs: number; }): boolean { const directory = textAttachmentDirectory(input); if (!directory || !NodeFS.existsSync(input.path)) return false; @@ -257,8 +257,7 @@ export function releaseTextAttachment(input: { writeTextAttachmentMetadata(directory, { version: 1, claims, - deleteAfter: - claims.length === 0 ? (input.nowMs ?? Date.now()) + TEXT_ATTACHMENT_DELETE_GRACE_MS : null, + deleteAfter: claims.length === 0 ? input.nowMs + TEXT_ATTACHMENT_DELETE_GRACE_MS : null, }); return true; } @@ -266,7 +265,7 @@ export function releaseTextAttachment(input: { export function reconcileTextAttachments(input: { readonly attachmentsDir: string; readonly retainedRelativePaths: ReadonlySet; - readonly nowMs?: number; + readonly nowMs: number; }): { readonly pending: number; readonly removed: number } { const textRoot = NodePath.join(input.attachmentsDir, TEXT_ATTACHMENT_DIRECTORY); let pending = 0; @@ -280,7 +279,7 @@ export function reconcileTextAttachments(input: { const retainedDirectories = new Set( [...input.retainedRelativePaths].map((relativePath) => NodePath.dirname(relativePath)), ); - const nowMs = input.nowMs ?? Date.now(); + const nowMs = input.nowMs; for (const entry of entries) { if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(entry)) { continue; diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 88df461a316..dffb254aca8 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -38,7 +38,6 @@ import { claimTextAttachment, reconcileTextAttachments, textAttachmentRelativePath, - TEXT_ATTACHMENT_DELETE_GRACE_MS, } from "../../attachmentStore.ts"; const makeProjectionPipelinePrefixedTestLayer = (prefix: string) => @@ -954,7 +953,7 @@ it.layer( reconcileTextAttachments({ attachmentsDir, retainedRelativePaths: new Set(), - nowMs: Date.now() + TEXT_ATTACHMENT_DELETE_GRACE_MS + 1, + nowMs: Number.MAX_SAFE_INTEGER, }); assert.isTrue(yield* exists(keepPath)); @@ -1291,7 +1290,7 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta reconcileTextAttachments({ attachmentsDir, retainedRelativePaths: new Set(retainedSharedPath ? [retainedSharedPath] : []), - nowMs: Date.now() + TEXT_ATTACHMENT_DELETE_GRACE_MS + 1, + nowMs: Number.MAX_SAFE_INTEGER, }); assert.isTrue(yield* exists(textAttachmentPath)); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 09ac7a4bb71..2b4de3d6090 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -5,6 +5,7 @@ import { type OrchestrationSessionStatus, ThreadId, } from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -506,10 +507,12 @@ const runAttachmentSideEffects = Effect.fn("runAttachmentSideEffects")(function* (message) => !sideEffects.deletedThreadIds.has(message.threadId), ), ); + const nowMs = yield* Clock.currentTimeMillis; yield* Effect.sync(() => reconcileTextAttachments({ attachmentsDir: attachmentsRootDir, retainedRelativePaths: retainedTextAttachmentPaths, + nowMs, }), ); } @@ -568,10 +571,12 @@ const reconcileGeneratedAttachments = Effect.fn("reconcileGeneratedAttachments") yield* fileSystem.remove(absolutePath, { force: true }); } } + const nowMs = yield* Clock.currentTimeMillis; yield* Effect.sync(() => reconcileTextAttachments({ attachmentsDir: attachmentsRootDir, retainedRelativePaths: retainedTextAttachmentPaths, + nowMs, }), ); }); @@ -601,10 +606,12 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti serverConfig.attachmentsDir, retainedMessages, ); + const nowMs = yield* Clock.currentTimeMillis; yield* Effect.sync(() => reconcileTextAttachments({ attachmentsDir: serverConfig.attachmentsDir, retainedRelativePaths, + nowMs, }), ); }).pipe( diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 92fa345b083..677adf15627 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1,4 +1,5 @@ import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; @@ -1586,15 +1587,19 @@ const makeWsRpcLayer = ( [WS_METHODS.assetsReleaseTextAttachment]: (input) => observeRpcEffect( WS_METHODS.assetsReleaseTextAttachment, - Effect.try({ - try: () => ({ - released: releaseTextAttachment({ - attachmentsDir: config.attachmentsDir, - path: input.path, - draftOwnerId: input.draftOwnerId, + Effect.gen(function* () { + const nowMs = yield* Clock.currentTimeMillis; + return yield* Effect.try({ + try: () => ({ + released: releaseTextAttachment({ + attachmentsDir: config.attachmentsDir, + path: input.path, + draftOwnerId: input.draftOwnerId, + nowMs, + }), }), - }), - catch: (cause) => new AssetTextAttachmentReleaseError({ path: input.path, cause }), + catch: (cause) => new AssetTextAttachmentReleaseError({ path: input.path, cause }), + }); }), { "rpc.aggregate": "workspace" }, ), From 685727356f17a5dbbca4a137c7896672d3391ea9 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 17:34:35 -0400 Subject: [PATCH 37/79] Persist text attachment draft claims --- apps/web/src/components/ChatView.tsx | 3 + apps/web/src/components/Sidebar.tsx | 26 ++---- apps/web/src/components/chat/ChatComposer.tsx | 87 ++++++++++++------- apps/web/src/composerDraftStore.test.ts | 25 ++++++ apps/web/src/composerDraftStore.ts | 14 +++ apps/web/src/connection/platform.ts | 16 ++-- .../src/deferredTextAttachmentCleanup.test.ts | 76 ---------------- apps/web/src/deferredTextAttachmentCleanup.ts | 59 ------------- apps/web/src/hooks/useThreadActions.ts | 20 ++--- apps/web/src/textAttachmentClaims.test.ts | 47 ++++++++++ apps/web/src/textAttachmentClaims.ts | 30 +++++++ 11 files changed, 199 insertions(+), 204 deletions(-) delete mode 100644 apps/web/src/deferredTextAttachmentCleanup.test.ts delete mode 100644 apps/web/src/deferredTextAttachmentCleanup.ts create mode 100644 apps/web/src/textAttachmentClaims.test.ts create mode 100644 apps/web/src/textAttachmentClaims.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f5ea5bb1eba..9951d96c166 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3923,6 +3923,7 @@ function ChatViewContent(props: ChatViewProps) { draftText: trimmed, planMarkdown: activeProposedPlan.planMarkdown, }); + composerRef.current?.releaseTextAttachmentClaims(); promptRef.current = ""; clearComposerDraftContent(composerDraftTarget); composerRef.current?.resetCursorState(); @@ -3942,6 +3943,7 @@ function ChatViewContent(props: ChatViewProps) { : null; if (standaloneSlashCommand) { handleInteractionModeChange(standaloneSlashCommand); + composerRef.current?.releaseTextAttachmentClaims(); promptRef.current = ""; clearComposerDraftContent(composerDraftTarget); composerRef.current?.resetCursorState(); @@ -4067,6 +4069,7 @@ function ChatViewContent(props: ChatViewProps) { }), ); } + composerRef.current?.releaseTextAttachmentClaims(); promptRef.current = ""; clearComposerDraftContent(composerDraftTarget); composerRef.current?.resetCursorState(); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 93bb613e607..6facce05619 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -108,11 +108,7 @@ import { import { isModelPickerOpen } from "../modelPickerVisibility"; import { useShortcutModifierState } from "../shortcutModifierState"; import { readLocalApi } from "../localApi"; -import { - composerDraftPromptsEnvironmentExcept, - composerDraftTargetsProject, - useComposerDraftStore, -} from "../composerDraftStore"; +import { composerDraftTargetsProject, useComposerDraftStore } from "../composerDraftStore"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { useDesktopUpdateState } from "../state/desktopUpdate"; @@ -123,7 +119,7 @@ import { useEnvironmentQuery } from "../state/query"; import { threadEnvironment, useEnvironmentThread } from "../state/threads"; import { vcsEnvironment } from "../state/vcs"; import { useEnvironment, useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; -import { unreferencedTextAttachmentPaths } from "../textAttachmentPaths"; +import { textAttachmentClaims } from "../textAttachmentClaims"; import { buildThreadRouteParams, resolveThreadRouteRef, @@ -1119,7 +1115,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false, }); - const deleteTextAttachment = useAtomCommand(assetEnvironment.deleteTextAttachment, { + const releaseTextAttachment = useAtomCommand(assetEnvironment.releaseTextAttachment, { reportFailure: false, }); const updateProject = useAtomCommand(projectEnvironment.update, { @@ -1454,12 +1450,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ) .map((thread) => scopeThreadRef(thread.environmentId, thread.id)); const discardedTargets = composerDraftTargetsProject(memberProjectRef, projectThreadRefs); - const discardedPrompts = discardedTargets.map( - (target) => draftStore.getComposerDraft(target)?.prompt ?? "", - ); - const retainedPrompts = composerDraftPromptsEnvironmentExcept( - member.environmentId, - discardedTargets, + const discardedClaims = discardedTargets.flatMap((target) => + textAttachmentClaims(target, draftStore.getComposerDraft(target)?.prompt ?? ""), ); const result = await deleteProject({ environmentId: member.environmentId, @@ -1472,10 +1464,10 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec return result; } await Promise.all( - unreferencedTextAttachmentPaths(discardedPrompts, retainedPrompts).map((path) => - deleteTextAttachment({ + discardedClaims.map(({ path, draftOwnerId }) => + releaseTextAttachment({ environmentId: member.environmentId, - input: { path }, + input: { path, draftOwnerId }, }), ), ); @@ -1485,7 +1477,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec draftStore.clearProjectDraftThreadId(memberProjectRef); return result; }, - [deleteProject, deleteTextAttachment, projectThreads], + [deleteProject, projectThreads, releaseTextAttachment], ); const handleRemoveProject = useCallback( diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 3098f434b79..44df67ed542 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -44,7 +44,6 @@ import { } from "../../composer-logic"; import { deriveComposerSendState, readFileAsDataUrl } from "../ChatView.logic"; import { - composerDraftPromptsEnvironment, type ComposerImageAttachment, type DraftId, type PersistedComposerImageAttachment, @@ -88,11 +87,7 @@ import { import { ContextWindowMeter } from "./ContextWindowMeter"; import { buildExpandedImagePreview, type ExpandedImagePreview } from "./ExpandedImagePreview"; import { basenameOfPath } from "../../pierre-icons"; -import { removedTextAttachmentPaths, textAttachmentPaths } from "../../textAttachmentPaths"; -import { - DeferredTextAttachmentCleanup, - isTextAttachmentReferenced, -} from "../../deferredTextAttachmentCleanup"; +import { textAttachmentClaimChanges, textAttachmentDraftOwnerId } from "../../textAttachmentClaims"; import { cn, randomUUID } from "~/lib/utils"; import { Separator } from "../ui/separator"; import { Button } from "../ui/button"; @@ -426,6 +421,8 @@ export interface ChatComposerHandle { }) => void; /** Insert a terminal context from the terminal drawer. */ addTerminalContext: (selection: TerminalContextSelection) => void; + /** Release draft claims before the parent destructively clears composer content. */ + releaseTextAttachmentClaims: () => void; /** Get the current prompt/effort/model state for use in send. */ getSendContext: () => { prompt: string; @@ -635,7 +632,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const writeTextAttachment = useAtomCommand(assetEnvironment.writeTextAttachment, { reportFailure: false, }); - const deleteTextAttachment = useAtomCommand(assetEnvironment.deleteTextAttachment, { + const claimTextAttachment = useAtomCommand(assetEnvironment.claimTextAttachment, { + reportFailure: false, + }); + const releaseTextAttachment = useAtomCommand(assetEnvironment.releaseTextAttachment, { reportFailure: false, }); const prompt = composerDraft.prompt; @@ -932,7 +932,30 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const attachmentQueueRef = useRef>(Promise.resolve()); const composerAttachmentKeyRef = useRef(composerAttachmentKey); composerAttachmentKeyRef.current = composerAttachmentKey; - const textAttachmentCleanupRef = useRef(new DeferredTextAttachmentCleanup()); + const textAttachmentClaimQueueRef = useRef>(Promise.resolve()); + const textAttachmentClaimStateRef = useRef<{ + draftOwnerId: string; + paths: Set; + } | null>(null); + + useEffect(() => { + const draftOwnerId = textAttachmentDraftOwnerId(composerDraftTarget); + const previous = textAttachmentClaimStateRef.current; + const previousPaths = + previous?.draftOwnerId === draftOwnerId ? previous.paths : new Set(); + const changes = textAttachmentClaimChanges(previousPaths, prompt); + textAttachmentClaimStateRef.current = { draftOwnerId, paths: changes.nextPaths }; + textAttachmentClaimQueueRef.current = textAttachmentClaimQueueRef.current + .catch(() => undefined) + .then(async () => { + for (const path of changes.claim) { + await claimTextAttachment({ environmentId, input: { path, draftOwnerId } }); + } + for (const path of changes.release) { + await releaseTextAttachment({ environmentId, input: { path, draftOwnerId } }); + } + }); + }, [claimTextAttachment, composerDraftTarget, environmentId, prompt, releaseTextAttachment]); // ------------------------------------------------------------------ // Derived: composer send state @@ -1183,30 +1206,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // ------------------------------------------------------------------ const setPrompt = useCallback( (nextPrompt: string) => { - const previousPrompt = getComposerDraft(composerDraftTarget)?.prompt ?? ""; - for (const path of textAttachmentPaths(nextPrompt)) { - textAttachmentCleanupRef.current.cancel(path); - } - const removedPaths = removedTextAttachmentPaths(previousPrompt, nextPrompt); - for (const path of removedPaths) { - textAttachmentCleanupRef.current.schedule(path, { - isReferenced: () => - isTextAttachmentReferenced(path, composerDraftPromptsEnvironment(environmentId)), - deletePath: async () => { - const result = await deleteTextAttachment({ environmentId, input: { path } }); - return result._tag === "Success"; - }, - }); - } setComposerDraftPrompt(composerDraftTarget, nextPrompt); }, - [ - composerDraftTarget, - deleteTextAttachment, - environmentId, - getComposerDraft, - setComposerDraftPrompt, - ], + [composerDraftTarget, setComposerDraftPrompt], ); const addComposerImage = useCallback( @@ -1836,7 +1838,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const contents = new TextDecoder("utf-8", { fatal: true }).decode(bytes); return writeTextAttachment({ environmentId, - input: { fileName: file.name || "context.txt", contents }, + input: { + fileName: file.name || "context.txt", + contents, + draftOwnerId: textAttachmentDraftOwnerId(composerDraftTarget), + }, }); }) .catch(() => null); @@ -2126,6 +2132,23 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerEditorRef.current?.focusAt(nextCollapsedCursor); }); }, + releaseTextAttachmentClaims: () => { + const draftOwnerId = textAttachmentDraftOwnerId(composerDraftTarget); + const paths = new Set([ + ...textAttachmentClaimChanges(new Set(), promptRef.current).nextPaths, + ...(textAttachmentClaimStateRef.current?.draftOwnerId === draftOwnerId + ? textAttachmentClaimStateRef.current.paths + : []), + ]); + textAttachmentClaimStateRef.current = { draftOwnerId, paths: new Set() }; + textAttachmentClaimQueueRef.current = textAttachmentClaimQueueRef.current + .catch(() => undefined) + .then(async () => { + for (const path of paths) { + await releaseTextAttachment({ environmentId, input: { path, draftOwnerId } }); + } + }); + }, getSendContext: () => ({ prompt: promptRef.current, images: composerImagesRef.current, @@ -2147,6 +2170,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerCursor, composerTerminalContexts, insertComposerDraftTerminalContext, + environmentId, + releaseTextAttachment, promptRef, composerImagesRef, composerTerminalContextsRef, diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 72345a2ee1c..a46abc2f294 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -60,6 +60,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" import { COMPOSER_DRAFT_STORAGE_KEY, clearComposerDraftsEnvironment, + composerDraftEntriesEnvironment, composerDraftPromptsEnvironment, composerDraftPromptsEnvironmentExcept, composerDraftTargetsProject, @@ -79,6 +80,7 @@ import { type TerminalContextDraft, } from "./lib/terminalContext"; import { createDebouncedStorage } from "./lib/storage"; +import { textAttachmentClaims } from "./textAttachmentClaims"; function makeImage(input: { id: string; @@ -736,6 +738,29 @@ describe("composerDraftStore project draft thread mapping", () => { } }); + it("lists stable targets and prompts before a bulk environment clear", () => { + const store = useComposerDraftStore.getState(); + const serverThreadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, otherThreadId); + const draftPath = + "/var/t3-data/attachments/text/12345678-1234-1234-1234-123456789abc/draft.txt"; + const threadPath = + "/var/t3-data/attachments/text/87654321-4321-4321-4321-cba987654321/thread.txt"; + store.setProjectDraftThreadId(projectRef, draftId, { + threadId: ThreadId.make("draft-thread"), + }); + store.setPrompt(draftId, `[draft.txt](${draftPath})`); + store.setPrompt(serverThreadRef, `[thread.txt](${threadPath})`); + + const entries = composerDraftEntriesEnvironment(TEST_ENVIRONMENT_ID); + expect(entries.flatMap(({ target, prompt }) => textAttachmentClaims(target, prompt))).toEqual([ + { path: draftPath, draftOwnerId: `draft:${draftId}` }, + { + path: threadPath, + draftOwnerId: `thread:${TEST_ENVIRONMENT_ID}:${otherThreadId}`, + }, + ]); + }); + it("collects every draft target removed with a forced project deletion", () => { const store = useComposerDraftStore.getState(); const projectThreadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index d4a68c93ef7..c68ced1b043 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -3404,6 +3404,20 @@ export function composerDraftPromptsEnvironment(environmentId: EnvironmentId): s }); } +export function composerDraftEntriesEnvironment( + environmentId: EnvironmentId, +): Array<{ target: ComposerThreadTarget; prompt: string }> { + const state = useComposerDraftStore.getState(); + return [...composerDraftKeysEnvironment(state, environmentId)].flatMap((key) => { + const draft = state.draftsByThreadKey[key]; + if (!draft) return []; + const target = state.draftThreadsByThreadKey[key] + ? DraftId.make(key) + : parseScopedThreadKey(key); + return target ? [{ target, prompt: draft.prompt }] : []; + }); +} + export function composerDraftPromptsEnvironmentExcept( environmentId: EnvironmentId, excludedTargets: ReadonlyArray, diff --git a/apps/web/src/connection/platform.ts b/apps/web/src/connection/platform.ts index 6fbeda34496..d47e749201a 100644 --- a/apps/web/src/connection/platform.ts +++ b/apps/web/src/connection/platform.ts @@ -52,9 +52,9 @@ import { } from "../environments/primary/target"; import { clearComposerDraftsEnvironment, - composerDraftPromptsEnvironment, + composerDraftEntriesEnvironment, } from "../composerDraftStore"; -import { textAttachmentPaths } from "../textAttachmentPaths"; +import { textAttachmentClaims } from "../textAttachmentClaims"; import { isHostedStaticApp } from "../hostedPairing"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { acknowledgeRpcRequest, trackRpcRequestSent } from "../rpc/requestLatencyState"; @@ -585,13 +585,13 @@ const environmentOwnedDataCleanupLayer = Layer.succeed( clear: (environmentId, supervisor) => Effect.gen(function* () { if (supervisor) { - const paths = [ - ...new Set(composerDraftPromptsEnvironment(environmentId).flatMap(textAttachmentPaths)), - ]; + const claims = composerDraftEntriesEnvironment(environmentId).flatMap( + ({ target, prompt }) => textAttachmentClaims(target, prompt), + ); yield* Effect.forEach( - paths, - (path) => - request(WS_METHODS.assetsDeleteTextAttachment, { path }).pipe( + claims, + ({ path, draftOwnerId }) => + request(WS_METHODS.assetsReleaseTextAttachment, { path, draftOwnerId }).pipe( Effect.provideService(EnvironmentSupervisor, supervisor), Effect.ignoreCause({ log: true }), ), diff --git a/apps/web/src/deferredTextAttachmentCleanup.test.ts b/apps/web/src/deferredTextAttachmentCleanup.test.ts deleted file mode 100644 index 068c14c21fb..00000000000 --- a/apps/web/src/deferredTextAttachmentCleanup.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; - -import { - DeferredTextAttachmentCleanup, - isTextAttachmentReferenced, -} from "./deferredTextAttachmentCleanup"; - -const PATH = "/var/t3-data/attachments/text/12345678-1234-1234-1234-123456789abc/shared.txt"; - -afterEach(() => { - vi.useRealTimers(); -}); - -describe("DeferredTextAttachmentCleanup", () => { - it("cancels deletion when a removed link is rapidly restored", async () => { - vi.useFakeTimers(); - const cleanup = new DeferredTextAttachmentCleanup(1_000); - const deletePath = vi.fn(); - - cleanup.schedule(PATH, { isReferenced: () => false, deletePath }); - cleanup.cancel(PATH); - await vi.advanceTimersByTimeAsync(1_000); - - expect(deletePath).not.toHaveBeenCalled(); - }); - - it("finishes a scheduled cleanup after the composer remounts", async () => { - vi.useFakeTimers(); - let cleanup = new DeferredTextAttachmentCleanup(1_000); - const deletePath = vi.fn(); - - cleanup.schedule(PATH, { isReferenced: () => false, deletePath }); - cleanup = new DeferredTextAttachmentCleanup(1_000); - await vi.advanceTimersByTimeAsync(1_000); - - expect(cleanup).toBeInstanceOf(DeferredTextAttachmentCleanup); - expect(deletePath).toHaveBeenCalledOnce(); - }); - - it("rechecks retained drafts before deleting", async () => { - vi.useFakeTimers(); - const cleanup = new DeferredTextAttachmentCleanup(1_000); - const deletePath = vi.fn(); - const retainedPrompts = [`Shared [shared.txt](${PATH})`]; - - cleanup.schedule(PATH, { - isReferenced: () => isTextAttachmentReferenced(PATH, retainedPrompts), - deletePath, - }); - await vi.advanceTimersByTimeAsync(1_000); - - expect(deletePath).not.toHaveBeenCalled(); - }); - - it("deletes after the deferral when no draft retains the path", async () => { - vi.useFakeTimers(); - const cleanup = new DeferredTextAttachmentCleanup(1_000); - const deletePath = vi.fn(); - - cleanup.schedule(PATH, { isReferenced: () => false, deletePath }); - await vi.advanceTimersByTimeAsync(1_000); - - expect(deletePath).toHaveBeenCalledOnce(); - }); - - it("retries a transient delete failure once", async () => { - vi.useFakeTimers(); - const cleanup = new DeferredTextAttachmentCleanup(1_000, 1); - const deletePath = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true); - - cleanup.schedule(PATH, { isReferenced: () => false, deletePath }); - await vi.advanceTimersByTimeAsync(2_000); - - expect(deletePath).toHaveBeenCalledTimes(2); - }); -}); diff --git a/apps/web/src/deferredTextAttachmentCleanup.ts b/apps/web/src/deferredTextAttachmentCleanup.ts deleted file mode 100644 index 39bcde35482..00000000000 --- a/apps/web/src/deferredTextAttachmentCleanup.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { textAttachmentPaths } from "./textAttachmentPaths"; - -export function isTextAttachmentReferenced(path: string, prompts: ReadonlyArray): boolean { - return prompts.some((prompt) => textAttachmentPaths(prompt).includes(path)); -} - -export class DeferredTextAttachmentCleanup { - readonly #delayMs: number; - readonly #maxRetries: number; - readonly #pending = new Map>(); - - constructor(delayMs = 1_000, maxRetries = 1) { - this.#delayMs = delayMs; - this.#maxRetries = maxRetries; - } - - schedule( - path: string, - options: { - isReferenced: () => boolean; - deletePath: () => boolean | void | Promise; - }, - ): void { - this.cancel(path); - this.#scheduleAttempt(path, options, this.#maxRetries); - } - - #scheduleAttempt( - path: string, - options: { - isReferenced: () => boolean; - deletePath: () => boolean | void | Promise; - }, - retriesRemaining: number, - ): void { - const timeout = setTimeout(async () => { - this.#pending.delete(path); - if (options.isReferenced()) return; - try { - const deleted = await options.deletePath(); - if (deleted === false && retriesRemaining > 0) { - this.#scheduleAttempt(path, options, retriesRemaining - 1); - } - } catch { - if (retriesRemaining > 0) { - this.#scheduleAttempt(path, options, retriesRemaining - 1); - } - } - }, this.#delayMs); - this.#pending.set(path, timeout); - } - - cancel(path: string): void { - const timeout = this.#pending.get(path); - if (timeout === undefined) return; - clearTimeout(timeout); - this.#pending.delete(path); - } -} diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index a72a684f1a8..1bbf10b709b 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -12,10 +12,7 @@ import { useRouter } from "@tanstack/react-router"; import { useCallback, useMemo, useRef } from "react"; import { getFallbackThreadIdAfterDelete } from "../components/Sidebar.logic"; -import { - composerDraftPromptsEnvironmentExcept, - useComposerDraftStore, -} from "../composerDraftStore"; +import { useComposerDraftStore } from "../composerDraftStore"; import { terminalEnvironment } from "../state/terminal"; import { threadEnvironment } from "../state/threads"; import { assetEnvironment } from "../state/assets"; @@ -30,7 +27,7 @@ import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from " import { stackedThreadToast, toastManager } from "../components/ui/toast"; import { useClientSettings } from "./useSettings"; import { useAtomCommand } from "../state/use-atom-command"; -import { unreferencedTextAttachmentPaths } from "../textAttachmentPaths"; +import { textAttachmentClaims } from "../textAttachmentClaims"; export class ThreadArchiveBlockedError extends Schema.TaggedErrorClass()( "ThreadArchiveBlockedError", @@ -55,7 +52,7 @@ export function useThreadActions() { const deleteThreadMutation = useAtomCommand(threadEnvironment.delete, { reportFailure: false, }); - const deleteTextAttachment = useAtomCommand(assetEnvironment.deleteTextAttachment, { + const releaseTextAttachment = useAtomCommand(assetEnvironment.releaseTextAttachment, { reportFailure: false, }); const stopThreadSession = useAtomCommand(threadEnvironment.stopSession); @@ -243,9 +240,6 @@ export function useThreadActions() { }); const discardedPrompt = useComposerDraftStore.getState().getComposerDraft(threadRef)?.prompt ?? ""; - const retainedPrompts = composerDraftPromptsEnvironmentExcept(threadRef.environmentId, [ - threadRef, - ]); const deleteResult = await deleteThreadMutation({ environmentId: threadRef.environmentId, input: { threadId: threadRef.threadId }, @@ -254,10 +248,10 @@ export function useThreadActions() { return deleteResult; } await Promise.all( - unreferencedTextAttachmentPaths([discardedPrompt], retainedPrompts).map((path) => - deleteTextAttachment({ + textAttachmentClaims(threadRef, discardedPrompt).map(({ path, draftOwnerId }) => + releaseTextAttachment({ environmentId: threadRef.environmentId, - input: { path }, + input: { path, draftOwnerId }, }), ), ); @@ -355,7 +349,7 @@ export function useThreadActions() { clearProjectDraftThreadById, clearTerminalUiState, closeTerminal, - deleteTextAttachment, + releaseTextAttachment, deleteThreadMutation, getCurrentRouteThreadRef, refreshVcsStatus, diff --git a/apps/web/src/textAttachmentClaims.test.ts b/apps/web/src/textAttachmentClaims.test.ts new file mode 100644 index 00000000000..8e58d7c5386 --- /dev/null +++ b/apps/web/src/textAttachmentClaims.test.ts @@ -0,0 +1,47 @@ +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { DraftId } from "./composerDraftStore"; +import { + textAttachmentClaimChanges, + textAttachmentClaims, + textAttachmentDraftOwnerId, +} from "./textAttachmentClaims"; + +const PATH = "/var/t3-data/attachments/text/12345678-1234-1234-1234-123456789abc/shared.txt"; + +describe("text attachment claims", () => { + it("uses stable owner ids for draft and server composer targets", () => { + expect(textAttachmentDraftOwnerId(DraftId.make("draft-a"))).toBe("draft:draft-a"); + expect( + textAttachmentDraftOwnerId( + scopeThreadRef(EnvironmentId.make("local"), ThreadId.make("thread-a")), + ), + ).toBe("thread:local:thread-a"); + }); + + it("claims every hydrated or copied generated link after remount", () => { + expect(textAttachmentClaimChanges(new Set(), `[shared.txt](${PATH})`)).toMatchObject({ + claim: [PATH], + release: [], + }); + }); + + it("releases a removed link and reclaims it after rapid undo", () => { + const removed = textAttachmentClaimChanges(new Set([PATH]), ""); + const restored = textAttachmentClaimChanges(removed.nextPaths, `[shared.txt](${PATH})`); + + expect(removed.release).toEqual([PATH]); + expect(restored.claim).toEqual([PATH]); + }); + + it("gives copied shared links independent draft claims", () => { + expect(textAttachmentClaims(DraftId.make("draft-a"), `[shared.txt](${PATH})`)).toEqual([ + { path: PATH, draftOwnerId: "draft:draft-a" }, + ]); + expect(textAttachmentClaims(DraftId.make("draft-b"), `[shared.txt](${PATH})`)).toEqual([ + { path: PATH, draftOwnerId: "draft:draft-b" }, + ]); + }); +}); diff --git a/apps/web/src/textAttachmentClaims.ts b/apps/web/src/textAttachmentClaims.ts new file mode 100644 index 00000000000..542d6a81d0a --- /dev/null +++ b/apps/web/src/textAttachmentClaims.ts @@ -0,0 +1,30 @@ +import type { ScopedThreadRef } from "@t3tools/contracts"; + +import type { ComposerThreadTarget, DraftId } from "./composerDraftStore"; +import { textAttachmentPaths } from "./textAttachmentPaths"; + +export function textAttachmentDraftOwnerId(target: ScopedThreadRef | DraftId): string { + return typeof target === "string" + ? `draft:${target}` + : `thread:${target.environmentId}:${target.threadId}`; +} + +export function textAttachmentClaimChanges( + previousPaths: ReadonlySet, + prompt: string, +): { claim: string[]; release: string[]; nextPaths: Set } { + const nextPaths = new Set(textAttachmentPaths(prompt)); + return { + claim: [...nextPaths].filter((path) => !previousPaths.has(path)), + release: [...previousPaths].filter((path) => !nextPaths.has(path)), + nextPaths, + }; +} + +export function textAttachmentClaims( + target: ComposerThreadTarget, + prompt: string, +): Array<{ path: string; draftOwnerId: string }> { + const draftOwnerId = textAttachmentDraftOwnerId(target); + return textAttachmentPaths(prompt).map((path) => ({ path, draftOwnerId })); +} From 88f46455dd4138a12c03b162f33420b755310999 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 17:41:18 -0400 Subject: [PATCH 38/79] Retain attachment claims until turn start --- .../web/src/components/ChatView.logic.test.ts | 15 +++++++++++++ apps/web/src/components/ChatView.logic.ts | 4 ++++ apps/web/src/components/ChatView.tsx | 7 +++++- apps/web/src/components/chat/ChatComposer.tsx | 22 ++++++++++++++++++- 4 files changed, 46 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 0a0103df183..730b84adf42 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -14,6 +14,7 @@ import { reconcileMountedTerminalThreadIds, reconcileRetainedMountedThreadIds, resolveSendEnvMode, + shouldReleaseTextAttachmentClaims, shouldWriteThreadErrorToCurrentServerThread, } from "./ChatView.logic"; @@ -22,6 +23,20 @@ const projectId = ProjectId.make("project-1"); const threadId = ThreadId.make("thread-1"); const now = "2026-03-29T00:00:00.000Z"; +describe("text attachment claim send ordering", () => { + it("retains claims throughout slow worktree preparation", () => { + expect(shouldReleaseTextAttachmentClaims(false)).toBe(false); + }); + + it("retains claims after metadata or other pre-send failures", () => { + expect(shouldReleaseTextAttachmentClaims(false)).toBe(false); + }); + + it("releases claims only after turn start succeeds", () => { + expect(shouldReleaseTextAttachmentClaims(true)).toBe(true); + }); +}); + function makeThread(overrides: Partial = {}): Thread { return { id: threadId, diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 705793ec77e..dd32e86289f 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -460,3 +460,7 @@ export function hasServerAcknowledgedLocalDispatch(input: { input.localDispatch.sessionUpdatedAt !== (session?.updatedAt ?? null) ); } + +export function shouldReleaseTextAttachmentClaims(turnStartSucceeded: boolean): boolean { + return turnStartSucceeded; +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9951d96c166..3f4bf400222 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -231,6 +231,7 @@ import { readFileAsDataUrl, reconcileMountedTerminalThreadIds, resolveSendEnvMode, + shouldReleaseTextAttachmentClaims, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, waitForStartedServerThread, @@ -4069,7 +4070,7 @@ function ChatViewContent(props: ChatViewProps) { }), ); } - composerRef.current?.releaseTextAttachmentClaims(); + composerRef.current?.holdTextAttachmentClaims(); promptRef.current = ""; clearComposerDraftContent(composerDraftTarget); composerRef.current?.resetCursorState(); @@ -4188,6 +4189,9 @@ function ChatViewContent(props: ChatViewProps) { failure = startResult; } else { turnStartSucceeded = true; + if (shouldReleaseTextAttachmentClaims(turnStartSucceeded)) { + composerRef.current?.releaseTextAttachmentClaims(); + } } } @@ -4234,6 +4238,7 @@ function ChatViewContent(props: ChatViewProps) { error instanceof Error ? error.message : "Failed to send message.", ); } + composerRef.current?.resumeTextAttachmentClaims(); } sendInFlightRef.current = false; if (!turnStartSucceeded) { diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 44df67ed542..8810eda583f 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -423,6 +423,10 @@ export interface ChatComposerHandle { addTerminalContext: (selection: TerminalContextSelection) => void; /** Release draft claims before the parent destructively clears composer content. */ releaseTextAttachmentClaims: () => void; + /** Keep claims alive while a send is preparing and may still fail. */ + holdTextAttachmentClaims: () => void; + /** Resume prompt-driven claim synchronization after a failed send. */ + resumeTextAttachmentClaims: () => void; /** Get the current prompt/effort/model state for use in send. */ getSendContext: () => { prompt: string; @@ -909,6 +913,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const [pendingAttachmentCounts, setPendingAttachmentCounts] = useState>( {}, ); + const [textAttachmentClaimsHeld, setTextAttachmentClaimsHeld] = useState(false); const isMobileViewport = useMediaQuery("max-sm"); const isComposerCollapsedMobile = isMobileViewport && !isComposerFocused; const composerAttachmentKey = composerAttachmentTargetKey(composerDraftTarget); @@ -939,6 +944,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } | null>(null); useEffect(() => { + if (textAttachmentClaimsHeld) return; const draftOwnerId = textAttachmentDraftOwnerId(composerDraftTarget); const previous = textAttachmentClaimStateRef.current; const previousPaths = @@ -955,7 +961,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) await releaseTextAttachment({ environmentId, input: { path, draftOwnerId } }); } }); - }, [claimTextAttachment, composerDraftTarget, environmentId, prompt, releaseTextAttachment]); + }, [ + claimTextAttachment, + composerDraftTarget, + environmentId, + prompt, + releaseTextAttachment, + textAttachmentClaimsHeld, + ]); // ------------------------------------------------------------------ // Derived: composer send state @@ -2133,6 +2146,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }); }, releaseTextAttachmentClaims: () => { + setTextAttachmentClaimsHeld(false); const draftOwnerId = textAttachmentDraftOwnerId(composerDraftTarget); const paths = new Set([ ...textAttachmentClaimChanges(new Set(), promptRef.current).nextPaths, @@ -2149,6 +2163,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } }); }, + holdTextAttachmentClaims: () => { + setTextAttachmentClaimsHeld(true); + }, + resumeTextAttachmentClaims: () => { + setTextAttachmentClaimsHeld(false); + }, getSendContext: () => ({ prompt: promptRef.current, images: composerImagesRef.current, From c88fde5d2dd01a0657b9e15876860ce60a9f4136 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 17:43:12 -0400 Subject: [PATCH 39/79] Index pending text attachment expiry --- apps/server/src/attachmentStore.ts | 46 +++++- .../Layers/ProjectionPipeline.test.ts | 49 +++++++ .../Layers/ProjectionPipeline.ts | 135 ++++++++++++++++-- 3 files changed, 214 insertions(+), 16 deletions(-) diff --git a/apps/server/src/attachmentStore.ts b/apps/server/src/attachmentStore.ts index b6a8c3a916a..4c6a3a69dd8 100644 --- a/apps/server/src/attachmentStore.ts +++ b/apps/server/src/attachmentStore.ts @@ -13,7 +13,8 @@ import { inferImageExtension, SAFE_IMAGE_FILE_EXTENSIONS } from "./imageMime.ts" const ATTACHMENT_FILENAME_EXTENSIONS = [...SAFE_IMAGE_FILE_EXTENSIONS, ".bin"]; const TEXT_ATTACHMENT_DIRECTORY = "text"; -const TEXT_ATTACHMENT_METADATA_FILE = ".t3-attachment.json"; +export const TEXT_ATTACHMENT_METADATA_FILE = ".t3-attachment.json"; +export const TEXT_ATTACHMENT_PENDING_DIRECTORY = ".pending"; export const TEXT_ATTACHMENT_DELETE_GRACE_MS = 60_000; const TEXT_ATTACHMENT_FILE_NAME_MAX_CHARS = 120; const WINDOWS_RESERVED_FILE_NAME_PATTERN = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i; @@ -227,6 +228,31 @@ function writeTextAttachmentMetadata(directory: string, metadata: TextAttachment NodeFS.renameSync(temporaryPath, metadataPath); } +function textAttachmentPendingPath(attachmentsDir: string, directory: string): string { + return NodePath.join( + attachmentsDir, + TEXT_ATTACHMENT_DIRECTORY, + TEXT_ATTACHMENT_PENDING_DIRECTORY, + `${NodePath.basename(directory)}.json`, + ); +} + +function removeTextAttachmentPendingMarker(attachmentsDir: string, directory: string): void { + NodeFS.rmSync(textAttachmentPendingPath(attachmentsDir, directory), { force: true }); +} + +function writeTextAttachmentPendingMarker( + attachmentsDir: string, + directory: string, + deleteAfter: number, +): void { + const pendingPath = textAttachmentPendingPath(attachmentsDir, directory); + NodeFS.mkdirSync(NodePath.dirname(pendingPath), { recursive: true }); + const temporaryPath = `${pendingPath}.${NodeCrypto.randomUUID()}.tmp`; + NodeFS.writeFileSync(temporaryPath, JSON.stringify({ deleteAfter })); + NodeFS.renameSync(temporaryPath, pendingPath); +} + export function claimTextAttachment(input: { readonly attachmentsDir: string; readonly path: string; @@ -240,6 +266,7 @@ export function claimTextAttachment(input: { claims: [...new Set([...metadata.claims, input.draftOwnerId])], deleteAfter: null, }); + removeTextAttachmentPendingMarker(input.attachmentsDir, directory); return true; } @@ -259,6 +286,15 @@ export function releaseTextAttachment(input: { claims, deleteAfter: claims.length === 0 ? input.nowMs + TEXT_ATTACHMENT_DELETE_GRACE_MS : null, }); + if (claims.length === 0) { + writeTextAttachmentPendingMarker( + input.attachmentsDir, + directory, + input.nowMs + TEXT_ATTACHMENT_DELETE_GRACE_MS, + ); + } else { + removeTextAttachmentPendingMarker(input.attachmentsDir, directory); + } return true; } @@ -298,6 +334,7 @@ export function reconcileTextAttachments(input: { if (metadata.deleteAfter !== null) { writeTextAttachmentMetadata(directory, { ...metadata, deleteAfter: null }); } + removeTextAttachmentPendingMarker(input.attachmentsDir, directory); continue; } if (metadata.deleteAfter === null) { @@ -305,14 +342,21 @@ export function reconcileTextAttachments(input: { ...metadata, deleteAfter: nowMs + TEXT_ATTACHMENT_DELETE_GRACE_MS, }); + writeTextAttachmentPendingMarker( + input.attachmentsDir, + directory, + nowMs + TEXT_ATTACHMENT_DELETE_GRACE_MS, + ); pending += 1; continue; } if (metadata.deleteAfter > nowMs) { + writeTextAttachmentPendingMarker(input.attachmentsDir, directory, metadata.deleteAfter); pending += 1; continue; } NodeFS.rmSync(directory, { recursive: true, force: true }); + removeTextAttachmentPendingMarker(input.attachmentsDir, directory); removed += 1; } return { pending, removed }; diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index dffb254aca8..ab3c9a1dac8 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -29,6 +29,7 @@ import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { ORCHESTRATION_PROJECTOR_NAMES, OrchestrationProjectionPipelineLive, + reconcileDueTextAttachments, } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; @@ -37,7 +38,9 @@ import { ServerConfig } from "../../config.ts"; import { claimTextAttachment, reconcileTextAttachments, + releaseTextAttachment, textAttachmentRelativePath, + TEXT_ATTACHMENT_DELETE_GRACE_MS, } from "../../attachmentStore.ts"; const makeProjectionPipelinePrefixedTestLayer = (prefix: string) => @@ -57,6 +60,52 @@ const exists = (filePath: string) => const BaseTestLayer = makeProjectionPipelinePrefixedTestLayer("t3-projection-pipeline-test-"); +it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-text-expiry-sweep-")))( + "text attachment expiry sweep", + (it) => { + it.effect("loads retained messages only when a pending attachment is due", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { attachmentsDir } = yield* ServerConfig; + let retainedLoads = 0; + const loadRetained = Effect.sync(() => { + retainedLoads += 1; + return new Set(); + }); + + yield* reconcileDueTextAttachments(attachmentsDir, loadRetained); + assert.equal(retainedLoads, 0); + + const attachmentPath = path.join( + attachmentsDir, + "text", + "00000000-0000-4000-8000-000000000010", + "orphan.txt", + ); + yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true }); + yield* fileSystem.writeFileString(attachmentPath, "orphan"); + claimTextAttachment({ + attachmentsDir, + path: attachmentPath, + draftOwnerId: "expiry-owner", + }); + releaseTextAttachment({ + attachmentsDir, + path: attachmentPath, + draftOwnerId: "expiry-owner", + nowMs: -TEXT_ATTACHMENT_DELETE_GRACE_MS - 1, + }); + + yield* reconcileDueTextAttachments(attachmentsDir, loadRetained); + + assert.equal(retainedLoads, 1); + assert.isFalse(yield* exists(attachmentPath)); + }), + ); + }, +); + it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { it.effect("bootstraps all projection states and writes projection rows", () => Effect.gen(function* () { diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 2b4de3d6090..dcfb8b8ff0b 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -12,6 +12,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as SqlClient from "effect/unstable/sql/SqlClient"; @@ -57,6 +58,8 @@ import { parseAttachmentIdFromRelativePath, parseThreadSegmentFromAttachmentId, reconcileTextAttachments, + TEXT_ATTACHMENT_METADATA_FILE, + TEXT_ATTACHMENT_PENDING_DIRECTORY, toSafeThreadAttachmentSegment, } from "../../attachmentStore.ts"; @@ -581,6 +584,113 @@ const reconcileGeneratedAttachments = Effect.fn("reconcileGeneratedAttachments") ); }); +interface DueTextAttachment { + readonly directory: string; + readonly markerPath: string; + readonly relativeDirectory: string; +} + +const readDueTextAttachments = Effect.fn("readDueTextAttachments")(function* ( + attachmentsDir: string, + nowMs: number, +) { + const fileSystem = yield* Effect.service(FileSystem.FileSystem); + const path = yield* Effect.service(Path.Path); + const pendingDirectory = path.join(attachmentsDir, "text", TEXT_ATTACHMENT_PENDING_DIRECTORY); + const entries = yield* fileSystem + .readDirectory(pendingDirectory, { recursive: false }) + .pipe(Effect.orElseSucceed(() => [] as Array)); + const candidates = yield* Effect.forEach( + entries, + (entry) => + Effect.gen(function* () { + const match = + /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.json$/i.exec(entry); + if (!match?.[1]) return null; + const markerPath = path.join(pendingDirectory, entry); + const marker = yield* fileSystem + .readFileString(markerPath) + .pipe(Effect.flatMap(Schema.decodeUnknownEffect(Schema.UnknownFromJsonString))); + if ( + typeof marker !== "object" || + marker === null || + !("deleteAfter" in marker) || + typeof marker.deleteAfter !== "number" || + marker.deleteAfter > nowMs + ) { + return null; + } + return { + directory: path.join(attachmentsDir, "text", match[1]), + markerPath, + relativeDirectory: `text/${match[1]}`, + } satisfies DueTextAttachment; + }).pipe(Effect.orElseSucceed(() => null)), + { concurrency: 16 }, + ); + return candidates.filter((candidate): candidate is DueTextAttachment => candidate !== null); +}); + +const removeDueTextAttachments = Effect.fn("removeDueTextAttachments")(function* ( + attachmentsDir: string, + candidates: ReadonlyArray, + retainedRelativePaths: ReadonlySet, + nowMs: number, +) { + const fileSystem = yield* Effect.service(FileSystem.FileSystem); + const path = yield* Effect.service(Path.Path); + const retainedDirectories = new Set( + [...retainedRelativePaths].map((relativePath) => + path.dirname(relativePath).replace(/\\/g, "/"), + ), + ); + yield* Effect.forEach( + candidates, + (candidate) => + Effect.gen(function* () { + if (retainedDirectories.has(candidate.relativeDirectory)) { + yield* fileSystem.remove(candidate.markerPath, { force: true }); + return; + } + const metadata = yield* fileSystem + .readFileString(path.join(candidate.directory, TEXT_ATTACHMENT_METADATA_FILE)) + .pipe( + Effect.flatMap(Schema.decodeUnknownEffect(Schema.UnknownFromJsonString)), + Effect.orElseSucceed(() => null), + ); + if (typeof metadata === "object" && metadata !== null) { + const claims = + "claims" in metadata && Array.isArray(metadata.claims) ? metadata.claims : []; + const deleteAfter = + "deleteAfter" in metadata && typeof metadata.deleteAfter === "number" + ? metadata.deleteAfter + : null; + if (claims.length > 0 || deleteAfter === null) { + yield* fileSystem.remove(candidate.markerPath, { force: true }); + return; + } + if (deleteAfter > nowMs) return; + } + yield* fileSystem.remove(candidate.directory, { recursive: true, force: true }); + yield* fileSystem.remove(candidate.markerPath, { force: true }); + }), + { concurrency: 4 }, + ); +}); + +export function reconcileDueTextAttachments( + attachmentsDir: string, + loadRetainedRelativePaths: Effect.Effect, E, R>, +) { + return Effect.gen(function* () { + const nowMs = yield* Clock.currentTimeMillis; + const dueCandidates = yield* readDueTextAttachments(attachmentsDir, nowMs); + if (dueCandidates.length === 0) return; + const retainedRelativePaths = yield* loadRetainedRelativePaths; + yield* removeDueTextAttachments(attachmentsDir, dueCandidates, retainedRelativePaths, nowMs); + }); +} + const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjectionPipeline")( function* () { const sql = yield* SqlClient.SqlClient; @@ -600,21 +710,16 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti const serverConfig = yield* ServerConfig; const bootstrapComplete = yield* Ref.make(false); - const reconcileTextAttachmentStore = Effect.gen(function* () { - const retainedMessages = yield* projectionThreadMessageRepository.listRetained(); - const retainedRelativePaths = collectThreadTextAttachmentRelativePaths( - serverConfig.attachmentsDir, - retainedMessages, - ); - const nowMs = yield* Clock.currentTimeMillis; - yield* Effect.sync(() => - reconcileTextAttachments({ - attachmentsDir: serverConfig.attachmentsDir, - retainedRelativePaths, - nowMs, - }), - ); - }).pipe( + const reconcileTextAttachmentStore = reconcileDueTextAttachments( + serverConfig.attachmentsDir, + projectionThreadMessageRepository + .listRetained() + .pipe( + Effect.map((retainedMessages) => + collectThreadTextAttachmentRelativePaths(serverConfig.attachmentsDir, retainedMessages), + ), + ), + ).pipe( Effect.catch((cause) => Effect.logWarning("failed to reconcile text attachments", { cause })), ); yield* Effect.forkScoped( From 8644b99cd7007f8004be7f9251aefb16032bea34 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 17:45:41 -0400 Subject: [PATCH 40/79] Retry attachment claim reconciliation --- apps/web/src/components/Sidebar.tsx | 11 +- apps/web/src/components/chat/ChatComposer.tsx | 85 +++++++-------- apps/web/src/connection/platform.ts | 1 + apps/web/src/hooks/useThreadActions.ts | 11 +- apps/web/src/textAttachmentClaims.test.ts | 79 +++++++++++++- apps/web/src/textAttachmentClaims.ts | 100 ++++++++++++++++++ 6 files changed, 232 insertions(+), 55 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 6facce05619..16b8f7c45d9 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -119,7 +119,7 @@ import { useEnvironmentQuery } from "../state/query"; import { threadEnvironment, useEnvironmentThread } from "../state/threads"; import { vcsEnvironment } from "../state/vcs"; import { useEnvironment, useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; -import { textAttachmentClaims } from "../textAttachmentClaims"; +import { retryTextAttachmentOperation, textAttachmentClaims } from "../textAttachmentClaims"; import { buildThreadRouteParams, resolveThreadRouteRef, @@ -1465,9 +1465,12 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec } await Promise.all( discardedClaims.map(({ path, draftOwnerId }) => - releaseTextAttachment({ - environmentId: member.environmentId, - input: { path, draftOwnerId }, + retryTextAttachmentOperation(async () => { + const result = await releaseTextAttachment({ + environmentId: member.environmentId, + input: { path, draftOwnerId }, + }); + return result._tag === "Success"; }), ), ); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 8810eda583f..c04270679d7 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -87,7 +87,11 @@ import { import { ContextWindowMeter } from "./ContextWindowMeter"; import { buildExpandedImagePreview, type ExpandedImagePreview } from "./ExpandedImagePreview"; import { basenameOfPath } from "../../pierre-icons"; -import { textAttachmentClaimChanges, textAttachmentDraftOwnerId } from "../../textAttachmentClaims"; +import { + textAttachmentClaimChanges, + textAttachmentDraftOwnerId, + TextAttachmentClaimReconciler, +} from "../../textAttachmentClaims"; import { cn, randomUUID } from "~/lib/utils"; import { Separator } from "../ui/separator"; import { Button } from "../ui/button"; @@ -937,38 +941,39 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const attachmentQueueRef = useRef>(Promise.resolve()); const composerAttachmentKeyRef = useRef(composerAttachmentKey); composerAttachmentKeyRef.current = composerAttachmentKey; - const textAttachmentClaimQueueRef = useRef>(Promise.resolve()); - const textAttachmentClaimStateRef = useRef<{ - draftOwnerId: string; - paths: Set; + const textAttachmentClaimReconcilerRef = useRef<{ + key: string; + value: TextAttachmentClaimReconciler; } | null>(null); + const getTextAttachmentClaimReconciler = useCallback(() => { + const draftOwnerId = textAttachmentDraftOwnerId(composerDraftTarget); + const key = `${environmentId}:${draftOwnerId}`; + const existing = textAttachmentClaimReconcilerRef.current; + if (existing?.key === key) return existing.value; + const value = new TextAttachmentClaimReconciler({ + claim: async (path) => { + const result = await claimTextAttachment({ + environmentId, + input: { path, draftOwnerId }, + }); + return result._tag === "Success" && result.value.claimed; + }, + release: async (path) => { + const result = await releaseTextAttachment({ + environmentId, + input: { path, draftOwnerId }, + }); + return result._tag === "Success"; + }, + }); + textAttachmentClaimReconcilerRef.current = { key, value }; + return value; + }, [claimTextAttachment, composerDraftTarget, environmentId, releaseTextAttachment]); useEffect(() => { if (textAttachmentClaimsHeld) return; - const draftOwnerId = textAttachmentDraftOwnerId(composerDraftTarget); - const previous = textAttachmentClaimStateRef.current; - const previousPaths = - previous?.draftOwnerId === draftOwnerId ? previous.paths : new Set(); - const changes = textAttachmentClaimChanges(previousPaths, prompt); - textAttachmentClaimStateRef.current = { draftOwnerId, paths: changes.nextPaths }; - textAttachmentClaimQueueRef.current = textAttachmentClaimQueueRef.current - .catch(() => undefined) - .then(async () => { - for (const path of changes.claim) { - await claimTextAttachment({ environmentId, input: { path, draftOwnerId } }); - } - for (const path of changes.release) { - await releaseTextAttachment({ environmentId, input: { path, draftOwnerId } }); - } - }); - }, [ - claimTextAttachment, - composerDraftTarget, - environmentId, - prompt, - releaseTextAttachment, - textAttachmentClaimsHeld, - ]); + getTextAttachmentClaimReconciler().setDesiredPrompt(prompt); + }, [getTextAttachmentClaimReconciler, prompt, textAttachmentClaimsHeld]); // ------------------------------------------------------------------ // Derived: composer send state @@ -1862,6 +1867,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) if (result === null) return `'${file.name}' is not a supported text file.`; if (result._tag === "Failure") return `Could not attach '${file.name}'.`; + getTextAttachmentClaimReconciler().confirmPaths([result.value.path]); const currentPrompt = getComposerDraft(composerDraftTarget)?.prompt ?? ""; const separator = currentPrompt.length > 0 && !/\s$/.test(currentPrompt) ? " " : ""; const nextPrompt = `${currentPrompt}${separator}${serializeComposerFileLink( @@ -2147,21 +2153,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }, releaseTextAttachmentClaims: () => { setTextAttachmentClaimsHeld(false); - const draftOwnerId = textAttachmentDraftOwnerId(composerDraftTarget); - const paths = new Set([ - ...textAttachmentClaimChanges(new Set(), promptRef.current).nextPaths, - ...(textAttachmentClaimStateRef.current?.draftOwnerId === draftOwnerId - ? textAttachmentClaimStateRef.current.paths - : []), - ]); - textAttachmentClaimStateRef.current = { draftOwnerId, paths: new Set() }; - textAttachmentClaimQueueRef.current = textAttachmentClaimQueueRef.current - .catch(() => undefined) - .then(async () => { - for (const path of paths) { - await releaseTextAttachment({ environmentId, input: { path, draftOwnerId } }); - } - }); + const reconciler = getTextAttachmentClaimReconciler(); + reconciler.confirmPaths(textAttachmentClaimChanges(new Set(), promptRef.current).nextPaths); + reconciler.setDesiredPaths([]); }, holdTextAttachmentClaims: () => { setTextAttachmentClaimsHeld(true); @@ -2190,8 +2184,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerCursor, composerTerminalContexts, insertComposerDraftTerminalContext, - environmentId, - releaseTextAttachment, + getTextAttachmentClaimReconciler, promptRef, composerImagesRef, composerTerminalContextsRef, diff --git a/apps/web/src/connection/platform.ts b/apps/web/src/connection/platform.ts index d47e749201a..c127bce149b 100644 --- a/apps/web/src/connection/platform.ts +++ b/apps/web/src/connection/platform.ts @@ -593,6 +593,7 @@ const environmentOwnedDataCleanupLayer = Layer.succeed( ({ path, draftOwnerId }) => request(WS_METHODS.assetsReleaseTextAttachment, { path, draftOwnerId }).pipe( Effect.provideService(EnvironmentSupervisor, supervisor), + Effect.retry({ times: 2 }), Effect.ignoreCause({ log: true }), ), { concurrency: 1, discard: true }, diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 1bbf10b709b..7571c81dd37 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -27,7 +27,7 @@ import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from " import { stackedThreadToast, toastManager } from "../components/ui/toast"; import { useClientSettings } from "./useSettings"; import { useAtomCommand } from "../state/use-atom-command"; -import { textAttachmentClaims } from "../textAttachmentClaims"; +import { retryTextAttachmentOperation, textAttachmentClaims } from "../textAttachmentClaims"; export class ThreadArchiveBlockedError extends Schema.TaggedErrorClass()( "ThreadArchiveBlockedError", @@ -249,9 +249,12 @@ export function useThreadActions() { } await Promise.all( textAttachmentClaims(threadRef, discardedPrompt).map(({ path, draftOwnerId }) => - releaseTextAttachment({ - environmentId: threadRef.environmentId, - input: { path, draftOwnerId }, + retryTextAttachmentOperation(async () => { + const result = await releaseTextAttachment({ + environmentId: threadRef.environmentId, + input: { path, draftOwnerId }, + }); + return result._tag === "Success"; }), ), ); diff --git a/apps/web/src/textAttachmentClaims.test.ts b/apps/web/src/textAttachmentClaims.test.ts index 8e58d7c5386..dd4ed927926 100644 --- a/apps/web/src/textAttachmentClaims.test.ts +++ b/apps/web/src/textAttachmentClaims.test.ts @@ -1,16 +1,22 @@ import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { DraftId } from "./composerDraftStore"; import { textAttachmentClaimChanges, textAttachmentClaims, textAttachmentDraftOwnerId, + TextAttachmentClaimReconciler, + retryTextAttachmentOperation, } from "./textAttachmentClaims"; const PATH = "/var/t3-data/attachments/text/12345678-1234-1234-1234-123456789abc/shared.txt"; +afterEach(() => { + vi.useRealTimers(); +}); + describe("text attachment claims", () => { it("uses stable owner ids for draft and server composer targets", () => { expect(textAttachmentDraftOwnerId(DraftId.make("draft-a"))).toBe("draft:draft-a"); @@ -44,4 +50,75 @@ describe("text attachment claims", () => { { path: PATH, draftOwnerId: "draft:draft-b" }, ]); }); + + it("retries a failed claim without marking it confirmed", async () => { + vi.useFakeTimers(); + const claim = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true); + const reconciler = new TextAttachmentClaimReconciler({ + claim, + release: vi.fn().mockResolvedValue(true), + retryDelayMs: 100, + }); + + reconciler.setDesiredPaths([PATH]); + await reconciler.settled(); + expect(reconciler.snapshot()).toEqual({ desired: new Set([PATH]), confirmed: new Set() }); + + await vi.advanceTimersByTimeAsync(100); + await reconciler.settled(); + expect(reconciler.snapshot().confirmed).toEqual(new Set([PATH])); + expect(claim).toHaveBeenCalledTimes(2); + }); + + it("retries a failed imperative release without forgetting the claim", async () => { + vi.useFakeTimers(); + const release = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true); + const reconciler = new TextAttachmentClaimReconciler({ + claim: vi.fn().mockResolvedValue(true), + release, + retryDelayMs: 100, + }); + reconciler.setDesiredPaths([PATH]); + await reconciler.settled(); + + reconciler.setDesiredPaths([]); + await reconciler.settled(); + expect(reconciler.snapshot()).toEqual({ desired: new Set(), confirmed: new Set([PATH]) }); + + await vi.advanceTimersByTimeAsync(100); + await reconciler.settled(); + expect(reconciler.snapshot().confirmed).toEqual(new Set()); + expect(release).toHaveBeenCalledTimes(2); + }); + + it("reconciles rapid remove and undo to a confirmed claim", async () => { + const release = vi.fn().mockResolvedValue(true); + const reconciler = new TextAttachmentClaimReconciler({ + claim: vi.fn().mockResolvedValue(true), + release, + }); + reconciler.setDesiredPaths([PATH]); + await reconciler.settled(); + + reconciler.setDesiredPaths([]); + reconciler.setDesiredPaths([PATH]); + await reconciler.settled(); + + expect(reconciler.snapshot()).toEqual({ + desired: new Set([PATH]), + confirmed: new Set([PATH]), + }); + expect(release).not.toHaveBeenCalled(); + }); + + it("retries destructive bulk releases", async () => { + vi.useFakeTimers(); + const operation = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true); + + const result = retryTextAttachmentOperation(operation, { retryDelayMs: 100 }); + await vi.advanceTimersByTimeAsync(100); + + await expect(result).resolves.toBe(true); + expect(operation).toHaveBeenCalledTimes(2); + }); }); diff --git a/apps/web/src/textAttachmentClaims.ts b/apps/web/src/textAttachmentClaims.ts index 542d6a81d0a..4e0858758ad 100644 --- a/apps/web/src/textAttachmentClaims.ts +++ b/apps/web/src/textAttachmentClaims.ts @@ -28,3 +28,103 @@ export function textAttachmentClaims( const draftOwnerId = textAttachmentDraftOwnerId(target); return textAttachmentPaths(prompt).map((path) => ({ path, draftOwnerId })); } + +export class TextAttachmentClaimReconciler { + readonly #claim: (path: string) => Promise; + readonly #release: (path: string) => Promise; + readonly #retryDelayMs: number; + readonly #maxRetries: number; + #desired = new Set(); + #confirmed = new Set(); + #queue: Promise = Promise.resolve(); + #retryTimer: ReturnType | null = null; + #retryCount = 0; + + constructor(options: { + claim: (path: string) => Promise; + release: (path: string) => Promise; + retryDelayMs?: number; + maxRetries?: number; + }) { + this.#claim = options.claim; + this.#release = options.release; + this.#retryDelayMs = options.retryDelayMs ?? 250; + this.#maxRetries = options.maxRetries ?? 3; + } + + setDesiredPrompt(prompt: string): void { + this.setDesiredPaths(textAttachmentPaths(prompt)); + } + + setDesiredPaths(paths: Iterable): void { + this.#desired = new Set(paths); + this.#retryCount = 0; + this.#clearRetry(); + this.#enqueueReconcile(); + } + + confirmPaths(paths: Iterable): void { + for (const path of paths) this.#confirmed.add(path); + } + + snapshot(): { desired: Set; confirmed: Set } { + return { + desired: new Set(this.#desired), + confirmed: new Set(this.#confirmed), + }; + } + + async settled(): Promise { + await this.#queue; + } + + #enqueueReconcile(): void { + this.#queue = this.#queue.catch(() => undefined).then(() => this.#reconcile()); + } + + async #reconcile(): Promise { + let failed = false; + for (const path of this.#desired) { + if (this.#confirmed.has(path)) continue; + if (await this.#claim(path)) this.#confirmed.add(path); + else failed = true; + } + for (const path of this.#confirmed) { + if (this.#desired.has(path)) continue; + if (await this.#release(path)) this.#confirmed.delete(path); + else failed = true; + } + if (!failed) { + this.#retryCount = 0; + return; + } + if (this.#retryCount >= this.#maxRetries || this.#retryTimer !== null) return; + const delay = this.#retryDelayMs * 2 ** this.#retryCount; + this.#retryCount += 1; + this.#retryTimer = setTimeout(() => { + this.#retryTimer = null; + this.#enqueueReconcile(); + }, delay); + } + + #clearRetry(): void { + if (this.#retryTimer === null) return; + clearTimeout(this.#retryTimer); + this.#retryTimer = null; + } +} + +export async function retryTextAttachmentOperation( + operation: () => Promise, + options: { maxAttempts?: number; retryDelayMs?: number } = {}, +): Promise { + const maxAttempts = options.maxAttempts ?? 3; + const retryDelayMs = options.retryDelayMs ?? 100; + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + if (await operation()) return true; + if (attempt + 1 < maxAttempts) { + await new Promise((resolve) => setTimeout(resolve, retryDelayMs * 2 ** attempt)); + } + } + return false; +} From 34aa0417407e45cefae965c627974f6d72d7cc89 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 17:47:11 -0400 Subject: [PATCH 41/79] Retain plan attachment claims until turn start --- apps/web/src/components/ChatView.tsx | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 3f4bf400222..fb4eab1fa8b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3924,14 +3924,26 @@ function ChatViewContent(props: ChatViewProps) { draftText: trimmed, planMarkdown: activeProposedPlan.planMarkdown, }); - composerRef.current?.releaseTextAttachmentClaims(); + composerRef.current?.holdTextAttachmentClaims(); promptRef.current = ""; clearComposerDraftContent(composerDraftTarget); composerRef.current?.resetCursorState(); - await onSubmitPlanFollowUp({ + const sent = await onSubmitPlanFollowUp({ text: followUp.text, interactionMode: followUp.interactionMode, }); + if (sent) { + composerRef.current?.releaseTextAttachmentClaims(); + } else { + promptRef.current = promptForSend; + setComposerDraftPrompt(composerDraftTarget, promptForSend); + composerRef.current?.resumeTextAttachmentClaims(); + composerRef.current?.resetCursorState({ + cursor: collapseExpandedComposerCursor(promptForSend, promptForSend.length), + prompt: promptForSend, + detectTrigger: true, + }); + } return; } const standaloneSlashCommand = @@ -4438,17 +4450,17 @@ function ChatViewContent(props: ChatViewProps) { isConnecting || sendInFlightRef.current ) { - return; + return false; } const trimmed = text.trim(); if (!trimmed) { - return; + return false; } const sendCtx = composerRef.current?.getSendContext(); if (!sendCtx) { - return; + return false; } const { selectedProvider: ctxSelectedProvider, @@ -4556,7 +4568,7 @@ function ChatViewContent(props: ChatViewProps) { } } sendInFlightRef.current = false; - return; + return true; } setOptimisticUserMessages((existing) => @@ -4571,6 +4583,7 @@ function ChatViewContent(props: ChatViewProps) { } sendInFlightRef.current = false; resetLocalDispatch(); + return false; }, [ activeThread, From 35971668acb26c188860ba44b901a5f5ca7c3db6 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 17:51:26 -0400 Subject: [PATCH 42/79] Repair pending attachment markers on release retry --- apps/server/src/attachmentStore.ts | 6 +++- .../Layers/ProjectionPipeline.test.ts | 30 +++++++++++++++---- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/apps/server/src/attachmentStore.ts b/apps/server/src/attachmentStore.ts index 4c6a3a69dd8..f522adc5927 100644 --- a/apps/server/src/attachmentStore.ts +++ b/apps/server/src/attachmentStore.ts @@ -279,7 +279,11 @@ export function releaseTextAttachment(input: { const directory = textAttachmentDirectory(input); if (!directory || !NodeFS.existsSync(input.path)) return false; const metadata = readTextAttachmentMetadata(directory); - if (!metadata.claims.includes(input.draftOwnerId)) return false; + if (!metadata.claims.includes(input.draftOwnerId)) { + if (metadata.deleteAfter === null) return false; + writeTextAttachmentPendingMarker(input.attachmentsDir, directory, metadata.deleteAfter); + return true; + } const claims = metadata.claims.filter((claim) => claim !== input.draftOwnerId); writeTextAttachmentMetadata(directory, { version: 1, diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index ab3c9a1dac8..1ef563d5f82 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -41,6 +41,7 @@ import { releaseTextAttachment, textAttachmentRelativePath, TEXT_ATTACHMENT_DELETE_GRACE_MS, + TEXT_ATTACHMENT_PENDING_DIRECTORY, } from "../../attachmentStore.ts"; const makeProjectionPipelinePrefixedTestLayer = (prefix: string) => @@ -90,12 +91,31 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-text-expiry-swe path: attachmentPath, draftOwnerId: "expiry-owner", }); - releaseTextAttachment({ + assert.isTrue( + releaseTextAttachment({ + attachmentsDir, + path: attachmentPath, + draftOwnerId: "expiry-owner", + nowMs: -TEXT_ATTACHMENT_DELETE_GRACE_MS - 1, + }), + ); + const pendingMarkerPath = path.join( attachmentsDir, - path: attachmentPath, - draftOwnerId: "expiry-owner", - nowMs: -TEXT_ATTACHMENT_DELETE_GRACE_MS - 1, - }); + "text", + TEXT_ATTACHMENT_PENDING_DIRECTORY, + "00000000-0000-4000-8000-000000000010.json", + ); + yield* fileSystem.remove(pendingMarkerPath, { force: true }); + assert.isFalse(yield* exists(pendingMarkerPath)); + assert.isTrue( + releaseTextAttachment({ + attachmentsDir, + path: attachmentPath, + draftOwnerId: "expiry-owner", + nowMs: 0, + }), + ); + assert.isTrue(yield* exists(pendingMarkerPath)); yield* reconcileDueTextAttachments(attachmentsDir, loadRetained); From 1e4576163a27c9e355475f4ddd9fc3a3b10b4c05 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 17:55:53 -0400 Subject: [PATCH 43/79] Persist attachment reconciliation through reconnects --- apps/web/src/components/ChatView.tsx | 1 + apps/web/src/components/chat/ChatComposer.tsx | 20 +++++ apps/web/src/connection/platform.ts | 25 +++--- apps/web/src/textAttachmentClaims.test.ts | 81 +++++++++++++++++-- apps/web/src/textAttachmentClaims.ts | 41 +++++++--- 5 files changed, 142 insertions(+), 26 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index fb4eab1fa8b..7bdd48112ec 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -5175,6 +5175,7 @@ function ChatViewContent(props: ChatViewProps) { isServerThread={isServerThread} isLocalDraftThread={isLocalDraftThread} phase={phase} + connectionPhase={activeEnvironmentConnectionPhase} isConnecting={isConnecting} isSendBusy={isSendBusy} isPreparingWorktree={isPreparingWorktree} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index c04270679d7..4d06abba6d0 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -468,6 +468,7 @@ export interface ChatComposerProps { // Session phase phase: SessionPhase; + connectionPhase: EnvironmentConnectionPresentation["phase"]; isConnecting: boolean; isSendBusy: boolean; isPreparingWorktree: boolean; @@ -577,6 +578,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isServerThread: _isServerThread, isLocalDraftThread: _isLocalDraftThread, phase, + connectionPhase, isConnecting, isSendBusy, isPreparingWorktree, @@ -950,6 +952,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const key = `${environmentId}:${draftOwnerId}`; const existing = textAttachmentClaimReconcilerRef.current; if (existing?.key === key) return existing.value; + existing?.value.dispose(); const value = new TextAttachmentClaimReconciler({ claim: async (path) => { const result = await claimTextAttachment({ @@ -975,6 +978,23 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) getTextAttachmentClaimReconciler().setDesiredPrompt(prompt); }, [getTextAttachmentClaimReconciler, prompt, textAttachmentClaimsHeld]); + useEffect(() => { + if (connectionPhase === "connected") { + textAttachmentClaimReconcilerRef.current?.value.reconcileNow(); + } + }, [connectionPhase]); + + useEffect( + () => () => { + const current = textAttachmentClaimReconcilerRef.current; + current?.value.dispose(); + if (textAttachmentClaimReconcilerRef.current === current) { + textAttachmentClaimReconcilerRef.current = null; + } + }, + [], + ); + // ------------------------------------------------------------------ // Derived: composer send state // ------------------------------------------------------------------ diff --git a/apps/web/src/connection/platform.ts b/apps/web/src/connection/platform.ts index c127bce149b..9fa3d1cdd95 100644 --- a/apps/web/src/connection/platform.ts +++ b/apps/web/src/connection/platform.ts @@ -588,16 +588,23 @@ const environmentOwnedDataCleanupLayer = Layer.succeed( const claims = composerDraftEntriesEnvironment(environmentId).flatMap( ({ target, prompt }) => textAttachmentClaims(target, prompt), ); - yield* Effect.forEach( - claims, - ({ path, draftOwnerId }) => - request(WS_METHODS.assetsReleaseTextAttachment, { path, draftOwnerId }).pipe( - Effect.provideService(EnvironmentSupervisor, supervisor), - Effect.retry({ times: 2 }), - Effect.ignoreCause({ log: true }), - ), - { concurrency: 1, discard: true }, + const releaseResult = yield* Effect.result( + Effect.forEach( + claims, + ({ path, draftOwnerId }) => + request(WS_METHODS.assetsReleaseTextAttachment, { path, draftOwnerId }).pipe( + Effect.provideService(EnvironmentSupervisor, supervisor), + Effect.retry({ times: 2 }), + ), + { concurrency: 1, discard: true }, + ), ); + if (releaseResult._tag === "Failure") { + yield* Effect.logWarning("Could not release text attachment draft claims.", { + environmentId, + }); + return; + } } clearComposerDraftsEnvironment(environmentId); }), diff --git a/apps/web/src/textAttachmentClaims.test.ts b/apps/web/src/textAttachmentClaims.test.ts index dd4ed927926..d46556e527e 100644 --- a/apps/web/src/textAttachmentClaims.test.ts +++ b/apps/web/src/textAttachmentClaims.test.ts @@ -70,6 +70,49 @@ describe("text attachment claims", () => { expect(claim).toHaveBeenCalledTimes(2); }); + it("continues retrying claims beyond three failures with capped backoff", async () => { + vi.useFakeTimers(); + const claim = vi + .fn() + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + const reconciler = new TextAttachmentClaimReconciler({ + claim, + release: vi.fn().mockResolvedValue(true), + retryDelayMs: 100, + maxRetryDelayMs: 200, + }); + + reconciler.setDesiredPaths([PATH]); + await reconciler.settled(); + await vi.advanceTimersByTimeAsync(700); + await reconciler.settled(); + + expect(claim).toHaveBeenCalledTimes(5); + expect(reconciler.snapshot().confirmed).toEqual(new Set([PATH])); + }); + + it("reconciles immediately when a connection resumes", async () => { + vi.useFakeTimers(); + const claim = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true); + const reconciler = new TextAttachmentClaimReconciler({ + claim, + release: vi.fn().mockResolvedValue(true), + retryDelayMs: 10_000, + }); + + reconciler.setDesiredPaths([PATH]); + await reconciler.settled(); + reconciler.reconcileNow(); + await reconciler.settled(); + + expect(claim).toHaveBeenCalledTimes(2); + expect(reconciler.snapshot().confirmed).toEqual(new Set([PATH])); + }); + it("retries a failed imperative release without forgetting the claim", async () => { vi.useFakeTimers(); const release = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true); @@ -111,14 +154,40 @@ describe("text attachment claims", () => { expect(release).not.toHaveBeenCalled(); }); - it("retries destructive bulk releases", async () => { + it("retries destructive bulk releases beyond three failures", async () => { vi.useFakeTimers(); - const operation = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true); - - const result = retryTextAttachmentOperation(operation, { retryDelayMs: 100 }); - await vi.advanceTimersByTimeAsync(100); + const operation = vi + .fn() + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + + const result = retryTextAttachmentOperation(operation, { + retryDelayMs: 100, + maxRetryDelayMs: 200, + }); + await vi.advanceTimersByTimeAsync(700); await expect(result).resolves.toBe(true); - expect(operation).toHaveBeenCalledTimes(2); + expect(operation).toHaveBeenCalledTimes(5); + }); + + it("cancels pending retry timers when disposed on unmount", async () => { + vi.useFakeTimers(); + const claim = vi.fn().mockResolvedValue(false); + const reconciler = new TextAttachmentClaimReconciler({ + claim, + release: vi.fn().mockResolvedValue(true), + retryDelayMs: 100, + }); + + reconciler.setDesiredPaths([PATH]); + await reconciler.settled(); + reconciler.dispose(); + await vi.advanceTimersByTimeAsync(10_000); + + expect(claim).toHaveBeenCalledOnce(); }); }); diff --git a/apps/web/src/textAttachmentClaims.ts b/apps/web/src/textAttachmentClaims.ts index 4e0858758ad..60772a5bd81 100644 --- a/apps/web/src/textAttachmentClaims.ts +++ b/apps/web/src/textAttachmentClaims.ts @@ -33,23 +33,24 @@ export class TextAttachmentClaimReconciler { readonly #claim: (path: string) => Promise; readonly #release: (path: string) => Promise; readonly #retryDelayMs: number; - readonly #maxRetries: number; + readonly #maxRetryDelayMs: number; #desired = new Set(); #confirmed = new Set(); #queue: Promise = Promise.resolve(); #retryTimer: ReturnType | null = null; #retryCount = 0; + #disposed = false; constructor(options: { claim: (path: string) => Promise; release: (path: string) => Promise; retryDelayMs?: number; - maxRetries?: number; + maxRetryDelayMs?: number; }) { this.#claim = options.claim; this.#release = options.release; this.#retryDelayMs = options.retryDelayMs ?? 250; - this.#maxRetries = options.maxRetries ?? 3; + this.#maxRetryDelayMs = options.maxRetryDelayMs ?? 30_000; } setDesiredPrompt(prompt: string): void { @@ -57,6 +58,7 @@ export class TextAttachmentClaimReconciler { } setDesiredPaths(paths: Iterable): void { + if (this.#disposed) return; this.#desired = new Set(paths); this.#retryCount = 0; this.#clearRetry(); @@ -64,9 +66,22 @@ export class TextAttachmentClaimReconciler { } confirmPaths(paths: Iterable): void { + if (this.#disposed) return; for (const path of paths) this.#confirmed.add(path); } + reconcileNow(): void { + if (this.#disposed) return; + this.#retryCount = 0; + this.#clearRetry(); + this.#enqueueReconcile(); + } + + dispose(): void { + this.#disposed = true; + this.#clearRetry(); + } + snapshot(): { desired: Set; confirmed: Set } { return { desired: new Set(this.#desired), @@ -79,10 +94,12 @@ export class TextAttachmentClaimReconciler { } #enqueueReconcile(): void { + if (this.#disposed) return; this.#queue = this.#queue.catch(() => undefined).then(() => this.#reconcile()); } async #reconcile(): Promise { + if (this.#disposed) return; let failed = false; for (const path of this.#desired) { if (this.#confirmed.has(path)) continue; @@ -98,8 +115,11 @@ export class TextAttachmentClaimReconciler { this.#retryCount = 0; return; } - if (this.#retryCount >= this.#maxRetries || this.#retryTimer !== null) return; - const delay = this.#retryDelayMs * 2 ** this.#retryCount; + if (this.#retryTimer !== null || this.#disposed) return; + const delay = Math.min( + this.#retryDelayMs * 2 ** Math.min(this.#retryCount, 30), + this.#maxRetryDelayMs, + ); this.#retryCount += 1; this.#retryTimer = setTimeout(() => { this.#retryTimer = null; @@ -116,15 +136,14 @@ export class TextAttachmentClaimReconciler { export async function retryTextAttachmentOperation( operation: () => Promise, - options: { maxAttempts?: number; retryDelayMs?: number } = {}, + options: { retryDelayMs?: number; maxRetryDelayMs?: number; signal?: AbortSignal } = {}, ): Promise { - const maxAttempts = options.maxAttempts ?? 3; const retryDelayMs = options.retryDelayMs ?? 100; - for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + const maxRetryDelayMs = options.maxRetryDelayMs ?? 30_000; + for (let attempt = 0; !options.signal?.aborted; attempt += 1) { if (await operation()) return true; - if (attempt + 1 < maxAttempts) { - await new Promise((resolve) => setTimeout(resolve, retryDelayMs * 2 ** attempt)); - } + const delay = Math.min(retryDelayMs * 2 ** Math.min(attempt, 30), maxRetryDelayMs); + await new Promise((resolve) => setTimeout(resolve, delay)); } return false; } From 25747a39d22bdf015a7a36329ddd745fab43f1f9 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 18:00:16 -0400 Subject: [PATCH 44/79] Reconcile background attachment claims --- apps/web/src/components/chat/ChatComposer.tsx | 55 ++++++-------- apps/web/src/textAttachmentClaims.test.ts | 41 ++++++++++ apps/web/src/textAttachmentClaims.ts | 75 ++++++++++++++++++- 3 files changed, 139 insertions(+), 32 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 4d06abba6d0..55577c03a76 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -44,6 +44,7 @@ import { } from "../../composer-logic"; import { deriveComposerSendState, readFileAsDataUrl } from "../ChatView.logic"; import { + composerDraftEntriesEnvironment, type ComposerImageAttachment, type DraftId, type PersistedComposerImageAttachment, @@ -90,7 +91,8 @@ import { basenameOfPath } from "../../pierre-icons"; import { textAttachmentClaimChanges, textAttachmentDraftOwnerId, - TextAttachmentClaimReconciler, + getTextAttachmentClaimReconciler as getRegisteredTextAttachmentClaimReconciler, + reconcileTextAttachmentClaimsEnvironment, } from "../../textAttachmentClaims"; import { cn, randomUUID } from "~/lib/utils"; import { Separator } from "../ui/separator"; @@ -943,35 +945,33 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const attachmentQueueRef = useRef>(Promise.resolve()); const composerAttachmentKeyRef = useRef(composerAttachmentKey); composerAttachmentKeyRef.current = composerAttachmentKey; - const textAttachmentClaimReconcilerRef = useRef<{ - key: string; - value: TextAttachmentClaimReconciler; - } | null>(null); - const getTextAttachmentClaimReconciler = useCallback(() => { - const draftOwnerId = textAttachmentDraftOwnerId(composerDraftTarget); - const key = `${environmentId}:${draftOwnerId}`; - const existing = textAttachmentClaimReconcilerRef.current; - if (existing?.key === key) return existing.value; - existing?.value.dispose(); - const value = new TextAttachmentClaimReconciler({ - claim: async (path) => { + const textAttachmentClaimOperations = useMemo( + () => ({ + claim: async (path: string, draftOwnerId: string) => { const result = await claimTextAttachment({ environmentId, input: { path, draftOwnerId }, }); return result._tag === "Success" && result.value.claimed; }, - release: async (path) => { + release: async (path: string, draftOwnerId: string) => { const result = await releaseTextAttachment({ environmentId, input: { path, draftOwnerId }, }); - return result._tag === "Success"; + return result._tag === "Success" && result.value.released; }, + }), + [claimTextAttachment, environmentId, releaseTextAttachment], + ); + const getTextAttachmentClaimReconciler = useCallback(() => { + const draftOwnerId = textAttachmentDraftOwnerId(composerDraftTarget); + return getRegisteredTextAttachmentClaimReconciler({ + environmentId, + draftOwnerId, + operations: textAttachmentClaimOperations, }); - textAttachmentClaimReconcilerRef.current = { key, value }; - return value; - }, [claimTextAttachment, composerDraftTarget, environmentId, releaseTextAttachment]); + }, [composerDraftTarget, environmentId, textAttachmentClaimOperations]); useEffect(() => { if (textAttachmentClaimsHeld) return; @@ -980,20 +980,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) useEffect(() => { if (connectionPhase === "connected") { - textAttachmentClaimReconcilerRef.current?.value.reconcileNow(); + reconcileTextAttachmentClaimsEnvironment( + environmentId, + composerDraftEntriesEnvironment(environmentId), + textAttachmentClaimOperations, + ); } - }, [connectionPhase]); - - useEffect( - () => () => { - const current = textAttachmentClaimReconcilerRef.current; - current?.value.dispose(); - if (textAttachmentClaimReconcilerRef.current === current) { - textAttachmentClaimReconcilerRef.current = null; - } - }, - [], - ); + }, [connectionPhase, environmentId, textAttachmentClaimOperations]); // ------------------------------------------------------------------ // Derived: composer send state diff --git a/apps/web/src/textAttachmentClaims.test.ts b/apps/web/src/textAttachmentClaims.test.ts index d46556e527e..a23be6c0d13 100644 --- a/apps/web/src/textAttachmentClaims.test.ts +++ b/apps/web/src/textAttachmentClaims.test.ts @@ -8,12 +8,16 @@ import { textAttachmentClaims, textAttachmentDraftOwnerId, TextAttachmentClaimReconciler, + getTextAttachmentClaimReconciler, + reconcileTextAttachmentClaimsEnvironment, + resetTextAttachmentClaimRegistryForTest, retryTextAttachmentOperation, } from "./textAttachmentClaims"; const PATH = "/var/t3-data/attachments/text/12345678-1234-1234-1234-123456789abc/shared.txt"; afterEach(() => { + resetTextAttachmentClaimRegistryForTest(); vi.useRealTimers(); }); @@ -190,4 +194,41 @@ describe("text attachment claims", () => { expect(claim).toHaveBeenCalledOnce(); }); + + it("retries a background draft claim after navigation and reconnect", async () => { + vi.useFakeTimers(); + const environmentId = EnvironmentId.make("offline-environment"); + const draft = DraftId.make("background-draft"); + const draftOwnerId = textAttachmentDraftOwnerId(draft); + let online = false; + const operations = { + claim: vi.fn(async () => online), + release: vi.fn(async () => online), + }; + + reconcileTextAttachmentClaimsEnvironment( + environmentId, + [{ target: draft, prompt: `[shared.txt](${PATH})` }], + operations, + ); + const background = getTextAttachmentClaimReconciler({ + environmentId, + draftOwnerId, + operations, + }); + await background.settled(); + expect(background.snapshot().confirmed).toEqual(new Set()); + + // Navigating away does not dispose the owner. A later reconnect reconciles + // every persisted draft, including this now-background draft. + online = true; + reconcileTextAttachmentClaimsEnvironment( + environmentId, + [{ target: draft, prompt: `[shared.txt](${PATH})` }], + operations, + ); + await background.settled(); + + expect(background.snapshot().confirmed).toEqual(new Set([PATH])); + }); }); diff --git a/apps/web/src/textAttachmentClaims.ts b/apps/web/src/textAttachmentClaims.ts index 60772a5bd81..0aa795c317c 100644 --- a/apps/web/src/textAttachmentClaims.ts +++ b/apps/web/src/textAttachmentClaims.ts @@ -1,4 +1,4 @@ -import type { ScopedThreadRef } from "@t3tools/contracts"; +import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; import type { ComposerThreadTarget, DraftId } from "./composerDraftStore"; import { textAttachmentPaths } from "./textAttachmentPaths"; @@ -147,3 +147,76 @@ export async function retryTextAttachmentOperation( } return false; } + +export interface TextAttachmentClaimOperations { + claim: (path: string, draftOwnerId: string) => Promise; + release: (path: string, draftOwnerId: string) => Promise; +} + +const textAttachmentClaimReconcilerRegistry = new Map(); + +function textAttachmentClaimRegistryKey( + environmentId: EnvironmentId, + draftOwnerId: string, +): string { + return `${environmentId}:${draftOwnerId}`; +} + +export function getTextAttachmentClaimReconciler(input: { + environmentId: EnvironmentId; + draftOwnerId: string; + operations: TextAttachmentClaimOperations; +}): TextAttachmentClaimReconciler { + const key = textAttachmentClaimRegistryKey(input.environmentId, input.draftOwnerId); + const existing = textAttachmentClaimReconcilerRegistry.get(key); + if (existing) return existing; + const reconciler = new TextAttachmentClaimReconciler({ + claim: (path) => input.operations.claim(path, input.draftOwnerId), + release: (path) => input.operations.release(path, input.draftOwnerId), + }); + textAttachmentClaimReconcilerRegistry.set(key, reconciler); + return reconciler; +} + +export function reconcileTextAttachmentClaimsEnvironment( + environmentId: EnvironmentId, + entries: ReadonlyArray<{ target: ComposerThreadTarget; prompt: string }>, + operations: TextAttachmentClaimOperations, +): void { + for (const { target, prompt } of entries) { + const draftOwnerId = textAttachmentDraftOwnerId(target); + const reconciler = getTextAttachmentClaimReconciler({ + environmentId, + draftOwnerId, + operations, + }); + reconciler.setDesiredPrompt(prompt); + reconciler.reconcileNow(); + } +} + +export async function detachTextAttachmentClaimOwner( + environmentId: EnvironmentId, + draftOwnerId: string, +): Promise { + const key = textAttachmentClaimRegistryKey(environmentId, draftOwnerId); + const reconciler = textAttachmentClaimReconcilerRegistry.get(key); + if (!reconciler) return; + textAttachmentClaimReconcilerRegistry.delete(key); + reconciler.dispose(); + await reconciler.settled(); +} + +export function disposeTextAttachmentClaimEnvironment(environmentId: EnvironmentId): void { + const prefix = `${environmentId}:`; + for (const [key, reconciler] of textAttachmentClaimReconcilerRegistry) { + if (!key.startsWith(prefix)) continue; + reconciler.dispose(); + textAttachmentClaimReconcilerRegistry.delete(key); + } +} + +export function resetTextAttachmentClaimRegistryForTest(): void { + for (const reconciler of textAttachmentClaimReconcilerRegistry.values()) reconciler.dispose(); + textAttachmentClaimReconcilerRegistry.clear(); +} From d1409ecb5f7fb83429c743e3e375121442ea9328 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 18:02:10 -0400 Subject: [PATCH 45/79] Fence destructive attachment claim releases --- apps/web/src/components/Sidebar.tsx | 14 ++++++- apps/web/src/hooks/useThreadActions.ts | 13 ++++++- apps/web/src/textAttachmentClaims.test.ts | 47 +++++++++++++++++++++++ 3 files changed, 70 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 16b8f7c45d9..eb6a88fa3b6 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -119,7 +119,12 @@ import { useEnvironmentQuery } from "../state/query"; import { threadEnvironment, useEnvironmentThread } from "../state/threads"; import { vcsEnvironment } from "../state/vcs"; import { useEnvironment, useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; -import { retryTextAttachmentOperation, textAttachmentClaims } from "../textAttachmentClaims"; +import { + detachTextAttachmentClaimOwner, + retryTextAttachmentOperation, + textAttachmentClaims, + textAttachmentDraftOwnerId, +} from "../textAttachmentClaims"; import { buildThreadRouteParams, resolveThreadRouteRef, @@ -1463,6 +1468,11 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec if (result._tag === "Failure") { return result; } + await Promise.all( + discardedTargets.map((target) => + detachTextAttachmentClaimOwner(member.environmentId, textAttachmentDraftOwnerId(target)), + ), + ); await Promise.all( discardedClaims.map(({ path, draftOwnerId }) => retryTextAttachmentOperation(async () => { @@ -1470,7 +1480,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec environmentId: member.environmentId, input: { path, draftOwnerId }, }); - return result._tag === "Success"; + return result._tag === "Success" && result.value.released; }), ), ); diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 7571c81dd37..36c48cbd6f1 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -27,7 +27,12 @@ import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from " import { stackedThreadToast, toastManager } from "../components/ui/toast"; import { useClientSettings } from "./useSettings"; import { useAtomCommand } from "../state/use-atom-command"; -import { retryTextAttachmentOperation, textAttachmentClaims } from "../textAttachmentClaims"; +import { + detachTextAttachmentClaimOwner, + retryTextAttachmentOperation, + textAttachmentClaims, + textAttachmentDraftOwnerId, +} from "../textAttachmentClaims"; export class ThreadArchiveBlockedError extends Schema.TaggedErrorClass()( "ThreadArchiveBlockedError", @@ -247,6 +252,10 @@ export function useThreadActions() { if (deleteResult._tag === "Failure") { return deleteResult; } + await detachTextAttachmentClaimOwner( + threadRef.environmentId, + textAttachmentDraftOwnerId(threadRef), + ); await Promise.all( textAttachmentClaims(threadRef, discardedPrompt).map(({ path, draftOwnerId }) => retryTextAttachmentOperation(async () => { @@ -254,7 +263,7 @@ export function useThreadActions() { environmentId: threadRef.environmentId, input: { path, draftOwnerId }, }); - return result._tag === "Success"; + return result._tag === "Success" && result.value.released; }), ), ); diff --git a/apps/web/src/textAttachmentClaims.test.ts b/apps/web/src/textAttachmentClaims.test.ts index a23be6c0d13..7287ca65db3 100644 --- a/apps/web/src/textAttachmentClaims.test.ts +++ b/apps/web/src/textAttachmentClaims.test.ts @@ -8,6 +8,7 @@ import { textAttachmentClaims, textAttachmentDraftOwnerId, TextAttachmentClaimReconciler, + detachTextAttachmentClaimOwner, getTextAttachmentClaimReconciler, reconcileTextAttachmentClaimsEnvironment, resetTextAttachmentClaimRegistryForTest, @@ -178,6 +179,52 @@ describe("text attachment claims", () => { expect(operation).toHaveBeenCalledTimes(5); }); + it("waits for an in-flight claim before detaching a destructively cleared owner", async () => { + const environmentId = EnvironmentId.make("destructive-environment"); + const draftOwnerId = "draft:destructive"; + let finishClaim: (claimed: boolean) => void = () => undefined; + const claim = vi.fn<(path: string) => Promise>( + (_path) => + new Promise((resolve) => { + finishClaim = resolve; + }), + ); + const operations = { + claim: (path: string) => claim(path), + release: vi.fn(async () => true), + }; + const reconciler = getTextAttachmentClaimReconciler({ + environmentId, + draftOwnerId, + operations, + }); + reconciler.setDesiredPaths([PATH]); + await vi.waitFor(() => expect(claim).toHaveBeenCalledOnce()); + + let detached = false; + const detach = detachTextAttachmentClaimOwner(environmentId, draftOwnerId).then(() => { + detached = true; + }); + await Promise.resolve(); + expect(detached).toBe(false); + + finishClaim(true); + await detach; + expect(detached).toBe(true); + expect(reconciler.snapshot().confirmed).toEqual(new Set([PATH])); + }); + + it("retries a released false result before destructive clear completes", async () => { + vi.useFakeTimers(); + const release = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true); + + const result = retryTextAttachmentOperation(release, { retryDelayMs: 100 }); + await vi.advanceTimersByTimeAsync(100); + + await expect(result).resolves.toBe(true); + expect(release).toHaveBeenCalledTimes(2); + }); + it("cancels pending retry timers when disposed on unmount", async () => { vi.useFakeTimers(); const claim = vi.fn().mockResolvedValue(false); From 8197a75e06598a9832d95cd2453d3a392b23c34d Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 18:06:00 -0400 Subject: [PATCH 46/79] Abort environment removal when cleanup fails --- apps/mobile/src/connection/platform.ts | 1 + apps/web/src/connection/platform.ts | 18 +++++++++---- .../src/connection/registry.test.ts | 27 +++++++++++++++++++ .../client-runtime/src/connection/registry.ts | 16 ++++++----- .../src/platform/persistence.ts | 6 +++-- 5 files changed, 55 insertions(+), 13 deletions(-) diff --git a/apps/mobile/src/connection/platform.ts b/apps/mobile/src/connection/platform.ts index cc51b56ee70..462280348f7 100644 --- a/apps/mobile/src/connection/platform.ts +++ b/apps/mobile/src/connection/platform.ts @@ -184,6 +184,7 @@ const providedCapabilitiesLayer = capabilitiesLayer.pipe( const environmentOwnedDataCleanupLayer = Layer.succeed( EnvironmentOwnedDataCleanup, EnvironmentOwnedDataCleanup.of({ + prepare: () => Effect.void, clear: (environmentId) => Effect.all( [ diff --git a/apps/web/src/connection/platform.ts b/apps/web/src/connection/platform.ts index 9fa3d1cdd95..a160010a286 100644 --- a/apps/web/src/connection/platform.ts +++ b/apps/web/src/connection/platform.ts @@ -1,6 +1,7 @@ import { ClientPresentation, CloudSession, + ConnectionPersistenceError, EnvironmentOwnedDataCleanup, PlatformConnectionSource, PrimaryEnvironmentAuth, @@ -54,7 +55,10 @@ import { clearComposerDraftsEnvironment, composerDraftEntriesEnvironment, } from "../composerDraftStore"; -import { textAttachmentClaims } from "../textAttachmentClaims"; +import { + disposeTextAttachmentClaimEnvironment, + textAttachmentClaims, +} from "../textAttachmentClaims"; import { isHostedStaticApp } from "../hostedPairing"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { acknowledgeRpcRequest, trackRpcRequestSent } from "../rpc/requestLatencyState"; @@ -582,7 +586,7 @@ const platformConnectionSourceLayer = Layer.effect( const environmentOwnedDataCleanupLayer = Layer.succeed( EnvironmentOwnedDataCleanup, EnvironmentOwnedDataCleanup.of({ - clear: (environmentId, supervisor) => + prepare: (environmentId, supervisor) => Effect.gen(function* () { if (supervisor) { const claims = composerDraftEntriesEnvironment(environmentId).flatMap( @@ -600,12 +604,16 @@ const environmentOwnedDataCleanupLayer = Layer.succeed( ), ); if (releaseResult._tag === "Failure") { - yield* Effect.logWarning("Could not release text attachment draft claims.", { - environmentId, + return yield* new ConnectionPersistenceError({ + operation: "clear-environment", + message: "Could not release text attachment draft claims.", }); - return; } } + }), + clear: (environmentId) => + Effect.sync(() => { + disposeTextAttachmentClaimEnvironment(environmentId); clearComposerDraftsEnvironment(environmentId); }), }), diff --git a/packages/client-runtime/src/connection/registry.test.ts b/packages/client-runtime/src/connection/registry.test.ts index c75cc2fad4b..1b9323a3311 100644 --- a/packages/client-runtime/src/connection/registry.test.ts +++ b/packages/client-runtime/src/connection/registry.test.ts @@ -135,6 +135,9 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( readonly beforeRegistrationRemove?: ( target: ConnectionTarget, ) => Effect.Effect; + readonly beforeOwnedDataClear?: ( + environmentId: EnvironmentId, + ) => Effect.Effect; }, ) { const storedTargets = yield* Ref.make( @@ -263,6 +266,7 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( ), }); const ownedDataCleanup = Persistence.EnvironmentOwnedDataCleanup.of({ + prepare: (environmentId) => options?.beforeOwnedDataClear?.(environmentId) ?? Effect.void, clear: (environmentId) => Ref.update(ownedDataClears, (environmentIds) => [...environmentIds, environmentId]), }); @@ -595,6 +599,29 @@ describe("EnvironmentRegistry", () => { }), ); + it.effect("keeps an environment registered when owned-data cleanup fails", () => + Effect.gen(function* () { + const cleanupError = new Persistence.ConnectionPersistenceError({ + operation: "clear-environment", + message: "Attachment claims could not be released.", + }); + const harness = yield* makeHarness([TARGET], [], [], { + beforeOwnedDataClear: () => Effect.fail(cleanupError), + }); + + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + yield* registry.start; + + const error = yield* Effect.flip(registry.remove(TARGET.environmentId)); + expect(error).toBe(cleanupError); + expect((yield* Ref.get(harness.storedTargets)).has(TARGET.environmentId)).toBe(true); + expect((yield* SubscriptionRef.get(registry.entries)).has(TARGET.environmentId)).toBe(true); + expect(yield* Ref.get(harness.ownedDataClears)).toEqual([]); + }).pipe(Effect.provide(harness.layer)); + }), + ); + it.effect("moves durable streams to a replacement supervisor", () => Effect.gen(function* () { const replacement = new RelayConnectionTarget({ diff --git a/packages/client-runtime/src/connection/registry.ts b/packages/client-runtime/src/connection/registry.ts index 960c5afb9f3..67e6d6e0107 100644 --- a/packages/client-runtime/src/connection/registry.ts +++ b/packages/client-runtime/src/connection/registry.ts @@ -70,10 +70,12 @@ export class EnvironmentRegistry extends Context.Service< readonly register: ( registration: ConnectionRegistration, ) => Effect.Effect; - readonly registerPlatform: (registration: PrimaryConnectionRegistration) => Effect.Effect; + readonly registerPlatform: ( + registration: PrimaryConnectionRegistration, + ) => Effect.Effect; readonly reconcilePlatform: ( registrations: ReadonlyArray, - ) => Effect.Effect; + ) => Effect.Effect; readonly remove: ( environmentId: EnvironmentId, ) => Effect.Effect< @@ -476,19 +478,20 @@ export const make = Effect.gen(function* () { environmentId, Effect.gen(function* () { const entry = (yield* SubscriptionRef.get(entries)).get(environmentId); + const serviceScope = (yield* SubscriptionRef.get(serviceScopes)).get(environmentId); + yield* ownedDataCleanup.prepare(environmentId, serviceScope?.supervisor); yield* Ref.update(platformEnvironmentIds, (current) => { const next = new Set(current); next.delete(environmentId); return next; }); - const serviceScope = (yield* SubscriptionRef.get(serviceScopes)).get(environmentId); - yield* ownedDataCleanup.clear(environmentId, serviceScope?.supervisor); yield* closeServiceScope(environmentId); yield* SubscriptionRef.update(entries, (current) => { const next = new Map(current); next.delete(environmentId); return next; }); + yield* ownedDataCleanup.clear(environmentId); if (entry !== undefined && entry.target._tag === "BearerConnectionTarget") { yield* credentials.remove(entry.target.connectionId).pipe( Effect.catch((error) => @@ -557,20 +560,21 @@ export const make = Effect.gen(function* () { ? yield* profiles.get(target.connectionId) : Option.none(); + const serviceScope = (yield* SubscriptionRef.get(serviceScopes)).get(environmentId); + yield* ownedDataCleanup.prepare(environmentId, serviceScope?.supervisor); yield* registrations.remove(target); yield* Ref.update(persistedTargetsByEnvironment, (current) => { const next = new Map(current); next.delete(environmentId); return next; }); - const serviceScope = (yield* SubscriptionRef.get(serviceScopes)).get(environmentId); - yield* ownedDataCleanup.clear(environmentId, serviceScope?.supervisor); yield* closeServiceScope(environmentId); yield* SubscriptionRef.update(entries, (current) => { const next = new Map(current); next.delete(environmentId); return next; }); + yield* ownedDataCleanup.clear(environmentId); yield* Effect.all( [ cache.clear(environmentId).pipe( diff --git a/packages/client-runtime/src/platform/persistence.ts b/packages/client-runtime/src/platform/persistence.ts index 8486855f218..0df6f39fa78 100644 --- a/packages/client-runtime/src/platform/persistence.ts +++ b/packages/client-runtime/src/platform/persistence.ts @@ -110,12 +110,14 @@ export class EnvironmentCacheStore extends Context.Service< >()("@t3tools/client-runtime/platform/persistence/EnvironmentCacheStore") {} export class EnvironmentOwnedDataCleanup extends Context.Reference<{ - readonly clear: ( + readonly prepare: ( environmentId: EnvironmentId, supervisor: EnvironmentSupervisor["Service"] | undefined, - ) => Effect.Effect; + ) => Effect.Effect; + readonly clear: (environmentId: EnvironmentId) => Effect.Effect; }>("@t3tools/client-runtime/platform/persistence/EnvironmentOwnedDataCleanup", { defaultValue: () => ({ + prepare: () => Effect.void, clear: () => Effect.void, }), }) {} From ee1b6608e77f14b6f7fa7e987545d71ca6bc69f9 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 18:09:45 -0400 Subject: [PATCH 47/79] Treat detached attachment releases as idempotent --- apps/web/src/components/Sidebar.tsx | 3 ++- apps/web/src/hooks/useThreadActions.ts | 3 ++- apps/web/src/textAttachmentClaims.test.ts | 15 +++++++++++++++ apps/web/src/textAttachmentClaims.ts | 4 ++++ 4 files changed, 23 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index eb6a88fa3b6..08a8a85da7b 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -121,6 +121,7 @@ import { vcsEnvironment } from "../state/vcs"; import { useEnvironment, useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { detachTextAttachmentClaimOwner, + detachedTextAttachmentReleaseComplete, retryTextAttachmentOperation, textAttachmentClaims, textAttachmentDraftOwnerId, @@ -1480,7 +1481,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec environmentId: member.environmentId, input: { path, draftOwnerId }, }); - return result._tag === "Success" && result.value.released; + return detachedTextAttachmentReleaseComplete(result); }), ), ); diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 36c48cbd6f1..e013d864cb4 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -29,6 +29,7 @@ import { useClientSettings } from "./useSettings"; import { useAtomCommand } from "../state/use-atom-command"; import { detachTextAttachmentClaimOwner, + detachedTextAttachmentReleaseComplete, retryTextAttachmentOperation, textAttachmentClaims, textAttachmentDraftOwnerId, @@ -263,7 +264,7 @@ export function useThreadActions() { environmentId: threadRef.environmentId, input: { path, draftOwnerId }, }); - return result._tag === "Success" && result.value.released; + return detachedTextAttachmentReleaseComplete(result); }), ), ); diff --git a/apps/web/src/textAttachmentClaims.test.ts b/apps/web/src/textAttachmentClaims.test.ts index 7287ca65db3..04f7efae219 100644 --- a/apps/web/src/textAttachmentClaims.test.ts +++ b/apps/web/src/textAttachmentClaims.test.ts @@ -9,6 +9,7 @@ import { textAttachmentDraftOwnerId, TextAttachmentClaimReconciler, detachTextAttachmentClaimOwner, + detachedTextAttachmentReleaseComplete, getTextAttachmentClaimReconciler, reconcileTextAttachmentClaimsEnvironment, resetTextAttachmentClaimRegistryForTest, @@ -225,6 +226,20 @@ describe("text attachment claims", () => { expect(release).toHaveBeenCalledTimes(2); }); + it("does not retry a permanently absent detached owner", async () => { + const releaseRpc = vi.fn(async () => ({ + _tag: "Success" as const, + value: { released: false }, + })); + + const completed = await retryTextAttachmentOperation(async () => + detachedTextAttachmentReleaseComplete(await releaseRpc()), + ); + + expect(completed).toBe(true); + expect(releaseRpc).toHaveBeenCalledOnce(); + }); + it("cancels pending retry timers when disposed on unmount", async () => { vi.useFakeTimers(); const claim = vi.fn().mockResolvedValue(false); diff --git a/apps/web/src/textAttachmentClaims.ts b/apps/web/src/textAttachmentClaims.ts index 0aa795c317c..14c95b869e1 100644 --- a/apps/web/src/textAttachmentClaims.ts +++ b/apps/web/src/textAttachmentClaims.ts @@ -148,6 +148,10 @@ export async function retryTextAttachmentOperation( return false; } +export function detachedTextAttachmentReleaseComplete(result: { readonly _tag: string }): boolean { + return result._tag === "Success"; +} + export interface TextAttachmentClaimOperations { claim: (path: string, draftOwnerId: string) => Promise; release: (path: string, draftOwnerId: string) => Promise; From 628dbfbe356abd84e8d0b55c5d9cf89d0410cd90 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 18:12:31 -0400 Subject: [PATCH 48/79] Pause attachment claims during environment removal --- apps/mobile/src/connection/platform.ts | 1 + apps/web/src/connection/platform.ts | 6 ++ apps/web/src/textAttachmentClaims.test.ts | 59 +++++++++++++++++++ apps/web/src/textAttachmentClaims.ts | 40 +++++++++++-- .../src/connection/registry.test.ts | 5 ++ .../client-runtime/src/connection/registry.ts | 24 +++++++- .../src/platform/persistence.ts | 2 + 7 files changed, 130 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/connection/platform.ts b/apps/mobile/src/connection/platform.ts index 462280348f7..57bb2830db1 100644 --- a/apps/mobile/src/connection/platform.ts +++ b/apps/mobile/src/connection/platform.ts @@ -185,6 +185,7 @@ const environmentOwnedDataCleanupLayer = Layer.succeed( EnvironmentOwnedDataCleanup, EnvironmentOwnedDataCleanup.of({ prepare: () => Effect.void, + resume: () => Effect.void, clear: (environmentId) => Effect.all( [ diff --git a/apps/web/src/connection/platform.ts b/apps/web/src/connection/platform.ts index a160010a286..e720159d5d8 100644 --- a/apps/web/src/connection/platform.ts +++ b/apps/web/src/connection/platform.ts @@ -57,6 +57,8 @@ import { } from "../composerDraftStore"; import { disposeTextAttachmentClaimEnvironment, + pauseTextAttachmentClaimEnvironment, + resumeTextAttachmentClaimEnvironment, textAttachmentClaims, } from "../textAttachmentClaims"; import { isHostedStaticApp } from "../hostedPairing"; @@ -588,6 +590,7 @@ const environmentOwnedDataCleanupLayer = Layer.succeed( EnvironmentOwnedDataCleanup.of({ prepare: (environmentId, supervisor) => Effect.gen(function* () { + yield* Effect.promise(() => pauseTextAttachmentClaimEnvironment(environmentId)); if (supervisor) { const claims = composerDraftEntriesEnvironment(environmentId).flatMap( ({ target, prompt }) => textAttachmentClaims(target, prompt), @@ -604,6 +607,7 @@ const environmentOwnedDataCleanupLayer = Layer.succeed( ), ); if (releaseResult._tag === "Failure") { + resumeTextAttachmentClaimEnvironment(environmentId); return yield* new ConnectionPersistenceError({ operation: "clear-environment", message: "Could not release text attachment draft claims.", @@ -611,6 +615,8 @@ const environmentOwnedDataCleanupLayer = Layer.succeed( } } }), + resume: (environmentId) => + Effect.sync(() => resumeTextAttachmentClaimEnvironment(environmentId)), clear: (environmentId) => Effect.sync(() => { disposeTextAttachmentClaimEnvironment(environmentId); diff --git a/apps/web/src/textAttachmentClaims.test.ts b/apps/web/src/textAttachmentClaims.test.ts index 04f7efae219..1a0a55c7de4 100644 --- a/apps/web/src/textAttachmentClaims.test.ts +++ b/apps/web/src/textAttachmentClaims.test.ts @@ -11,8 +11,10 @@ import { detachTextAttachmentClaimOwner, detachedTextAttachmentReleaseComplete, getTextAttachmentClaimReconciler, + pauseTextAttachmentClaimEnvironment, reconcileTextAttachmentClaimsEnvironment, resetTextAttachmentClaimRegistryForTest, + resumeTextAttachmentClaimEnvironment, retryTextAttachmentOperation, } from "./textAttachmentClaims"; @@ -293,4 +295,61 @@ describe("text attachment claims", () => { expect(background.snapshot().confirmed).toEqual(new Set([PATH])); }); + + it("waits for an in-flight claim before environment release preparation", async () => { + const environmentId = EnvironmentId.make("preparing-environment"); + let finishClaim: (claimed: boolean) => void = () => undefined; + const operations = { + claim: vi.fn( + () => + new Promise((resolve) => { + finishClaim = resolve; + }), + ), + release: vi.fn(async () => true), + }; + const reconciler = getTextAttachmentClaimReconciler({ + environmentId, + draftOwnerId: "draft:preparing", + operations, + }); + reconciler.setDesiredPaths([PATH]); + await vi.waitFor(() => expect(operations.claim).toHaveBeenCalledOnce()); + + let prepared = false; + const prepare = pauseTextAttachmentClaimEnvironment(environmentId).then(() => { + prepared = true; + }); + await Promise.resolve(); + expect(prepared).toBe(false); + + finishClaim(true); + await prepare; + expect(prepared).toBe(true); + expect(reconciler.snapshot().confirmed).toEqual(new Set([PATH])); + }); + + it("resumes claim reconciliation after environment preparation fails", async () => { + vi.useFakeTimers(); + const environmentId = EnvironmentId.make("recoverable-environment"); + let online = false; + const operations = { + claim: vi.fn(async () => online), + release: vi.fn(async () => online), + }; + const reconciler = getTextAttachmentClaimReconciler({ + environmentId, + draftOwnerId: "draft:recoverable", + operations, + }); + reconciler.setDesiredPaths([PATH]); + await reconciler.settled(); + await pauseTextAttachmentClaimEnvironment(environmentId); + + online = true; + resumeTextAttachmentClaimEnvironment(environmentId); + await reconciler.settled(); + + expect(reconciler.snapshot().confirmed).toEqual(new Set([PATH])); + }); }); diff --git a/apps/web/src/textAttachmentClaims.ts b/apps/web/src/textAttachmentClaims.ts index 14c95b869e1..e37cd9640de 100644 --- a/apps/web/src/textAttachmentClaims.ts +++ b/apps/web/src/textAttachmentClaims.ts @@ -40,6 +40,7 @@ export class TextAttachmentClaimReconciler { #retryTimer: ReturnType | null = null; #retryCount = 0; #disposed = false; + #paused = false; constructor(options: { claim: (path: string) => Promise; @@ -62,7 +63,7 @@ export class TextAttachmentClaimReconciler { this.#desired = new Set(paths); this.#retryCount = 0; this.#clearRetry(); - this.#enqueueReconcile(); + if (!this.#paused) this.#enqueueReconcile(); } confirmPaths(paths: Iterable): void { @@ -71,7 +72,7 @@ export class TextAttachmentClaimReconciler { } reconcileNow(): void { - if (this.#disposed) return; + if (this.#disposed || this.#paused) return; this.#retryCount = 0; this.#clearRetry(); this.#enqueueReconcile(); @@ -82,6 +83,19 @@ export class TextAttachmentClaimReconciler { this.#clearRetry(); } + async pause(): Promise { + if (this.#disposed) return; + this.#paused = true; + this.#clearRetry(); + await this.#queue; + } + + resume(): void { + if (this.#disposed || !this.#paused) return; + this.#paused = false; + this.reconcileNow(); + } + snapshot(): { desired: Set; confirmed: Set } { return { desired: new Set(this.#desired), @@ -94,7 +108,7 @@ export class TextAttachmentClaimReconciler { } #enqueueReconcile(): void { - if (this.#disposed) return; + if (this.#disposed || this.#paused) return; this.#queue = this.#queue.catch(() => undefined).then(() => this.#reconcile()); } @@ -115,7 +129,7 @@ export class TextAttachmentClaimReconciler { this.#retryCount = 0; return; } - if (this.#retryTimer !== null || this.#disposed) return; + if (this.#retryTimer !== null || this.#disposed || this.#paused) return; const delay = Math.min( this.#retryDelayMs * 2 ** Math.min(this.#retryCount, 30), this.#maxRetryDelayMs, @@ -220,6 +234,24 @@ export function disposeTextAttachmentClaimEnvironment(environmentId: Environment } } +export async function pauseTextAttachmentClaimEnvironment( + environmentId: EnvironmentId, +): Promise { + const prefix = `${environmentId}:`; + await Promise.all( + [...textAttachmentClaimReconcilerRegistry.entries()].flatMap(([key, reconciler]) => + key.startsWith(prefix) ? [reconciler.pause()] : [], + ), + ); +} + +export function resumeTextAttachmentClaimEnvironment(environmentId: EnvironmentId): void { + const prefix = `${environmentId}:`; + for (const [key, reconciler] of textAttachmentClaimReconcilerRegistry) { + if (key.startsWith(prefix)) reconciler.resume(); + } +} + export function resetTextAttachmentClaimRegistryForTest(): void { for (const reconciler of textAttachmentClaimReconcilerRegistry.values()) reconciler.dispose(); textAttachmentClaimReconcilerRegistry.clear(); diff --git a/packages/client-runtime/src/connection/registry.test.ts b/packages/client-runtime/src/connection/registry.test.ts index 1b9323a3311..39ce5b9c570 100644 --- a/packages/client-runtime/src/connection/registry.test.ts +++ b/packages/client-runtime/src/connection/registry.test.ts @@ -146,6 +146,7 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( const shellCache = yield* Ref.make(new Map([[TARGET.environmentId, CACHED_SNAPSHOT]])); const cacheClears = yield* Ref.make>([]); const ownedDataClears = yield* Ref.make>([]); + const ownedDataResumes = yield* Ref.make>([]); const sessions = yield* Ref.make>([]); const releasedSessions = yield* Ref.make(0); const storedProfiles = yield* Ref.make( @@ -267,6 +268,8 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( }); const ownedDataCleanup = Persistence.EnvironmentOwnedDataCleanup.of({ prepare: (environmentId) => options?.beforeOwnedDataClear?.(environmentId) ?? Effect.void, + resume: (environmentId) => + Ref.update(ownedDataResumes, (environmentIds) => [...environmentIds, environmentId]), clear: (environmentId) => Ref.update(ownedDataClears, (environmentIds) => [...environmentIds, environmentId]), }); @@ -394,6 +397,7 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( shellCache, cacheClears, ownedDataClears, + ownedDataResumes, sessions, releasedSessions, storedProfiles, @@ -618,6 +622,7 @@ describe("EnvironmentRegistry", () => { expect((yield* Ref.get(harness.storedTargets)).has(TARGET.environmentId)).toBe(true); expect((yield* SubscriptionRef.get(registry.entries)).has(TARGET.environmentId)).toBe(true); expect(yield* Ref.get(harness.ownedDataClears)).toEqual([]); + expect(yield* Ref.get(harness.ownedDataResumes)).toEqual([TARGET.environmentId]); }).pipe(Effect.provide(harness.layer)); }), ); diff --git a/packages/client-runtime/src/connection/registry.ts b/packages/client-runtime/src/connection/registry.ts index 67e6d6e0107..7f651895afe 100644 --- a/packages/client-runtime/src/connection/registry.ts +++ b/packages/client-runtime/src/connection/registry.ts @@ -479,7 +479,13 @@ export const make = Effect.gen(function* () { Effect.gen(function* () { const entry = (yield* SubscriptionRef.get(entries)).get(environmentId); const serviceScope = (yield* SubscriptionRef.get(serviceScopes)).get(environmentId); - yield* ownedDataCleanup.prepare(environmentId, serviceScope?.supervisor); + yield* ownedDataCleanup + .prepare(environmentId, serviceScope?.supervisor) + .pipe( + Effect.catch((error) => + ownedDataCleanup.resume(environmentId).pipe(Effect.andThen(Effect.fail(error))), + ), + ); yield* Ref.update(platformEnvironmentIds, (current) => { const next = new Set(current); next.delete(environmentId); @@ -561,8 +567,20 @@ export const make = Effect.gen(function* () { : Option.none(); const serviceScope = (yield* SubscriptionRef.get(serviceScopes)).get(environmentId); - yield* ownedDataCleanup.prepare(environmentId, serviceScope?.supervisor); - yield* registrations.remove(target); + yield* ownedDataCleanup + .prepare(environmentId, serviceScope?.supervisor) + .pipe( + Effect.catch((error) => + ownedDataCleanup.resume(environmentId).pipe(Effect.andThen(Effect.fail(error))), + ), + ); + yield* registrations + .remove(target) + .pipe( + Effect.catch((error) => + ownedDataCleanup.resume(environmentId).pipe(Effect.andThen(Effect.fail(error))), + ), + ); yield* Ref.update(persistedTargetsByEnvironment, (current) => { const next = new Map(current); next.delete(environmentId); diff --git a/packages/client-runtime/src/platform/persistence.ts b/packages/client-runtime/src/platform/persistence.ts index 0df6f39fa78..94a8108e948 100644 --- a/packages/client-runtime/src/platform/persistence.ts +++ b/packages/client-runtime/src/platform/persistence.ts @@ -114,10 +114,12 @@ export class EnvironmentOwnedDataCleanup extends Context.Reference<{ environmentId: EnvironmentId, supervisor: EnvironmentSupervisor["Service"] | undefined, ) => Effect.Effect; + readonly resume: (environmentId: EnvironmentId) => Effect.Effect; readonly clear: (environmentId: EnvironmentId) => Effect.Effect; }>("@t3tools/client-runtime/platform/persistence/EnvironmentOwnedDataCleanup", { defaultValue: () => ({ prepare: () => Effect.void, + resume: () => Effect.void, clear: () => Effect.void, }), }) {} From 6533050f9bcdd283c398bbba55c0baeb6b1c10db Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 18:15:34 -0400 Subject: [PATCH 49/79] Reclaim attachment claims after removal abort --- apps/web/src/textAttachmentClaims.test.ts | 22 ++++++++++++++++++++++ apps/web/src/textAttachmentClaims.ts | 9 ++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/apps/web/src/textAttachmentClaims.test.ts b/apps/web/src/textAttachmentClaims.test.ts index 1a0a55c7de4..0b960a2939f 100644 --- a/apps/web/src/textAttachmentClaims.test.ts +++ b/apps/web/src/textAttachmentClaims.test.ts @@ -352,4 +352,26 @@ describe("text attachment claims", () => { expect(reconciler.snapshot().confirmed).toEqual(new Set([PATH])); }); + + it("reclaims paths partially released before environment preparation fails", async () => { + const environmentId = EnvironmentId.make("partially-released-environment"); + const claim = vi.fn(async () => true); + const operations = { claim, release: vi.fn(async () => true) }; + const reconciler = getTextAttachmentClaimReconciler({ + environmentId, + draftOwnerId: "draft:partial-release", + operations, + }); + reconciler.setDesiredPaths([PATH]); + await reconciler.settled(); + expect(claim).toHaveBeenCalledOnce(); + + await pauseTextAttachmentClaimEnvironment(environmentId); + // Preparation released this claim, then a later release failed. + resumeTextAttachmentClaimEnvironment(environmentId); + await reconciler.settled(); + + expect(claim).toHaveBeenCalledTimes(2); + expect(reconciler.snapshot().confirmed).toEqual(new Set([PATH])); + }); }); diff --git a/apps/web/src/textAttachmentClaims.ts b/apps/web/src/textAttachmentClaims.ts index e37cd9640de..8cf5a788350 100644 --- a/apps/web/src/textAttachmentClaims.ts +++ b/apps/web/src/textAttachmentClaims.ts @@ -71,6 +71,11 @@ export class TextAttachmentClaimReconciler { for (const path of paths) this.#confirmed.add(path); } + invalidateDesiredConfirmations(): void { + if (this.#disposed) return; + for (const path of this.#desired) this.#confirmed.delete(path); + } + reconcileNow(): void { if (this.#disposed || this.#paused) return; this.#retryCount = 0; @@ -248,7 +253,9 @@ export async function pauseTextAttachmentClaimEnvironment( export function resumeTextAttachmentClaimEnvironment(environmentId: EnvironmentId): void { const prefix = `${environmentId}:`; for (const [key, reconciler] of textAttachmentClaimReconcilerRegistry) { - if (key.startsWith(prefix)) reconciler.resume(); + if (!key.startsWith(prefix)) continue; + reconciler.invalidateDesiredConfirmations(); + reconciler.resume(); } } From 51eb42bb5e9b00f70cc20c50631210db95b91712 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 18:18:26 -0400 Subject: [PATCH 50/79] Fence text attachment uploads during teardown --- apps/web/src/components/Sidebar.tsx | 6 ++ apps/web/src/components/chat/ChatComposer.tsx | 25 ++++-- apps/web/src/connection/platform.ts | 11 ++- apps/web/src/hooks/useThreadActions.ts | 6 ++ apps/web/src/textAttachmentClaims.test.ts | 74 +++++++++++++++++ apps/web/src/textAttachmentClaims.ts | 82 +++++++++++++++++++ 6 files changed, 197 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 08a8a85da7b..ca5dc268f7e 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -122,6 +122,7 @@ import { useEnvironment, useEnvironments, usePrimaryEnvironmentId } from "../sta import { detachTextAttachmentClaimOwner, detachedTextAttachmentReleaseComplete, + fenceTextAttachmentUploadOwner, retryTextAttachmentOperation, textAttachmentClaims, textAttachmentDraftOwnerId, @@ -1459,6 +1460,11 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const discardedClaims = discardedTargets.flatMap((target) => textAttachmentClaims(target, draftStore.getComposerDraft(target)?.prompt ?? ""), ); + await Promise.all( + discardedTargets.map((target) => + fenceTextAttachmentUploadOwner(member.environmentId, textAttachmentDraftOwnerId(target)), + ), + ); const result = await deleteProject({ environmentId: member.environmentId, input: { diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 55577c03a76..ed9fcfaf960 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -93,6 +93,8 @@ import { textAttachmentDraftOwnerId, getTextAttachmentClaimReconciler as getRegisteredTextAttachmentClaimReconciler, reconcileTextAttachmentClaimsEnvironment, + retryTextAttachmentOperation, + runTextAttachmentUpload, } from "../../textAttachmentClaims"; import { cn, randomUUID } from "~/lib/utils"; import { Separator } from "../ui/separator"; @@ -1867,13 +1869,24 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const bytes = new Uint8Array(buffer); if (bytes.includes(0)) return null; const contents = new TextDecoder("utf-8", { fatal: true }).decode(bytes); - return writeTextAttachment({ + const draftOwnerId = textAttachmentDraftOwnerId(composerDraftTarget); + return runTextAttachmentUpload({ environmentId, - input: { - fileName: file.name || "context.txt", - contents, - draftOwnerId: textAttachmentDraftOwnerId(composerDraftTarget), - }, + draftOwnerId, + upload: () => + writeTextAttachment({ + environmentId, + input: { fileName: file.name || "context.txt", contents, draftOwnerId }, + }), + path: (result) => (result._tag === "Success" ? result.value.path : null), + release: (path) => + retryTextAttachmentOperation(async () => { + const released = await releaseTextAttachment({ + environmentId, + input: { path, draftOwnerId }, + }); + return released._tag === "Success"; + }).then(() => undefined), }); }) .catch(() => null); diff --git a/apps/web/src/connection/platform.ts b/apps/web/src/connection/platform.ts index e720159d5d8..03ae2c360d1 100644 --- a/apps/web/src/connection/platform.ts +++ b/apps/web/src/connection/platform.ts @@ -57,8 +57,11 @@ import { } from "../composerDraftStore"; import { disposeTextAttachmentClaimEnvironment, + clearTextAttachmentUploadEnvironment, + fenceTextAttachmentUploadEnvironment, pauseTextAttachmentClaimEnvironment, resumeTextAttachmentClaimEnvironment, + resumeTextAttachmentUploadEnvironment, textAttachmentClaims, } from "../textAttachmentClaims"; import { isHostedStaticApp } from "../hostedPairing"; @@ -590,6 +593,7 @@ const environmentOwnedDataCleanupLayer = Layer.succeed( EnvironmentOwnedDataCleanup.of({ prepare: (environmentId, supervisor) => Effect.gen(function* () { + yield* Effect.promise(() => fenceTextAttachmentUploadEnvironment(environmentId)); yield* Effect.promise(() => pauseTextAttachmentClaimEnvironment(environmentId)); if (supervisor) { const claims = composerDraftEntriesEnvironment(environmentId).flatMap( @@ -608,6 +612,7 @@ const environmentOwnedDataCleanupLayer = Layer.succeed( ); if (releaseResult._tag === "Failure") { resumeTextAttachmentClaimEnvironment(environmentId); + resumeTextAttachmentUploadEnvironment(environmentId); return yield* new ConnectionPersistenceError({ operation: "clear-environment", message: "Could not release text attachment draft claims.", @@ -616,10 +621,14 @@ const environmentOwnedDataCleanupLayer = Layer.succeed( } }), resume: (environmentId) => - Effect.sync(() => resumeTextAttachmentClaimEnvironment(environmentId)), + Effect.sync(() => { + resumeTextAttachmentClaimEnvironment(environmentId); + resumeTextAttachmentUploadEnvironment(environmentId); + }), clear: (environmentId) => Effect.sync(() => { disposeTextAttachmentClaimEnvironment(environmentId); + clearTextAttachmentUploadEnvironment(environmentId); clearComposerDraftsEnvironment(environmentId); }), }), diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index e013d864cb4..20c2d20be8a 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -30,6 +30,7 @@ import { useAtomCommand } from "../state/use-atom-command"; import { detachTextAttachmentClaimOwner, detachedTextAttachmentReleaseComplete, + fenceTextAttachmentUploadOwner, retryTextAttachmentOperation, textAttachmentClaims, textAttachmentDraftOwnerId, @@ -228,6 +229,11 @@ export function useThreadActions() { }); } + await fenceTextAttachmentUploadOwner( + threadRef.environmentId, + textAttachmentDraftOwnerId(threadRef), + ); + await closeTerminal({ environmentId: threadRef.environmentId, input: { threadId: threadRef.threadId, deleteHistory: true }, diff --git a/apps/web/src/textAttachmentClaims.test.ts b/apps/web/src/textAttachmentClaims.test.ts index 0b960a2939f..da99a68a3da 100644 --- a/apps/web/src/textAttachmentClaims.test.ts +++ b/apps/web/src/textAttachmentClaims.test.ts @@ -10,11 +10,15 @@ import { TextAttachmentClaimReconciler, detachTextAttachmentClaimOwner, detachedTextAttachmentReleaseComplete, + fenceTextAttachmentUploadEnvironment, + fenceTextAttachmentUploadOwner, getTextAttachmentClaimReconciler, pauseTextAttachmentClaimEnvironment, reconcileTextAttachmentClaimsEnvironment, resetTextAttachmentClaimRegistryForTest, resumeTextAttachmentClaimEnvironment, + resumeTextAttachmentUploadEnvironment, + runTextAttachmentUpload, retryTextAttachmentOperation, } from "./textAttachmentClaims"; @@ -374,4 +378,74 @@ describe("text attachment claims", () => { expect(claim).toHaveBeenCalledTimes(2); expect(reconciler.snapshot().confirmed).toEqual(new Set([PATH])); }); + + it("fences an in-flight thread upload and releases its late claim", async () => { + const environmentId = EnvironmentId.make("thread-upload-environment"); + let finishUpload: (result: { path: string }) => void = () => undefined; + const release = vi.fn(async () => undefined); + const upload = runTextAttachmentUpload({ + environmentId, + draftOwnerId: "thread:env:thread-a", + upload: () => + new Promise<{ path: string }>((resolve) => { + finishUpload = resolve; + }), + path: (result) => result.path, + release, + }); + + const fence = fenceTextAttachmentUploadOwner(environmentId, "thread:env:thread-a"); + finishUpload({ path: PATH }); + await fence; + + await expect(upload).resolves.toBeNull(); + expect(release).toHaveBeenCalledWith(PATH); + }); + + it("rejects new project-owner uploads after destructive fencing", async () => { + const environmentId = EnvironmentId.make("project-upload-environment"); + await fenceTextAttachmentUploadOwner(environmentId, "draft:project-draft"); + const upload = vi.fn(async () => ({ path: PATH })); + + await expect( + runTextAttachmentUpload({ + environmentId, + draftOwnerId: "draft:project-draft", + upload, + path: (result) => result.path, + release: vi.fn(async () => undefined), + }), + ).resolves.toBeNull(); + expect(upload).not.toHaveBeenCalled(); + }); + + it("fences all environment uploads and resumes them after cleanup abort", async () => { + const environmentId = EnvironmentId.make("environment-upload-fence"); + let finishUpload: (result: { path: string }) => void = () => undefined; + const first = runTextAttachmentUpload({ + environmentId, + draftOwnerId: "draft:environment-draft", + upload: () => + new Promise<{ path: string }>((resolve) => { + finishUpload = resolve; + }), + path: (result) => result.path, + release: vi.fn(async () => undefined), + }); + const fence = fenceTextAttachmentUploadEnvironment(environmentId); + finishUpload({ path: PATH }); + await fence; + await expect(first).resolves.toBeNull(); + + resumeTextAttachmentUploadEnvironment(environmentId); + await expect( + runTextAttachmentUpload({ + environmentId, + draftOwnerId: "draft:environment-draft", + upload: async () => ({ path: PATH }), + path: (result) => result.path, + release: vi.fn(async () => undefined), + }), + ).resolves.toEqual({ path: PATH }); + }); }); diff --git a/apps/web/src/textAttachmentClaims.ts b/apps/web/src/textAttachmentClaims.ts index 8cf5a788350..65374885d30 100644 --- a/apps/web/src/textAttachmentClaims.ts +++ b/apps/web/src/textAttachmentClaims.ts @@ -262,4 +262,86 @@ export function resumeTextAttachmentClaimEnvironment(environmentId: EnvironmentI export function resetTextAttachmentClaimRegistryForTest(): void { for (const reconciler of textAttachmentClaimReconcilerRegistry.values()) reconciler.dispose(); textAttachmentClaimReconcilerRegistry.clear(); + textAttachmentUploadRegistry.clear(); +} + +interface TextAttachmentUploadOwnerState { + fenced: boolean; + pending: Set>; +} + +const textAttachmentUploadRegistry = new Map(); + +function textAttachmentUploadOwnerState( + environmentId: EnvironmentId, + draftOwnerId: string, +): TextAttachmentUploadOwnerState { + const key = textAttachmentClaimRegistryKey(environmentId, draftOwnerId); + const existing = textAttachmentUploadRegistry.get(key); + if (existing) return existing; + const state = { fenced: false, pending: new Set>() }; + textAttachmentUploadRegistry.set(key, state); + return state; +} + +export async function runTextAttachmentUpload(input: { + environmentId: EnvironmentId; + draftOwnerId: string; + upload: () => Promise; + path: (result: T) => string | null; + release: (path: string) => Promise; +}): Promise { + const state = textAttachmentUploadOwnerState(input.environmentId, input.draftOwnerId); + if (state.fenced) return null; + let finish: () => void = () => undefined; + const pending = new Promise((resolve) => { + finish = resolve; + }); + state.pending.add(pending); + try { + const result = await input.upload(); + if (!state.fenced) return result; + const path = input.path(result); + if (path) await input.release(path); + return null; + } finally { + state.pending.delete(pending); + finish(); + } +} + +export async function fenceTextAttachmentUploadOwner( + environmentId: EnvironmentId, + draftOwnerId: string, +): Promise { + const state = textAttachmentUploadOwnerState(environmentId, draftOwnerId); + state.fenced = true; + await Promise.all(state.pending); +} + +export async function fenceTextAttachmentUploadEnvironment( + environmentId: EnvironmentId, +): Promise { + const prefix = `${environmentId}:`; + const waits: Promise[] = []; + for (const [key, state] of textAttachmentUploadRegistry) { + if (!key.startsWith(prefix)) continue; + state.fenced = true; + waits.push(...state.pending); + } + await Promise.all(waits); +} + +export function resumeTextAttachmentUploadEnvironment(environmentId: EnvironmentId): void { + const prefix = `${environmentId}:`; + for (const [key, state] of textAttachmentUploadRegistry) { + if (key.startsWith(prefix)) state.fenced = false; + } +} + +export function clearTextAttachmentUploadEnvironment(environmentId: EnvironmentId): void { + const prefix = `${environmentId}:`; + for (const key of textAttachmentUploadRegistry.keys()) { + if (key.startsWith(prefix)) textAttachmentUploadRegistry.delete(key); + } } From 3e14836da54fbaab7505fe7822358ba5e241901e Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 18:20:54 -0400 Subject: [PATCH 51/79] Fence newly discovered environment upload owners --- apps/web/src/textAttachmentClaims.test.ts | 12 ++++++++++++ apps/web/src/textAttachmentClaims.ts | 6 ++++++ 2 files changed, 18 insertions(+) diff --git a/apps/web/src/textAttachmentClaims.test.ts b/apps/web/src/textAttachmentClaims.test.ts index da99a68a3da..61206f9d28b 100644 --- a/apps/web/src/textAttachmentClaims.test.ts +++ b/apps/web/src/textAttachmentClaims.test.ts @@ -437,6 +437,18 @@ describe("text attachment claims", () => { await fence; await expect(first).resolves.toBeNull(); + const newOwnerUpload = vi.fn(async () => ({ path: PATH })); + await expect( + runTextAttachmentUpload({ + environmentId, + draftOwnerId: "draft:first-seen-after-prepare", + upload: newOwnerUpload, + path: (result) => result.path, + release: vi.fn(async () => undefined), + }), + ).resolves.toBeNull(); + expect(newOwnerUpload).not.toHaveBeenCalled(); + resumeTextAttachmentUploadEnvironment(environmentId); await expect( runTextAttachmentUpload({ diff --git a/apps/web/src/textAttachmentClaims.ts b/apps/web/src/textAttachmentClaims.ts index 65374885d30..5ac87acc35c 100644 --- a/apps/web/src/textAttachmentClaims.ts +++ b/apps/web/src/textAttachmentClaims.ts @@ -263,6 +263,7 @@ export function resetTextAttachmentClaimRegistryForTest(): void { for (const reconciler of textAttachmentClaimReconcilerRegistry.values()) reconciler.dispose(); textAttachmentClaimReconcilerRegistry.clear(); textAttachmentUploadRegistry.clear(); + fencedTextAttachmentUploadEnvironmentIds.clear(); } interface TextAttachmentUploadOwnerState { @@ -271,6 +272,7 @@ interface TextAttachmentUploadOwnerState { } const textAttachmentUploadRegistry = new Map(); +const fencedTextAttachmentUploadEnvironmentIds = new Set(); function textAttachmentUploadOwnerState( environmentId: EnvironmentId, @@ -291,6 +293,7 @@ export async function runTextAttachmentUpload(input: { path: (result: T) => string | null; release: (path: string) => Promise; }): Promise { + if (fencedTextAttachmentUploadEnvironmentIds.has(input.environmentId)) return null; const state = textAttachmentUploadOwnerState(input.environmentId, input.draftOwnerId); if (state.fenced) return null; let finish: () => void = () => undefined; @@ -322,6 +325,7 @@ export async function fenceTextAttachmentUploadOwner( export async function fenceTextAttachmentUploadEnvironment( environmentId: EnvironmentId, ): Promise { + fencedTextAttachmentUploadEnvironmentIds.add(environmentId); const prefix = `${environmentId}:`; const waits: Promise[] = []; for (const [key, state] of textAttachmentUploadRegistry) { @@ -333,6 +337,7 @@ export async function fenceTextAttachmentUploadEnvironment( } export function resumeTextAttachmentUploadEnvironment(environmentId: EnvironmentId): void { + fencedTextAttachmentUploadEnvironmentIds.delete(environmentId); const prefix = `${environmentId}:`; for (const [key, state] of textAttachmentUploadRegistry) { if (key.startsWith(prefix)) state.fenced = false; @@ -340,6 +345,7 @@ export function resumeTextAttachmentUploadEnvironment(environmentId: Environment } export function clearTextAttachmentUploadEnvironment(environmentId: EnvironmentId): void { + fencedTextAttachmentUploadEnvironmentIds.delete(environmentId); const prefix = `${environmentId}:`; for (const key of textAttachmentUploadRegistry.keys()) { if (key.startsWith(prefix)) textAttachmentUploadRegistry.delete(key); From 69c32e44420ccb8bbe7c4a4b582f87e9fed9869a Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 18:23:38 -0400 Subject: [PATCH 52/79] Resume owner upload fences after delete failure --- apps/web/src/components/Sidebar.tsx | 6 ++++ apps/web/src/hooks/useThreadActions.ts | 10 ++++++ apps/web/src/textAttachmentClaims.test.ts | 38 +++++++++++++++++++++++ apps/web/src/textAttachmentClaims.ts | 17 ++++++++++ 4 files changed, 71 insertions(+) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index ca5dc268f7e..04e91e65347 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -120,9 +120,11 @@ import { threadEnvironment, useEnvironmentThread } from "../state/threads"; import { vcsEnvironment } from "../state/vcs"; import { useEnvironment, useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { + clearTextAttachmentUploadOwner, detachTextAttachmentClaimOwner, detachedTextAttachmentReleaseComplete, fenceTextAttachmentUploadOwner, + resumeTextAttachmentUploadOwner, retryTextAttachmentOperation, textAttachmentClaims, textAttachmentDraftOwnerId, @@ -1473,6 +1475,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }, }); if (result._tag === "Failure") { + for (const target of discardedTargets) { + resumeTextAttachmentUploadOwner(member.environmentId, textAttachmentDraftOwnerId(target)); + } return result; } await Promise.all( @@ -1493,6 +1498,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ); for (const target of discardedTargets) { draftStore.clearDraftThread(target); + clearTextAttachmentUploadOwner(member.environmentId, textAttachmentDraftOwnerId(target)); } draftStore.clearProjectDraftThreadId(memberProjectRef); return result; diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 20c2d20be8a..8c872679357 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -28,9 +28,11 @@ import { stackedThreadToast, toastManager } from "../components/ui/toast"; import { useClientSettings } from "./useSettings"; import { useAtomCommand } from "../state/use-atom-command"; import { + clearTextAttachmentUploadOwner, detachTextAttachmentClaimOwner, detachedTextAttachmentReleaseComplete, fenceTextAttachmentUploadOwner, + resumeTextAttachmentUploadOwner, retryTextAttachmentOperation, textAttachmentClaims, textAttachmentDraftOwnerId, @@ -257,6 +259,10 @@ export function useThreadActions() { input: { threadId: threadRef.threadId }, }); if (deleteResult._tag === "Failure") { + resumeTextAttachmentUploadOwner( + threadRef.environmentId, + textAttachmentDraftOwnerId(threadRef), + ); return deleteResult; } await detachTextAttachmentClaimOwner( @@ -281,6 +287,10 @@ export function useThreadActions() { threadRef, ); clearTerminalUiState(threadRef); + clearTextAttachmentUploadOwner( + threadRef.environmentId, + textAttachmentDraftOwnerId(threadRef), + ); if (shouldNavigateToFallback) { if (fallbackThreadId) { diff --git a/apps/web/src/textAttachmentClaims.test.ts b/apps/web/src/textAttachmentClaims.test.ts index 61206f9d28b..5906a64be9e 100644 --- a/apps/web/src/textAttachmentClaims.test.ts +++ b/apps/web/src/textAttachmentClaims.test.ts @@ -10,6 +10,7 @@ import { TextAttachmentClaimReconciler, detachTextAttachmentClaimOwner, detachedTextAttachmentReleaseComplete, + clearTextAttachmentUploadOwner, fenceTextAttachmentUploadEnvironment, fenceTextAttachmentUploadOwner, getTextAttachmentClaimReconciler, @@ -18,6 +19,7 @@ import { resetTextAttachmentClaimRegistryForTest, resumeTextAttachmentClaimEnvironment, resumeTextAttachmentUploadEnvironment, + resumeTextAttachmentUploadOwner, runTextAttachmentUpload, retryTextAttachmentOperation, } from "./textAttachmentClaims"; @@ -460,4 +462,40 @@ describe("text attachment claims", () => { }), ).resolves.toEqual({ path: PATH }); }); + + it("resumes an owner upload fence after thread or project deletion fails", async () => { + const environmentId = EnvironmentId.make("failed-owner-delete"); + const draftOwnerId = "draft:failed-delete"; + await fenceTextAttachmentUploadOwner(environmentId, draftOwnerId); + resumeTextAttachmentUploadOwner(environmentId, draftOwnerId); + + await expect( + runTextAttachmentUpload({ + environmentId, + draftOwnerId, + upload: async () => ({ path: PATH }), + path: (result) => result.path, + release: vi.fn(async () => undefined), + }), + ).resolves.toEqual({ path: PATH }); + }); + + it("removes successful destruction owner fence state", async () => { + const environmentId = EnvironmentId.make("successful-owner-delete"); + const draftOwnerId = "draft:successful-delete"; + await fenceTextAttachmentUploadOwner(environmentId, draftOwnerId); + clearTextAttachmentUploadOwner(environmentId, draftOwnerId); + const upload = vi.fn(async () => ({ path: PATH })); + + await expect( + runTextAttachmentUpload({ + environmentId, + draftOwnerId, + upload, + path: (result) => result.path, + release: vi.fn(async () => undefined), + }), + ).resolves.toEqual({ path: PATH }); + expect(upload).toHaveBeenCalledOnce(); + }); }); diff --git a/apps/web/src/textAttachmentClaims.ts b/apps/web/src/textAttachmentClaims.ts index 5ac87acc35c..0a2d5ee0b62 100644 --- a/apps/web/src/textAttachmentClaims.ts +++ b/apps/web/src/textAttachmentClaims.ts @@ -322,6 +322,23 @@ export async function fenceTextAttachmentUploadOwner( await Promise.all(state.pending); } +export function resumeTextAttachmentUploadOwner( + environmentId: EnvironmentId, + draftOwnerId: string, +): void { + const state = textAttachmentUploadRegistry.get( + textAttachmentClaimRegistryKey(environmentId, draftOwnerId), + ); + if (state) state.fenced = false; +} + +export function clearTextAttachmentUploadOwner( + environmentId: EnvironmentId, + draftOwnerId: string, +): void { + textAttachmentUploadRegistry.delete(textAttachmentClaimRegistryKey(environmentId, draftOwnerId)); +} + export async function fenceTextAttachmentUploadEnvironment( environmentId: EnvironmentId, ): Promise { From aa3c9cd2a0b760cfa6ed9b6f5d8df0267f754949 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 18:26:43 -0400 Subject: [PATCH 53/79] Tombstone destroyed attachment upload owners --- apps/web/src/components/Sidebar.tsx | 7 +++++-- apps/web/src/hooks/useThreadActions.ts | 4 ++-- apps/web/src/textAttachmentClaims.test.ts | 10 +++++----- apps/web/src/textAttachmentClaims.ts | 6 ++++-- 4 files changed, 16 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 04e91e65347..b3a55cc4bd2 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -120,7 +120,7 @@ import { threadEnvironment, useEnvironmentThread } from "../state/threads"; import { vcsEnvironment } from "../state/vcs"; import { useEnvironment, useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { - clearTextAttachmentUploadOwner, + tombstoneTextAttachmentUploadOwner, detachTextAttachmentClaimOwner, detachedTextAttachmentReleaseComplete, fenceTextAttachmentUploadOwner, @@ -1498,7 +1498,10 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ); for (const target of discardedTargets) { draftStore.clearDraftThread(target); - clearTextAttachmentUploadOwner(member.environmentId, textAttachmentDraftOwnerId(target)); + tombstoneTextAttachmentUploadOwner( + member.environmentId, + textAttachmentDraftOwnerId(target), + ); } draftStore.clearProjectDraftThreadId(memberProjectRef); return result; diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 8c872679357..25b0b3e3a49 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -28,7 +28,7 @@ import { stackedThreadToast, toastManager } from "../components/ui/toast"; import { useClientSettings } from "./useSettings"; import { useAtomCommand } from "../state/use-atom-command"; import { - clearTextAttachmentUploadOwner, + tombstoneTextAttachmentUploadOwner, detachTextAttachmentClaimOwner, detachedTextAttachmentReleaseComplete, fenceTextAttachmentUploadOwner, @@ -287,7 +287,7 @@ export function useThreadActions() { threadRef, ); clearTerminalUiState(threadRef); - clearTextAttachmentUploadOwner( + tombstoneTextAttachmentUploadOwner( threadRef.environmentId, textAttachmentDraftOwnerId(threadRef), ); diff --git a/apps/web/src/textAttachmentClaims.test.ts b/apps/web/src/textAttachmentClaims.test.ts index 5906a64be9e..4851b396453 100644 --- a/apps/web/src/textAttachmentClaims.test.ts +++ b/apps/web/src/textAttachmentClaims.test.ts @@ -10,7 +10,7 @@ import { TextAttachmentClaimReconciler, detachTextAttachmentClaimOwner, detachedTextAttachmentReleaseComplete, - clearTextAttachmentUploadOwner, + tombstoneTextAttachmentUploadOwner, fenceTextAttachmentUploadEnvironment, fenceTextAttachmentUploadOwner, getTextAttachmentClaimReconciler, @@ -480,11 +480,11 @@ describe("text attachment claims", () => { ).resolves.toEqual({ path: PATH }); }); - it("removes successful destruction owner fence state", async () => { + it("keeps a successful destruction owner tombstoned", async () => { const environmentId = EnvironmentId.make("successful-owner-delete"); const draftOwnerId = "draft:successful-delete"; await fenceTextAttachmentUploadOwner(environmentId, draftOwnerId); - clearTextAttachmentUploadOwner(environmentId, draftOwnerId); + tombstoneTextAttachmentUploadOwner(environmentId, draftOwnerId); const upload = vi.fn(async () => ({ path: PATH })); await expect( @@ -495,7 +495,7 @@ describe("text attachment claims", () => { path: (result) => result.path, release: vi.fn(async () => undefined), }), - ).resolves.toEqual({ path: PATH }); - expect(upload).toHaveBeenCalledOnce(); + ).resolves.toBeNull(); + expect(upload).not.toHaveBeenCalled(); }); }); diff --git a/apps/web/src/textAttachmentClaims.ts b/apps/web/src/textAttachmentClaims.ts index 0a2d5ee0b62..2b4b6118902 100644 --- a/apps/web/src/textAttachmentClaims.ts +++ b/apps/web/src/textAttachmentClaims.ts @@ -332,11 +332,13 @@ export function resumeTextAttachmentUploadOwner( if (state) state.fenced = false; } -export function clearTextAttachmentUploadOwner( +export function tombstoneTextAttachmentUploadOwner( environmentId: EnvironmentId, draftOwnerId: string, ): void { - textAttachmentUploadRegistry.delete(textAttachmentClaimRegistryKey(environmentId, draftOwnerId)); + const state = textAttachmentUploadOwnerState(environmentId, draftOwnerId); + state.fenced = true; + state.pending.clear(); } export async function fenceTextAttachmentUploadEnvironment( From 0cd53bf184240797920062c0a9f8c081d49bf954 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 18:37:18 -0400 Subject: [PATCH 54/79] Guard environment cleanup against all exits --- apps/web/src/connection/platform.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/connection/platform.ts b/apps/web/src/connection/platform.ts index 03ae2c360d1..550319f099c 100644 --- a/apps/web/src/connection/platform.ts +++ b/apps/web/src/connection/platform.ts @@ -599,7 +599,7 @@ const environmentOwnedDataCleanupLayer = Layer.succeed( const claims = composerDraftEntriesEnvironment(environmentId).flatMap( ({ target, prompt }) => textAttachmentClaims(target, prompt), ); - const releaseResult = yield* Effect.result( + const releaseResult = yield* Effect.exit( Effect.forEach( claims, ({ path, draftOwnerId }) => From ca47555f9212958eb55cd7bfb88b433593e50c50 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 18:37:38 -0400 Subject: [PATCH 55/79] Bound destructive attachment release retries --- apps/web/src/textAttachmentClaims.test.ts | 13 +++++++++++++ apps/web/src/textAttachmentClaims.ts | 11 +++++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/apps/web/src/textAttachmentClaims.test.ts b/apps/web/src/textAttachmentClaims.test.ts index 4851b396453..1e5c36c15fa 100644 --- a/apps/web/src/textAttachmentClaims.test.ts +++ b/apps/web/src/textAttachmentClaims.test.ts @@ -188,6 +188,19 @@ describe("text attachment claims", () => { expect(operation).toHaveBeenCalledTimes(5); }); + it("bounds permanently failing destructive releases", async () => { + vi.useFakeTimers(); + const operation = vi.fn().mockResolvedValue(false); + const result = retryTextAttachmentOperation(operation, { + retryDelayMs: 10, + maxRetryDelayMs: 10, + maxAttempts: 3, + }); + await vi.advanceTimersByTimeAsync(30); + await expect(result).resolves.toBe(false); + expect(operation).toHaveBeenCalledTimes(3); + }); + it("waits for an in-flight claim before detaching a destructively cleared owner", async () => { const environmentId = EnvironmentId.make("destructive-environment"); const draftOwnerId = "draft:destructive"; diff --git a/apps/web/src/textAttachmentClaims.ts b/apps/web/src/textAttachmentClaims.ts index 2b4b6118902..839f7b4a2d8 100644 --- a/apps/web/src/textAttachmentClaims.ts +++ b/apps/web/src/textAttachmentClaims.ts @@ -155,12 +155,19 @@ export class TextAttachmentClaimReconciler { export async function retryTextAttachmentOperation( operation: () => Promise, - options: { retryDelayMs?: number; maxRetryDelayMs?: number; signal?: AbortSignal } = {}, + options: { + retryDelayMs?: number; + maxRetryDelayMs?: number; + maxAttempts?: number; + signal?: AbortSignal; + } = {}, ): Promise { const retryDelayMs = options.retryDelayMs ?? 100; const maxRetryDelayMs = options.maxRetryDelayMs ?? 30_000; - for (let attempt = 0; !options.signal?.aborted; attempt += 1) { + const maxAttempts = options.maxAttempts ?? 5; + for (let attempt = 0; attempt < maxAttempts && !options.signal?.aborted; attempt += 1) { if (await operation()) return true; + if (attempt + 1 >= maxAttempts) return false; const delay = Math.min(retryDelayMs * 2 ** Math.min(attempt, 30), maxRetryDelayMs); await new Promise((resolve) => setTimeout(resolve, delay)); } From c0021ce77327127b41873c8265dd461e97a29058 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 18:37:43 -0400 Subject: [PATCH 56/79] Roll back text attachments when claiming fails --- apps/server/src/attachmentStore.test.ts | 23 ++++++++++++++ apps/server/src/attachmentStore.ts | 30 ++++++++++++++++++ apps/server/src/ws.ts | 41 ++++++++----------------- 3 files changed, 65 insertions(+), 29 deletions(-) diff --git a/apps/server/src/attachmentStore.test.ts b/apps/server/src/attachmentStore.test.ts index f69f70ad6ce..0a25784ba6e 100644 --- a/apps/server/src/attachmentStore.test.ts +++ b/apps/server/src/attachmentStore.test.ts @@ -15,6 +15,7 @@ import { releaseTextAttachment, resolveAttachmentPathById, TEXT_ATTACHMENT_DELETE_GRACE_MS, + writeClaimedTextAttachment, textAttachmentDirectory, } from "./attachmentStore.ts"; @@ -98,6 +99,28 @@ describe("attachmentStore", () => { expect(attachmentPath).not.toContain(`${NodePath.sep}..${NodePath.sep}`); }); + it("removes a written attachment when its initial claim fails", () => { + const attachmentsDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3code-text-write-claim-"), + ); + try { + expect(() => + writeClaimedTextAttachment( + { + attachmentsDir, + fileName: "context.md", + contents: "context", + draftOwnerId: "draft-owner", + }, + () => false, + ), + ).toThrow(/initial text attachment claim/); + expect(NodeFS.readdirSync(NodePath.join(attachmentsDir, "text"))).toEqual([]); + } finally { + NodeFS.rmSync(attachmentsDir, { recursive: true, force: true }); + } + }); + it("bounds text attachment basenames and avoids Windows reserved names", () => { const attachmentsDir = NodePath.join(NodeOS.tmpdir(), "t3code-attachments"); const reservedPath = createTextAttachmentPath({ attachmentsDir, fileName: "CON.ts" }); diff --git a/apps/server/src/attachmentStore.ts b/apps/server/src/attachmentStore.ts index f522adc5927..44ded19a8e3 100644 --- a/apps/server/src/attachmentStore.ts +++ b/apps/server/src/attachmentStore.ts @@ -270,6 +270,36 @@ export function claimTextAttachment(input: { return true; } +export function writeClaimedTextAttachment( + input: { + readonly attachmentsDir: string; + readonly fileName: string; + readonly contents: string; + readonly draftOwnerId: string; + }, + claim: typeof claimTextAttachment = claimTextAttachment, +): string { + const attachmentPath = createTextAttachmentPath(input); + const directory = NodePath.dirname(attachmentPath); + try { + NodeFS.mkdirSync(directory, { recursive: true }); + NodeFS.writeFileSync(attachmentPath, input.contents); + if ( + !claim({ + attachmentsDir: input.attachmentsDir, + path: attachmentPath, + draftOwnerId: input.draftOwnerId, + }) + ) { + throw new Error("Failed to create the initial text attachment claim."); + } + return attachmentPath; + } catch (cause) { + NodeFS.rmSync(directory, { recursive: true, force: true }); + throw cause; + } +} + export function releaseTextAttachment(input: { readonly attachmentsDir: string; readonly path: string; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 677adf15627..b97e5dab638 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -92,8 +92,8 @@ import * as PreviewManager from "./preview/Manager.ts"; import { issueAssetUrl } from "./assets/AssetAccess.ts"; import { claimTextAttachment, - createTextAttachmentPath, releaseTextAttachment, + writeClaimedTextAttachment, } from "./attachmentStore.ts"; import * as PortScanner from "./preview/PortScanner.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; @@ -1538,34 +1538,17 @@ const makeWsRpcLayer = ( [WS_METHODS.assetsWriteTextAttachment]: (input) => observeRpcEffect( WS_METHODS.assetsWriteTextAttachment, - Effect.gen(function* () { - const attachmentPath = createTextAttachmentPath({ - attachmentsDir: config.attachmentsDir, - fileName: input.fileName, - }); - yield* fileSystem - .makeDirectory(path.dirname(attachmentPath), { recursive: true }) - .pipe( - Effect.andThen(fileSystem.writeFileString(attachmentPath, input.contents)), - Effect.mapError( - (cause) => - new AssetTextAttachmentWriteError({ - fileName: input.fileName, - cause, - }), - ), - ); - yield* Effect.try({ - try: () => - claimTextAttachment({ - attachmentsDir: config.attachmentsDir, - path: attachmentPath, - draftOwnerId: input.draftOwnerId, - }), - catch: (cause) => - new AssetTextAttachmentWriteError({ fileName: input.fileName, cause }), - }); - return { path: attachmentPath }; + Effect.try({ + try: () => ({ + path: writeClaimedTextAttachment({ + attachmentsDir: config.attachmentsDir, + fileName: input.fileName, + contents: input.contents, + draftOwnerId: input.draftOwnerId, + }), + }), + catch: (cause) => + new AssetTextAttachmentWriteError({ fileName: input.fileName, cause }), }), { "rpc.aggregate": "workspace" }, ), From dc00250f1df112d8ae417342175cf86186530821 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 18:37:59 -0400 Subject: [PATCH 57/79] Clean up archived thread attachment owners --- apps/web/src/hooks/useThreadActions.ts | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 25b0b3e3a49..ad4ffad0465 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -165,11 +165,32 @@ export function useThreadActions() { async (target: ScopedThreadRef, opts: { deletedThreadKeys?: ReadonlySet } = {}) => { const resolved = resolveThreadTarget(target); if (!resolved) { - // Thread not in main store (e.g. archived thread) — dispatch delete directly. + const draftOwnerId = textAttachmentDraftOwnerId(target); + const discardedPrompt = + useComposerDraftStore.getState().getComposerDraft(target)?.prompt ?? ""; + await fenceTextAttachmentUploadOwner(target.environmentId, draftOwnerId); const result = await deleteThreadMutation({ environmentId: target.environmentId, input: { threadId: target.threadId }, }); + if (result._tag === "Failure") { + resumeTextAttachmentUploadOwner(target.environmentId, draftOwnerId); + return result; + } + await detachTextAttachmentClaimOwner(target.environmentId, draftOwnerId); + await Promise.all( + textAttachmentClaims(target, discardedPrompt).map(({ path }) => + retryTextAttachmentOperation(async () => { + const released = await releaseTextAttachment({ + environmentId: target.environmentId, + input: { path, draftOwnerId }, + }); + return detachedTextAttachmentReleaseComplete(released); + }), + ), + ); + clearComposerDraftForThread(target); + tombstoneTextAttachmentUploadOwner(target.environmentId, draftOwnerId); if (result._tag === "Success") { refreshArchivedThreadsForEnvironment(target.environmentId); } From 0a3d75795f890679cf805e0565294510bf55eef5 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 18:39:43 -0400 Subject: [PATCH 58/79] Parse balanced Markdown attachment links --- apps/server/src/attachmentStore.test.ts | 4 +-- apps/server/src/attachmentStore.ts | 7 ++--- apps/web/src/textAttachmentPaths.test.ts | 6 ++++ apps/web/src/textAttachmentPaths.ts | 7 ++--- packages/shared/package.json | 4 +++ packages/shared/src/markdownLinks.test.ts | 13 ++++++++ packages/shared/src/markdownLinks.ts | 37 +++++++++++++++++++++++ 7 files changed, 66 insertions(+), 12 deletions(-) create mode 100644 packages/shared/src/markdownLinks.test.ts create mode 100644 packages/shared/src/markdownLinks.ts diff --git a/apps/server/src/attachmentStore.test.ts b/apps/server/src/attachmentStore.test.ts index 0a25784ba6e..76b8eee975b 100644 --- a/apps/server/src/attachmentStore.test.ts +++ b/apps/server/src/attachmentStore.test.ts @@ -135,7 +135,7 @@ describe("attachmentStore", () => { }); it("validates and collects server-owned text attachment paths", () => { - const attachmentsDir = NodePath.join(NodeOS.tmpdir(), "t3code-attachments"); + const attachmentsDir = NodePath.join(NodeOS.tmpdir(), "t3code-(attachments)"); const attachmentPath = createTextAttachmentPath({ attachmentsDir, fileName: "notes.txt" }); const encodedPath = encodeURI(attachmentPath).replaceAll("\\", "%5C"); @@ -145,7 +145,7 @@ describe("attachmentStore", () => { expect( collectTextAttachmentRelativePaths({ attachmentsDir, - text: `[notes.txt](${encodedPath})`, + text: `before[notes.txt](${encodedPath}),after`, }), ).toEqual(new Set([NodePath.relative(attachmentsDir, attachmentPath).replaceAll("\\", "/")])); expect( diff --git a/apps/server/src/attachmentStore.ts b/apps/server/src/attachmentStore.ts index 44ded19a8e3..5d4789e3ddc 100644 --- a/apps/server/src/attachmentStore.ts +++ b/apps/server/src/attachmentStore.ts @@ -4,6 +4,7 @@ import * as NodeFS from "node:fs"; import * as NodePath from "node:path"; import type { ChatAttachment } from "@t3tools/contracts"; +import { markdownLinkDestinations } from "@t3tools/shared/markdownLinks"; import { normalizeAttachmentRelativePath, @@ -164,16 +165,12 @@ export function textAttachmentDirectory(input: { return relativePath ? NodePath.join(input.attachmentsDir, NodePath.dirname(relativePath)) : null; } -const MARKDOWN_LINK_DESTINATION_PATTERN = /\]\(([^)\s]+)\)/g; - export function collectTextAttachmentRelativePaths(input: { readonly attachmentsDir: string; readonly text: string; }): Set { const paths = new Set(); - for (const match of input.text.matchAll(MARKDOWN_LINK_DESTINATION_PATTERN)) { - const encodedPath = match[1]; - if (!encodedPath) continue; + for (const encodedPath of markdownLinkDestinations(input.text)) { let path = encodedPath; try { path = decodeURIComponent(encodedPath); diff --git a/apps/web/src/textAttachmentPaths.test.ts b/apps/web/src/textAttachmentPaths.test.ts index 88b8e943cce..ab109a8a3df 100644 --- a/apps/web/src/textAttachmentPaths.test.ts +++ b/apps/web/src/textAttachmentPaths.test.ts @@ -19,6 +19,12 @@ describe("textAttachmentPaths", () => { expect(textAttachmentPaths(`[notes.txt](${path})`)).toEqual([path]); }); + + it("collects adjacent links whose absolute paths contain parentheses", () => { + const path = "/var/t3-(data)/attachments/text/12345678-1234-1234-1234-123456789abc/notes.txt"; + + expect(textAttachmentPaths(`before[notes.txt](${path}),after`)).toEqual([path]); + }); }); describe("removedTextAttachmentPaths", () => { diff --git a/apps/web/src/textAttachmentPaths.ts b/apps/web/src/textAttachmentPaths.ts index bb4de284ec1..f2d668309c6 100644 --- a/apps/web/src/textAttachmentPaths.ts +++ b/apps/web/src/textAttachmentPaths.ts @@ -7,8 +7,6 @@ const LEGACY_FLAT_TEXT_ATTACHMENT_PATH_PATTERN = new RegExp( `(?:^|[\\\\/])\\.t3[\\\\/]attachments[\\\\/]${UUID_PATH_SEGMENT}-[^\\\\/]+$`, "i", ); -const MARKDOWN_LINK_DESTINATION_PATTERN = /(?:^|\s)\[(?:\\.|[^\]\\])*\]\(([^)\s]+)\)(?=\s|$)/g; - export function isTextAttachmentPath(path: string): boolean { return ( TEXT_ATTACHMENT_PATH_PATTERN.test(path) || LEGACY_FLAT_TEXT_ATTACHMENT_PATH_PATTERN.test(path) @@ -17,9 +15,7 @@ export function isTextAttachmentPath(path: string): boolean { export function textAttachmentPaths(prompt: string): string[] { const paths = new Set(); - for (const match of prompt.matchAll(MARKDOWN_LINK_DESTINATION_PATTERN)) { - const encodedPath = match[1]; - if (!encodedPath) continue; + for (const encodedPath of markdownLinkDestinations(prompt)) { let path = encodedPath; try { path = decodeURIComponent(encodedPath); @@ -47,3 +43,4 @@ export function unreferencedTextAttachmentPaths( ), ]; } +import { markdownLinkDestinations } from "@t3tools/shared/markdownLinks"; diff --git a/packages/shared/package.json b/packages/shared/package.json index e08844cbfae..9a0af6f2071 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -147,6 +147,10 @@ "types": "./src/composerInlineTokens.ts", "import": "./src/composerInlineTokens.ts" }, + "./markdownLinks": { + "types": "./src/markdownLinks.ts", + "import": "./src/markdownLinks.ts" + }, "./terminalLabels": { "types": "./src/terminalLabels.ts", "import": "./src/terminalLabels.ts" diff --git a/packages/shared/src/markdownLinks.test.ts b/packages/shared/src/markdownLinks.test.ts new file mode 100644 index 00000000000..4aa4eb377f5 --- /dev/null +++ b/packages/shared/src/markdownLinks.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { markdownLinkDestinations } from "./markdownLinks.ts"; + +describe("markdownLinkDestinations", () => { + it("parses adjacent punctuation and balanced destination parentheses", () => { + expect( + markdownLinkDestinations( + "prefix[one](/tmp/attachments/text/id/one.txt),[two](/tmp/a(b)/two.txt).", + ), + ).toEqual(["/tmp/attachments/text/id/one.txt", "/tmp/a(b)/two.txt"]); + }); +}); diff --git a/packages/shared/src/markdownLinks.ts b/packages/shared/src/markdownLinks.ts new file mode 100644 index 00000000000..ddada842de4 --- /dev/null +++ b/packages/shared/src/markdownLinks.ts @@ -0,0 +1,37 @@ +export function markdownLinkDestinations(markdown: string): ReadonlyArray { + const destinations: Array = []; + let searchFrom = 0; + while (searchFrom < markdown.length) { + const destinationStart = markdown.indexOf("](", searchFrom); + if (destinationStart < 0) break; + let depth = 1; + let escaped = false; + let cursor = destinationStart + 2; + const contentStart = cursor; + for (; cursor < markdown.length; cursor += 1) { + const character = markdown[cursor]; + if (escaped) { + escaped = false; + continue; + } + if (character === "\\") { + escaped = true; + continue; + } + if (character === "(") { + depth += 1; + continue; + } + if (character !== ")") continue; + depth -= 1; + if (depth === 0) break; + } + if (depth === 0 && cursor > contentStart) { + destinations.push(markdown.slice(contentStart, cursor)); + searchFrom = cursor + 1; + } else { + searchFrom = destinationStart + 2; + } + } + return destinations; +} From f4843b5fb6750ad4a652686707ce63465dc0eb3b Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 18:41:06 -0400 Subject: [PATCH 59/79] Preserve due attachments when metadata is unreadable --- .../Layers/ProjectionPipeline.test.ts | 41 +++++++++++++++++++ .../Layers/ProjectionPipeline.ts | 32 ++++++++------- 2 files changed, 59 insertions(+), 14 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 1ef563d5f82..6ed7e75798a 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -41,6 +41,7 @@ import { releaseTextAttachment, textAttachmentRelativePath, TEXT_ATTACHMENT_DELETE_GRACE_MS, + TEXT_ATTACHMENT_METADATA_FILE, TEXT_ATTACHMENT_PENDING_DIRECTORY, } from "../../attachmentStore.ts"; @@ -123,6 +124,46 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-text-expiry-swe assert.isFalse(yield* exists(attachmentPath)); }), ); + + it.effect("preserves a due attachment when metadata cannot be decoded", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { attachmentsDir } = yield* ServerConfig; + const attachmentPath = path.join( + attachmentsDir, + "text", + "00000000-0000-4000-8000-000000000011", + "corrupt.txt", + ); + yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true }); + yield* fileSystem.writeFileString(attachmentPath, "preserve"); + claimTextAttachment({ + attachmentsDir, + path: attachmentPath, + draftOwnerId: "corrupt-owner", + }); + releaseTextAttachment({ + attachmentsDir, + path: attachmentPath, + draftOwnerId: "corrupt-owner", + nowMs: -TEXT_ATTACHMENT_DELETE_GRACE_MS - 1, + }); + const metadataPath = path.join(path.dirname(attachmentPath), TEXT_ATTACHMENT_METADATA_FILE); + const markerPath = path.join( + attachmentsDir, + "text", + TEXT_ATTACHMENT_PENDING_DIRECTORY, + "00000000-0000-4000-8000-000000000011.json", + ); + yield* fileSystem.writeFileString(metadataPath, "not-json"); + + yield* reconcileDueTextAttachments(attachmentsDir, Effect.succeed(new Set())); + + assert.isTrue(yield* exists(attachmentPath)); + assert.isTrue(yield* exists(markerPath)); + }), + ); }, ); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index dcfb8b8ff0b..13503ad15b8 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -652,25 +652,29 @@ const removeDueTextAttachments = Effect.fn("removeDueTextAttachments")(function* yield* fileSystem.remove(candidate.markerPath, { force: true }); return; } - const metadata = yield* fileSystem + const metadataResult = yield* fileSystem .readFileString(path.join(candidate.directory, TEXT_ATTACHMENT_METADATA_FILE)) .pipe( Effect.flatMap(Schema.decodeUnknownEffect(Schema.UnknownFromJsonString)), - Effect.orElseSucceed(() => null), + Effect.result, ); - if (typeof metadata === "object" && metadata !== null) { - const claims = - "claims" in metadata && Array.isArray(metadata.claims) ? metadata.claims : []; - const deleteAfter = - "deleteAfter" in metadata && typeof metadata.deleteAfter === "number" - ? metadata.deleteAfter - : null; - if (claims.length > 0 || deleteAfter === null) { - yield* fileSystem.remove(candidate.markerPath, { force: true }); - return; - } - if (deleteAfter > nowMs) return; + if (metadataResult._tag === "Failure") return; + const metadata = metadataResult.success; + if ( + typeof metadata !== "object" || + metadata === null || + !("claims" in metadata) || + !Array.isArray(metadata.claims) || + !("deleteAfter" in metadata) || + (metadata.deleteAfter !== null && typeof metadata.deleteAfter !== "number") + ) { + return; + } + if (metadata.claims.length > 0 || metadata.deleteAfter === null) { + yield* fileSystem.remove(candidate.markerPath, { force: true }); + return; } + if (metadata.deleteAfter > nowMs) return; yield* fileSystem.remove(candidate.directory, { recursive: true, force: true }); yield* fileSystem.remove(candidate.markerPath, { force: true }); }), From db4a3c500cfad725e15fc055414cb781668bfbb2 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 18:41:49 -0400 Subject: [PATCH 60/79] Resume owned data after removal aborts --- .../src/connection/registry.test.ts | 54 ++++- .../client-runtime/src/connection/registry.ts | 190 +++++++++--------- 2 files changed, 144 insertions(+), 100 deletions(-) diff --git a/packages/client-runtime/src/connection/registry.test.ts b/packages/client-runtime/src/connection/registry.test.ts index 39ce5b9c570..9ed069dd10a 100644 --- a/packages/client-runtime/src/connection/registry.test.ts +++ b/packages/client-runtime/src/connection/registry.test.ts @@ -138,6 +138,7 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( readonly beforeOwnedDataClear?: ( environmentId: EnvironmentId, ) => Effect.Effect; + readonly beforeCacheClear?: (environmentId: EnvironmentId) => Effect.Effect; }, ) { const storedTargets = yield* Ref.make( @@ -256,11 +257,14 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( loadVcsRefs: () => Effect.succeed(Option.none()), saveVcsRefs: () => Effect.void, clear: (environmentId) => - Ref.update(shellCache, (current) => { - const next = new Map(current); - next.delete(environmentId); - return next; - }).pipe( + (options?.beforeCacheClear?.(environmentId) ?? Effect.void).pipe( + Effect.andThen( + Ref.update(shellCache, (current) => { + const next = new Map(current); + next.delete(environmentId); + return next; + }), + ), Effect.andThen( Ref.update(cacheClears, (environmentIds) => [...environmentIds, environmentId]), ), @@ -627,6 +631,46 @@ describe("EnvironmentRegistry", () => { }), ); + it.effect("resumes owned data when removal is interrupted", () => + Effect.gen(function* () { + const harness = yield* makeHarness([TARGET], [], [], { + beforeRegistrationRemove: () => Effect.interrupt, + }); + + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + yield* registry.start; + + const exit = yield* Effect.exit(registry.remove(TARGET.environmentId)); + + expect(exit._tag).toBe("Failure"); + expect((yield* Ref.get(harness.storedTargets)).has(TARGET.environmentId)).toBe(true); + expect((yield* SubscriptionRef.get(registry.entries)).has(TARGET.environmentId)).toBe(true); + expect(yield* Ref.get(harness.ownedDataClears)).toEqual([]); + expect(yield* Ref.get(harness.ownedDataResumes)).toEqual([TARGET.environmentId]); + }).pipe(Effect.provide(harness.layer)); + }), + ); + + it.effect("resumes owned data when platform removal defects", () => + Effect.gen(function* () { + const harness = yield* makeHarness([], [], [], { + beforeCacheClear: () => Effect.die("cache defect"), + }); + + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + yield* registry.registerPlatform(new PrimaryConnectionRegistration({ target: TARGET })); + + const exit = yield* Effect.exit(registry.reconcilePlatform([])); + + expect(exit._tag).toBe("Failure"); + expect(yield* Ref.get(harness.ownedDataClears)).toEqual([]); + expect(yield* Ref.get(harness.ownedDataResumes)).toEqual([TARGET.environmentId]); + }).pipe(Effect.provide(harness.layer)); + }), + ); + it.effect("moves durable streams to a replacement supervisor", () => Effect.gen(function* () { const replacement = new RelayConnectionTarget({ diff --git a/packages/client-runtime/src/connection/registry.ts b/packages/client-runtime/src/connection/registry.ts index 7f651895afe..eba0e25e848 100644 --- a/packages/client-runtime/src/connection/registry.ts +++ b/packages/client-runtime/src/connection/registry.ts @@ -246,6 +246,16 @@ export const make = Effect.gen(function* () { yield* Scope.close(lease.scope, Exit.void); }); + const resumeOwnedDataOnFailure = ( + environmentId: EnvironmentId, + effect: Effect.Effect, + ): Effect.Effect => + effect.pipe( + Effect.onExit((exit) => + Exit.isFailure(exit) ? ownedDataCleanup.resume(environmentId) : Effect.void, + ), + ); + const createServiceScope = Effect.fn("EnvironmentRegistry.createServiceScope")( (entry: ConnectionCatalogEntry) => Effect.uninterruptible( @@ -476,52 +486,49 @@ export const make = Effect.gen(function* () { function* (environmentId: EnvironmentId) { yield* withLeaseLock( environmentId, - Effect.gen(function* () { - const entry = (yield* SubscriptionRef.get(entries)).get(environmentId); - const serviceScope = (yield* SubscriptionRef.get(serviceScopes)).get(environmentId); - yield* ownedDataCleanup - .prepare(environmentId, serviceScope?.supervisor) - .pipe( - Effect.catch((error) => - ownedDataCleanup.resume(environmentId).pipe(Effect.andThen(Effect.fail(error))), - ), - ); - yield* Ref.update(platformEnvironmentIds, (current) => { - const next = new Set(current); - next.delete(environmentId); - return next; - }); - yield* closeServiceScope(environmentId); - yield* SubscriptionRef.update(entries, (current) => { - const next = new Map(current); - next.delete(environmentId); - return next; - }); - yield* ownedDataCleanup.clear(environmentId); - if (entry !== undefined && entry.target._tag === "BearerConnectionTarget") { - yield* credentials.remove(entry.target.connectionId).pipe( - Effect.catch((error) => - Effect.logWarning("Could not clear the platform bearer credential.", { - environmentId, - error, - }), - ), - ); - } - yield* Effect.all( - [ - cache.clear(environmentId).pipe( + resumeOwnedDataOnFailure( + environmentId, + Effect.gen(function* () { + const entry = (yield* SubscriptionRef.get(entries)).get(environmentId); + const serviceScope = (yield* SubscriptionRef.get(serviceScopes)).get(environmentId); + yield* ownedDataCleanup.prepare(environmentId, serviceScope?.supervisor); + yield* Ref.update(platformEnvironmentIds, (current) => { + const next = new Set(current); + next.delete(environmentId); + return next; + }); + yield* closeServiceScope(environmentId); + yield* SubscriptionRef.update(entries, (current) => { + const next = new Map(current); + next.delete(environmentId); + return next; + }); + if (entry !== undefined && entry.target._tag === "BearerConnectionTarget") { + yield* credentials.remove(entry.target.connectionId).pipe( Effect.catch((error) => - Effect.logWarning("Could not clear cached environment data after removal.", { + Effect.logWarning("Could not clear the platform bearer credential.", { environmentId, error, }), ), - ), - ], - { concurrency: "unbounded", discard: true }, - ); - }), + ); + } + yield* Effect.all( + [ + cache.clear(environmentId).pipe( + Effect.catch((error) => + Effect.logWarning("Could not clear cached environment data after removal.", { + environmentId, + error, + }), + ), + ), + ], + { concurrency: "unbounded", discard: true }, + ); + yield* ownedDataCleanup.clear(environmentId); + }), + ), ); }, ); @@ -567,61 +574,54 @@ export const make = Effect.gen(function* () { : Option.none(); const serviceScope = (yield* SubscriptionRef.get(serviceScopes)).get(environmentId); - yield* ownedDataCleanup - .prepare(environmentId, serviceScope?.supervisor) - .pipe( - Effect.catch((error) => - ownedDataCleanup.resume(environmentId).pipe(Effect.andThen(Effect.fail(error))), - ), - ); - yield* registrations - .remove(target) - .pipe( - Effect.catch((error) => - ownedDataCleanup.resume(environmentId).pipe(Effect.andThen(Effect.fail(error))), - ), - ); - yield* Ref.update(persistedTargetsByEnvironment, (current) => { - const next = new Map(current); - next.delete(environmentId); - return next; - }); - yield* closeServiceScope(environmentId); - yield* SubscriptionRef.update(entries, (current) => { - const next = new Map(current); - next.delete(environmentId); - return next; - }); - yield* ownedDataCleanup.clear(environmentId); - yield* Effect.all( - [ - cache.clear(environmentId).pipe( - Effect.catch((error) => - Effect.logWarning("Could not clear cached environment data after removal.", { - environmentId, - error, - }), - ), - ), - ], - { concurrency: "unbounded", discard: true }, - ); + yield* resumeOwnedDataOnFailure( + environmentId, + Effect.gen(function* () { + yield* ownedDataCleanup.prepare(environmentId, serviceScope?.supervisor); + yield* registrations.remove(target); + yield* Ref.update(persistedTargetsByEnvironment, (current) => { + const next = new Map(current); + next.delete(environmentId); + return next; + }); + yield* closeServiceScope(environmentId); + yield* SubscriptionRef.update(entries, (current) => { + const next = new Map(current); + next.delete(environmentId); + return next; + }); + yield* Effect.all( + [ + cache.clear(environmentId).pipe( + Effect.catch((error) => + Effect.logWarning("Could not clear cached environment data after removal.", { + environmentId, + error, + }), + ), + ), + ], + { concurrency: "unbounded", discard: true }, + ); - if ( - target._tag === "SshConnectionTarget" && - Option.isSome(profile) && - isSshConnectionProfile(profile.value) - ) { - yield* ssh.disconnect(profile.value.target).pipe( - Effect.tapError((error) => - Effect.logWarning("Could not disconnect the managed SSH environment.", { - environmentId, - error, - }), - ), - Effect.ignore, - ); - } + if ( + target._tag === "SshConnectionTarget" && + Option.isSome(profile) && + isSshConnectionProfile(profile.value) + ) { + yield* ssh.disconnect(profile.value.target).pipe( + Effect.tapError((error) => + Effect.logWarning("Could not disconnect the managed SSH environment.", { + environmentId, + error, + }), + ), + Effect.ignore, + ); + } + yield* ownedDataCleanup.clear(environmentId); + }), + ); }), ); }); From 97977a435f05f829235643553e9321d76040615f Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 18:42:10 -0400 Subject: [PATCH 61/79] Reset expiry when attachments become retained --- .../Layers/ProjectionPipeline.test.ts | 53 +++++++++++++++++++ .../Layers/ProjectionPipeline.ts | 39 +++++++++----- 2 files changed, 78 insertions(+), 14 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 6ed7e75798a..b5ee84bcfa9 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -164,6 +164,59 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-text-expiry-swe assert.isTrue(yield* exists(markerPath)); }), ); + + it.effect("clears pending expiry when a due attachment becomes retained", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { attachmentsDir } = yield* ServerConfig; + const attachmentPath = path.join( + attachmentsDir, + "text", + "00000000-0000-4000-8000-000000000012", + "retained.txt", + ); + yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true }); + yield* fileSystem.writeFileString(attachmentPath, "retained"); + claimTextAttachment({ + attachmentsDir, + path: attachmentPath, + draftOwnerId: "retained-owner", + }); + releaseTextAttachment({ + attachmentsDir, + path: attachmentPath, + draftOwnerId: "retained-owner", + nowMs: -TEXT_ATTACHMENT_DELETE_GRACE_MS - 1, + }); + const relativePath = textAttachmentRelativePath({ + attachmentsDir, + path: attachmentPath, + }); + assert.isNotNull(relativePath); + + yield* reconcileDueTextAttachments( + attachmentsDir, + Effect.succeed(new Set(relativePath ? [relativePath] : [])), + ); + + assert.isFalse( + releaseTextAttachment({ + attachmentsDir, + path: attachmentPath, + draftOwnerId: "retained-owner", + nowMs: 0, + }), + ); + reconcileTextAttachments({ + attachmentsDir, + retainedRelativePaths: new Set(), + nowMs: 0, + }); + yield* reconcileDueTextAttachments(attachmentsDir, Effect.succeed(new Set())); + assert.isTrue(yield* exists(attachmentPath)); + }), + ); }, ); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 13503ad15b8..6652aaf601a 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -590,6 +590,18 @@ interface DueTextAttachment { readonly relativeDirectory: string; } +const TextAttachmentMetadataSchema = Schema.Struct({ + version: Schema.Literal(1), + claims: Schema.Array(Schema.String), + deleteAfter: Schema.NullOr(Schema.Number), +}); +const decodeTextAttachmentMetadata = Schema.decodeUnknownEffect( + Schema.fromJsonString(TextAttachmentMetadataSchema), +); +const encodeTextAttachmentMetadata = Schema.encodeEffect( + Schema.fromJsonString(TextAttachmentMetadataSchema), +); + const readDueTextAttachments = Effect.fn("readDueTextAttachments")(function* ( attachmentsDir: string, nowMs: number, @@ -649,27 +661,26 @@ const removeDueTextAttachments = Effect.fn("removeDueTextAttachments")(function* (candidate) => Effect.gen(function* () { if (retainedDirectories.has(candidate.relativeDirectory)) { + const metadataPath = path.join(candidate.directory, TEXT_ATTACHMENT_METADATA_FILE); + const metadataResult = yield* fileSystem + .readFileString(metadataPath) + .pipe(Effect.flatMap(decodeTextAttachmentMetadata), Effect.result); + if (metadataResult._tag === "Failure") return; + if (metadataResult.success.deleteAfter !== null) { + const encoded = yield* encodeTextAttachmentMetadata({ + ...metadataResult.success, + deleteAfter: null, + }); + yield* fileSystem.writeFileString(metadataPath, encoded); + } yield* fileSystem.remove(candidate.markerPath, { force: true }); return; } const metadataResult = yield* fileSystem .readFileString(path.join(candidate.directory, TEXT_ATTACHMENT_METADATA_FILE)) - .pipe( - Effect.flatMap(Schema.decodeUnknownEffect(Schema.UnknownFromJsonString)), - Effect.result, - ); + .pipe(Effect.flatMap(decodeTextAttachmentMetadata), Effect.result); if (metadataResult._tag === "Failure") return; const metadata = metadataResult.success; - if ( - typeof metadata !== "object" || - metadata === null || - !("claims" in metadata) || - !Array.isArray(metadata.claims) || - !("deleteAfter" in metadata) || - (metadata.deleteAfter !== null && typeof metadata.deleteAfter !== "number") - ) { - return; - } if (metadata.claims.length > 0 || metadata.deleteAfter === null) { yield* fileSystem.remove(candidate.markerPath, { force: true }); return; From bd1f7428cea2f2011a3b9774c4263458c6432ae0 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 18:45:20 -0400 Subject: [PATCH 62/79] Serialize text attachment claims and cleanup --- .../Layers/ProjectionPipeline.test.ts | 52 +++++++++++++ .../Layers/ProjectionPipeline.ts | 31 +++++--- apps/server/src/textAttachmentMutationLock.ts | 10 +++ apps/server/src/ws.ts | 74 ++++++++++--------- 4 files changed, 120 insertions(+), 47 deletions(-) create mode 100644 apps/server/src/textAttachmentMutationLock.ts diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index b5ee84bcfa9..ca615a5965b 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -11,7 +11,9 @@ import { } from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; @@ -35,6 +37,7 @@ import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQu import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts"; import { ServerConfig } from "../../config.ts"; +import { withTextAttachmentMutationLock } from "../../textAttachmentMutationLock.ts"; import { claimTextAttachment, reconcileTextAttachments, @@ -217,6 +220,55 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-text-expiry-swe assert.isTrue(yield* exists(attachmentPath)); }), ); + + it.effect("serializes a concurrent claim ahead of due deletion", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { attachmentsDir } = yield* ServerConfig; + const attachmentPath = path.join( + attachmentsDir, + "text", + "00000000-0000-4000-8000-000000000013", + "race.txt", + ); + yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true }); + yield* fileSystem.writeFileString(attachmentPath, "race"); + claimTextAttachment({ + attachmentsDir, + path: attachmentPath, + draftOwnerId: "original-race-owner", + }); + releaseTextAttachment({ + attachmentsDir, + path: attachmentPath, + draftOwnerId: "original-race-owner", + nowMs: -TEXT_ATTACHMENT_DELETE_GRACE_MS - 1, + }); + const claimStarted = yield* Deferred.make(); + const finishClaim = yield* Deferred.make(); + const claimFiber = yield* withTextAttachmentMutationLock( + Effect.gen(function* () { + yield* Deferred.succeed(claimStarted, undefined); + yield* Deferred.await(finishClaim); + return claimTextAttachment({ + attachmentsDir, + path: attachmentPath, + draftOwnerId: "concurrent-race-owner", + }); + }), + ).pipe(Effect.forkScoped); + yield* Deferred.await(claimStarted); + const sweepFiber = yield* withTextAttachmentMutationLock( + reconcileDueTextAttachments(attachmentsDir, Effect.succeed(new Set())), + ).pipe(Effect.forkScoped); + yield* Deferred.succeed(finishClaim, undefined); + + assert.isTrue(yield* Fiber.join(claimFiber)); + yield* Fiber.join(sweepFiber); + assert.isTrue(yield* exists(attachmentPath)); + }), + ); }, ); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 6652aaf601a..b7c605138af 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -48,6 +48,7 @@ import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/Projectio import { ProjectionThreadRepositoryLive } from "../../persistence/Layers/ProjectionThreads.ts"; import { ServerConfig } from "../../config.ts"; import { SAFE_IMAGE_FILE_EXTENSIONS } from "../../imageMime.ts"; +import { withTextAttachmentMutationLock } from "../../textAttachmentMutationLock.ts"; import { OrchestrationProjectionPipeline, type OrchestrationProjectionPipelineShape, @@ -725,15 +726,20 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti const serverConfig = yield* ServerConfig; const bootstrapComplete = yield* Ref.make(false); - const reconcileTextAttachmentStore = reconcileDueTextAttachments( - serverConfig.attachmentsDir, - projectionThreadMessageRepository - .listRetained() - .pipe( - Effect.map((retainedMessages) => - collectThreadTextAttachmentRelativePaths(serverConfig.attachmentsDir, retainedMessages), + const reconcileTextAttachmentStore = withTextAttachmentMutationLock( + reconcileDueTextAttachments( + serverConfig.attachmentsDir, + projectionThreadMessageRepository + .listRetained() + .pipe( + Effect.map((retainedMessages) => + collectThreadTextAttachmentRelativePaths( + serverConfig.attachmentsDir, + retainedMessages, + ), + ), ), - ), + ), ).pipe( Effect.catch((cause) => Effect.logWarning("failed to reconcile text attachments", { cause })), ); @@ -1809,9 +1815,8 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ); if (applyAttachmentSideEffects) { - yield* runAttachmentSideEffects( - attachmentSideEffects, - projectionThreadMessageRepository, + yield* withTextAttachmentMutationLock( + runAttachmentSideEffects(attachmentSideEffects, projectionThreadMessageRepository), ).pipe( Effect.catch((cause) => Effect.logWarning("failed to apply projected attachment side-effects", { @@ -1858,7 +1863,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti const bootstrap: OrchestrationProjectionPipelineShape["bootstrap"] = Effect.gen(function* () { yield* Effect.forEach(projectors, bootstrapProjector, { concurrency: 1 }); yield* Ref.set(bootstrapComplete, true); - yield* reconcileGeneratedAttachments(projectionThreadMessageRepository).pipe( + yield* withTextAttachmentMutationLock( + reconcileGeneratedAttachments(projectionThreadMessageRepository), + ).pipe( Effect.catch((cause) => Effect.logWarning("failed to reconcile generated attachments", { cause }), ), diff --git a/apps/server/src/textAttachmentMutationLock.ts b/apps/server/src/textAttachmentMutationLock.ts new file mode 100644 index 00000000000..7367145f12b --- /dev/null +++ b/apps/server/src/textAttachmentMutationLock.ts @@ -0,0 +1,10 @@ +import * as Effect from "effect/Effect"; +import * as Semaphore from "effect/Semaphore"; + +const lock = Effect.runSync(Semaphore.make(1)); + +export function withTextAttachmentMutationLock( + effect: Effect.Effect, +): Effect.Effect { + return lock.withPermits(1)(effect); +} diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index b97e5dab638..b0da1e5a308 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -4,10 +4,8 @@ import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; @@ -110,6 +108,7 @@ import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; +import { withTextAttachmentMutationLock } from "./textAttachmentMutationLock.ts"; import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; @@ -409,8 +408,6 @@ const makeWsRpcLayer = ( Effect.gen(function* () { const currentSessionId = currentSession.sessionId; const crypto = yield* Crypto.Crypto; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; const orchestrationEngine = yield* OrchestrationEngine.OrchestrationEngineService; const checkpointDiffQuery = yield* CheckpointDiffQuery.CheckpointDiffQuery; @@ -1538,52 +1535,59 @@ const makeWsRpcLayer = ( [WS_METHODS.assetsWriteTextAttachment]: (input) => observeRpcEffect( WS_METHODS.assetsWriteTextAttachment, - Effect.try({ - try: () => ({ - path: writeClaimedTextAttachment({ - attachmentsDir: config.attachmentsDir, - fileName: input.fileName, - contents: input.contents, - draftOwnerId: input.draftOwnerId, + withTextAttachmentMutationLock( + Effect.try({ + try: () => ({ + path: writeClaimedTextAttachment({ + attachmentsDir: config.attachmentsDir, + fileName: input.fileName, + contents: input.contents, + draftOwnerId: input.draftOwnerId, + }), }), + catch: (cause) => + new AssetTextAttachmentWriteError({ fileName: input.fileName, cause }), }), - catch: (cause) => - new AssetTextAttachmentWriteError({ fileName: input.fileName, cause }), - }), + ), { "rpc.aggregate": "workspace" }, ), [WS_METHODS.assetsClaimTextAttachment]: (input) => observeRpcEffect( WS_METHODS.assetsClaimTextAttachment, - Effect.try({ - try: () => ({ - claimed: claimTextAttachment({ - attachmentsDir: config.attachmentsDir, - path: input.path, - draftOwnerId: input.draftOwnerId, + withTextAttachmentMutationLock( + Effect.try({ + try: () => ({ + claimed: claimTextAttachment({ + attachmentsDir: config.attachmentsDir, + path: input.path, + draftOwnerId: input.draftOwnerId, + }), }), + catch: (cause) => new AssetTextAttachmentClaimError({ path: input.path, cause }), }), - catch: (cause) => new AssetTextAttachmentClaimError({ path: input.path, cause }), - }), + ), { "rpc.aggregate": "workspace" }, ), [WS_METHODS.assetsReleaseTextAttachment]: (input) => observeRpcEffect( WS_METHODS.assetsReleaseTextAttachment, - Effect.gen(function* () { - const nowMs = yield* Clock.currentTimeMillis; - return yield* Effect.try({ - try: () => ({ - released: releaseTextAttachment({ - attachmentsDir: config.attachmentsDir, - path: input.path, - draftOwnerId: input.draftOwnerId, - nowMs, + withTextAttachmentMutationLock( + Effect.gen(function* () { + const nowMs = yield* Clock.currentTimeMillis; + return yield* Effect.try({ + try: () => ({ + released: releaseTextAttachment({ + attachmentsDir: config.attachmentsDir, + path: input.path, + draftOwnerId: input.draftOwnerId, + nowMs, + }), }), - }), - catch: (cause) => new AssetTextAttachmentReleaseError({ path: input.path, cause }), - }); - }), + catch: (cause) => + new AssetTextAttachmentReleaseError({ path: input.path, cause }), + }); + }), + ), { "rpc.aggregate": "workspace" }, ), [WS_METHODS.subscribeVcsStatus]: (input) => From 107c6e2f3ea0218dd2b61a8d199e484bac867a89 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 18:51:49 -0400 Subject: [PATCH 63/79] Preserve attachments with invalid metadata --- apps/server/src/attachmentStore.test.ts | 34 +++++++++++++++++ apps/server/src/attachmentStore.ts | 49 +++++++++++++++++++------ 2 files changed, 71 insertions(+), 12 deletions(-) diff --git a/apps/server/src/attachmentStore.test.ts b/apps/server/src/attachmentStore.test.ts index 76b8eee975b..ecc07841dda 100644 --- a/apps/server/src/attachmentStore.test.ts +++ b/apps/server/src/attachmentStore.test.ts @@ -15,6 +15,8 @@ import { releaseTextAttachment, resolveAttachmentPathById, TEXT_ATTACHMENT_DELETE_GRACE_MS, + TEXT_ATTACHMENT_METADATA_FILE, + TEXT_ATTACHMENT_PENDING_DIRECTORY, writeClaimedTextAttachment, textAttachmentDirectory, } from "./attachmentStore.ts"; @@ -240,4 +242,36 @@ describe("attachmentStore", () => { NodeFS.rmSync(attachmentsDir, { recursive: true, force: true }); } }); + + it("preserves malformed metadata during full reconciliation", () => { + const attachmentsDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3code-text-invalid-metadata-"), + ); + try { + const attachmentPath = createTextAttachmentPath({ attachmentsDir, fileName: "keep.md" }); + const directory = NodePath.dirname(attachmentPath); + const metadataPath = NodePath.join(directory, TEXT_ATTACHMENT_METADATA_FILE); + const pendingPath = NodePath.join( + attachmentsDir, + "text", + TEXT_ATTACHMENT_PENDING_DIRECTORY, + `${NodePath.basename(directory)}.json`, + ); + NodeFS.mkdirSync(directory, { recursive: true }); + NodeFS.writeFileSync(attachmentPath, "keep"); + NodeFS.writeFileSync(metadataPath, "malformed"); + + reconcileTextAttachments({ + attachmentsDir, + retainedRelativePaths: new Set(), + nowMs: Number.MAX_SAFE_INTEGER, + }); + + expect(NodeFS.existsSync(attachmentPath)).toBe(true); + expect(NodeFS.readFileSync(metadataPath, "utf8")).toBe("malformed"); + expect(NodeFS.existsSync(pendingPath)).toBe(false); + } finally { + NodeFS.rmSync(attachmentsDir, { recursive: true, force: true }); + } + }); }); diff --git a/apps/server/src/attachmentStore.ts b/apps/server/src/attachmentStore.ts index 5d4789e3ddc..76b97b9aa07 100644 --- a/apps/server/src/attachmentStore.ts +++ b/apps/server/src/attachmentStore.ts @@ -192,30 +192,52 @@ interface TextAttachmentMetadata { readonly deleteAfter: number | null; } +type TextAttachmentMetadataReadResult = + | { readonly _tag: "Missing" } + | { readonly _tag: "Invalid" } + | { readonly _tag: "Valid"; readonly metadata: TextAttachmentMetadata }; + const emptyTextAttachmentMetadata = (): TextAttachmentMetadata => ({ version: 1, claims: [], deleteAfter: null, }); -function readTextAttachmentMetadata(directory: string): TextAttachmentMetadata { +function readTextAttachmentMetadata(directory: string): TextAttachmentMetadataReadResult { try { const parsed = JSON.parse( NodeFS.readFileSync(NodePath.join(directory, TEXT_ATTACHMENT_METADATA_FILE), "utf8"), ) as Partial; if (parsed.version !== 1 || !Array.isArray(parsed.claims)) { - return emptyTextAttachmentMetadata(); + return { _tag: "Invalid" }; + } + if ( + parsed.claims.some((claim) => typeof claim !== "string") || + (parsed.deleteAfter !== null && typeof parsed.deleteAfter !== "number") + ) { + return { _tag: "Invalid" }; } return { - version: 1, - claims: [ - ...new Set(parsed.claims.filter((claim): claim is string => typeof claim === "string")), - ], - deleteAfter: typeof parsed.deleteAfter === "number" ? parsed.deleteAfter : null, + _tag: "Valid", + metadata: { + version: 1, + claims: [...new Set(parsed.claims as ReadonlyArray)], + deleteAfter: parsed.deleteAfter ?? null, + }, }; - } catch { - return emptyTextAttachmentMetadata(); + } catch (cause) { + return typeof cause === "object" && cause !== null && "code" in cause && cause.code === "ENOENT" + ? { _tag: "Missing" } + : { _tag: "Invalid" }; + } +} + +function metadataForMutation(directory: string): TextAttachmentMetadata { + const result = readTextAttachmentMetadata(directory); + if (result._tag === "Invalid") { + throw new Error("Text attachment metadata is unreadable or malformed."); } + return result._tag === "Valid" ? result.metadata : emptyTextAttachmentMetadata(); } function writeTextAttachmentMetadata(directory: string, metadata: TextAttachmentMetadata): void { @@ -257,7 +279,7 @@ export function claimTextAttachment(input: { }): boolean { const directory = textAttachmentDirectory(input); if (!directory || !NodeFS.existsSync(input.path)) return false; - const metadata = readTextAttachmentMetadata(directory); + const metadata = metadataForMutation(directory); writeTextAttachmentMetadata(directory, { version: 1, claims: [...new Set([...metadata.claims, input.draftOwnerId])], @@ -305,7 +327,7 @@ export function releaseTextAttachment(input: { }): boolean { const directory = textAttachmentDirectory(input); if (!directory || !NodeFS.existsSync(input.path)) return false; - const metadata = readTextAttachmentMetadata(directory); + const metadata = metadataForMutation(directory); if (!metadata.claims.includes(input.draftOwnerId)) { if (metadata.deleteAfter === null) return false; writeTextAttachmentPendingMarker(input.attachmentsDir, directory, metadata.deleteAfter); @@ -359,7 +381,10 @@ export function reconcileTextAttachments(input: { continue; } if (!stat.isDirectory()) continue; - const metadata = readTextAttachmentMetadata(directory); + const metadataResult = readTextAttachmentMetadata(directory); + if (metadataResult._tag === "Invalid") continue; + const metadata = + metadataResult._tag === "Valid" ? metadataResult.metadata : emptyTextAttachmentMetadata(); const relativeDirectory = `${TEXT_ATTACHMENT_DIRECTORY}/${entry}`; if (retainedDirectories.has(relativeDirectory) || metadata.claims.length > 0) { if (metadata.deleteAfter !== null) { From 6d5f15977a50e9810ea3b0576b16c09a941031d3 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 18:53:48 -0400 Subject: [PATCH 64/79] Parse only valid Markdown attachment links --- apps/server/src/attachmentStore.test.ts | 18 ++++ apps/web/src/textAttachmentPaths.test.ts | 12 +++ packages/shared/src/markdownLinks.test.ts | 25 +++++ packages/shared/src/markdownLinks.ts | 112 +++++++++++++++++----- 4 files changed, 141 insertions(+), 26 deletions(-) diff --git a/apps/server/src/attachmentStore.test.ts b/apps/server/src/attachmentStore.test.ts index ecc07841dda..e612df9b16f 100644 --- a/apps/server/src/attachmentStore.test.ts +++ b/apps/server/src/attachmentStore.test.ts @@ -155,6 +155,24 @@ describe("attachmentStore", () => { ).toBeNull(); }); + it("ignores generated paths that are not Markdown links or are inside code", () => { + const attachmentsDir = NodePath.join(NodeOS.tmpdir(), "t3code-attachments"); + const attachmentPath = createTextAttachmentPath({ attachmentsDir, fileName: "notes.txt" }); + + expect( + collectTextAttachmentRelativePaths({ + attachmentsDir, + text: [ + `missing](${attachmentPath})`, + `\`[inline](${attachmentPath})\``, + "```md", + `[fenced](${attachmentPath})`, + "```", + ].join("\n"), + }), + ).toEqual(new Set()); + }); + it("persists draft claims across reconciliation and restart-style reloads", () => { const attachmentsDir = NodeFS.mkdtempSync( NodePath.join(NodeOS.tmpdir(), "t3code-text-claims-"), diff --git a/apps/web/src/textAttachmentPaths.test.ts b/apps/web/src/textAttachmentPaths.test.ts index ab109a8a3df..ab4bc0bc285 100644 --- a/apps/web/src/textAttachmentPaths.test.ts +++ b/apps/web/src/textAttachmentPaths.test.ts @@ -25,6 +25,18 @@ describe("textAttachmentPaths", () => { expect(textAttachmentPaths(`before[notes.txt](${path}),after`)).toEqual([path]); }); + + it("ignores generated paths outside valid Markdown links and inside code", () => { + const path = "/var/t3-data/attachments/text/12345678-1234-1234-1234-123456789abc/notes.txt"; + + expect( + textAttachmentPaths( + [`missing](${path})`, `\`[inline](${path})\``, "```", `[fenced](${path})`, "```"].join( + "\n", + ), + ), + ).toEqual([]); + }); }); describe("removedTextAttachmentPaths", () => { diff --git a/packages/shared/src/markdownLinks.test.ts b/packages/shared/src/markdownLinks.test.ts index 4aa4eb377f5..908f8316e6b 100644 --- a/packages/shared/src/markdownLinks.test.ts +++ b/packages/shared/src/markdownLinks.test.ts @@ -10,4 +10,29 @@ describe("markdownLinkDestinations", () => { ), ).toEqual(["/tmp/attachments/text/id/one.txt", "/tmp/a(b)/two.txt"]); }); + + it("requires balanced unescaped labels and destinations", () => { + expect( + markdownLinkDestinations( + String.raw`missing](/missing) \[escaped](/escaped) [label]\(/escaped-open) [nested [label]](/a(b)/c)`, + ), + ).toEqual(["/a(b)/c"]); + }); + + it("ignores links inside inline and fenced code", () => { + expect( + markdownLinkDestinations( + [ + "`[inline](/inline)` [real](/real)", + "```md", + "[fenced](/fenced)", + "```", + "~~~", + "[tilde](/tilde)", + "~~~", + ].join("\n"), + ), + ).toEqual(["/real"]); + expect(markdownLinkDestinations("unmatched ` [still-real](/real)")).toEqual(["/real"]); + }); }); diff --git a/packages/shared/src/markdownLinks.ts b/packages/shared/src/markdownLinks.ts index ddada842de4..b221f04ee75 100644 --- a/packages/shared/src/markdownLinks.ts +++ b/packages/shared/src/markdownLinks.ts @@ -1,37 +1,97 @@ +function delimiterRunLength(markdown: string, start: number, delimiter: string): number { + let end = start; + while (markdown[end] === delimiter) end += 1; + return end - start; +} + +function isFenceStart(markdown: string, index: number): boolean { + const lineStart = markdown.lastIndexOf("\n", index - 1) + 1; + const prefix = markdown.slice(lineStart, index); + return prefix.length <= 3 && /^ *$/.test(prefix); +} + +function fenceEnd(markdown: string, start: number, delimiter: string, runLength: number): number { + let lineStart = markdown.indexOf("\n", start) + 1; + if (lineStart === 0) return markdown.length; + while (lineStart < markdown.length) { + let cursor = lineStart; + let spaces = 0; + while (markdown[cursor] === " " && spaces < 4) { + cursor += 1; + spaces += 1; + } + if ( + spaces <= 3 && + markdown[cursor] === delimiter && + delimiterRunLength(markdown, cursor, delimiter) >= runLength + ) { + const lineEnd = markdown.indexOf("\n", cursor); + return lineEnd < 0 ? markdown.length : lineEnd + 1; + } + const nextLine = markdown.indexOf("\n", lineStart); + if (nextLine < 0) return markdown.length; + lineStart = nextLine + 1; + } + return markdown.length; +} + +function balancedEnd(markdown: string, start: number, opening: string, closing: string): number { + let depth = 1; + for (let cursor = start; cursor < markdown.length; cursor += 1) { + const character = markdown[cursor]; + if (character === "\\") { + cursor += 1; + continue; + } + if (character === opening) { + depth += 1; + } else if (character === closing) { + depth -= 1; + if (depth === 0) return cursor; + } + } + return -1; +} + export function markdownLinkDestinations(markdown: string): ReadonlyArray { const destinations: Array = []; - let searchFrom = 0; - while (searchFrom < markdown.length) { - const destinationStart = markdown.indexOf("](", searchFrom); - if (destinationStart < 0) break; - let depth = 1; - let escaped = false; - let cursor = destinationStart + 2; - const contentStart = cursor; - for (; cursor < markdown.length; cursor += 1) { - const character = markdown[cursor]; - if (escaped) { - escaped = false; - continue; - } - if (character === "\\") { - escaped = true; + let cursor = 0; + while (cursor < markdown.length) { + const character = markdown[cursor]; + if (character === "\\") { + cursor += 2; + continue; + } + if (character === "`" || character === "~") { + const runLength = delimiterRunLength(markdown, cursor, character); + if (runLength >= 3 && isFenceStart(markdown, cursor)) { + cursor = fenceEnd(markdown, cursor, character, runLength); continue; } - if (character === "(") { - depth += 1; + if (character === "`") { + const delimiter = "`".repeat(runLength); + const end = markdown.indexOf(delimiter, cursor + runLength); + cursor = end < 0 ? cursor + runLength : end + runLength; continue; } - if (character !== ")") continue; - depth -= 1; - if (depth === 0) break; } - if (depth === 0 && cursor > contentStart) { - destinations.push(markdown.slice(contentStart, cursor)); - searchFrom = cursor + 1; - } else { - searchFrom = destinationStart + 2; + if (character !== "[") { + cursor += 1; + continue; + } + const labelEnd = balancedEnd(markdown, cursor + 1, "[", "]"); + if (labelEnd < 0 || markdown[labelEnd + 1] !== "(") { + cursor += 1; + continue; + } + const destinationStart = labelEnd + 2; + const destinationEnd = balancedEnd(markdown, destinationStart, "(", ")"); + if (destinationEnd < 0 || destinationEnd === destinationStart) { + cursor = destinationStart; + continue; } + destinations.push(markdown.slice(destinationStart, destinationEnd)); + cursor = destinationEnd + 1; } return destinations; } From 35dd2e4aa6981a7c288e0e58a5356f1d6b1c5e32 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 19:01:39 -0400 Subject: [PATCH 65/79] Keep attachment releases retrying after teardown --- apps/web/src/components/Sidebar.tsx | 31 ++--- apps/web/src/components/chat/ChatComposer.tsx | 20 +-- apps/web/src/connection/platform.ts | 24 +++- apps/web/src/hooks/useThreadActions.ts | 54 ++++---- apps/web/src/textAttachmentClaims.test.ts | 91 ++++++++++++++ apps/web/src/textAttachmentClaims.ts | 118 +++++++++++++++++- 6 files changed, 275 insertions(+), 63 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index b3a55cc4bd2..c216f7e6f7c 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -121,11 +121,10 @@ import { vcsEnvironment } from "../state/vcs"; import { useEnvironment, useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { tombstoneTextAttachmentUploadOwner, - detachTextAttachmentClaimOwner, detachedTextAttachmentReleaseComplete, fenceTextAttachmentUploadOwner, + releaseTextAttachmentClaimsInBackground, resumeTextAttachmentUploadOwner, - retryTextAttachmentOperation, textAttachmentClaims, textAttachmentDraftOwnerId, } from "../textAttachmentClaims"; @@ -1480,22 +1479,18 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec } return result; } - await Promise.all( - discardedTargets.map((target) => - detachTextAttachmentClaimOwner(member.environmentId, textAttachmentDraftOwnerId(target)), - ), - ); - await Promise.all( - discardedClaims.map(({ path, draftOwnerId }) => - retryTextAttachmentOperation(async () => { - const result = await releaseTextAttachment({ - environmentId: member.environmentId, - input: { path, draftOwnerId }, - }); - return detachedTextAttachmentReleaseComplete(result); - }), - ), - ); + await releaseTextAttachmentClaimsInBackground({ + environmentId: member.environmentId, + claims: discardedClaims, + draftOwnerIds: discardedTargets.map(textAttachmentDraftOwnerId), + release: async ({ path, draftOwnerId }) => { + const result = await releaseTextAttachment({ + environmentId: member.environmentId, + input: { path, draftOwnerId }, + }); + return detachedTextAttachmentReleaseComplete(result); + }, + }); for (const target of discardedTargets) { draftStore.clearDraftThread(target); tombstoneTextAttachmentUploadOwner( diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index ed9fcfaf960..d887c12b516 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -93,7 +93,7 @@ import { textAttachmentDraftOwnerId, getTextAttachmentClaimReconciler as getRegisteredTextAttachmentClaimReconciler, reconcileTextAttachmentClaimsEnvironment, - retryTextAttachmentOperation, + releaseTextAttachmentClaimsInBackground, runTextAttachmentUpload, } from "../../textAttachmentClaims"; import { cn, randomUUID } from "~/lib/utils"; @@ -1880,13 +1880,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }), path: (result) => (result._tag === "Success" ? result.value.path : null), release: (path) => - retryTextAttachmentOperation(async () => { - const released = await releaseTextAttachment({ - environmentId, - input: { path, draftOwnerId }, - }); - return released._tag === "Success"; - }).then(() => undefined), + releaseTextAttachmentClaimsInBackground({ + environmentId, + claims: [{ path, draftOwnerId }], + release: async ({ path: releasePath, draftOwnerId: ownerId }) => { + const released = await releaseTextAttachment({ + environmentId, + input: { path: releasePath, draftOwnerId: ownerId }, + }); + return released._tag === "Success"; + }, + }), }); }) .catch(() => null); diff --git a/apps/web/src/connection/platform.ts b/apps/web/src/connection/platform.ts index 550319f099c..706094a98c3 100644 --- a/apps/web/src/connection/platform.ts +++ b/apps/web/src/connection/platform.ts @@ -60,6 +60,7 @@ import { clearTextAttachmentUploadEnvironment, fenceTextAttachmentUploadEnvironment, pauseTextAttachmentClaimEnvironment, + pendingTextAttachmentClaimReleases, resumeTextAttachmentClaimEnvironment, resumeTextAttachmentUploadEnvironment, textAttachmentClaims, @@ -595,10 +596,27 @@ const environmentOwnedDataCleanupLayer = Layer.succeed( Effect.gen(function* () { yield* Effect.promise(() => fenceTextAttachmentUploadEnvironment(environmentId)); yield* Effect.promise(() => pauseTextAttachmentClaimEnvironment(environmentId)); + const claims = [ + ...composerDraftEntriesEnvironment(environmentId).flatMap(({ target, prompt }) => + textAttachmentClaims(target, prompt), + ), + ...pendingTextAttachmentClaimReleases(environmentId), + ].filter( + (claim, index, all) => + all.findIndex( + (candidate) => + candidate.path === claim.path && candidate.draftOwnerId === claim.draftOwnerId, + ) === index, + ); + if (claims.length > 0 && !supervisor) { + resumeTextAttachmentClaimEnvironment(environmentId); + resumeTextAttachmentUploadEnvironment(environmentId); + return yield* new ConnectionPersistenceError({ + operation: "clear-environment", + message: "Could not release text attachment draft claims without a connection.", + }); + } if (supervisor) { - const claims = composerDraftEntriesEnvironment(environmentId).flatMap( - ({ target, prompt }) => textAttachmentClaims(target, prompt), - ); const releaseResult = yield* Effect.exit( Effect.forEach( claims, diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index ad4ffad0465..54ecb7b0f8c 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -29,11 +29,10 @@ import { useClientSettings } from "./useSettings"; import { useAtomCommand } from "../state/use-atom-command"; import { tombstoneTextAttachmentUploadOwner, - detachTextAttachmentClaimOwner, detachedTextAttachmentReleaseComplete, fenceTextAttachmentUploadOwner, + releaseTextAttachmentClaimsInBackground, resumeTextAttachmentUploadOwner, - retryTextAttachmentOperation, textAttachmentClaims, textAttachmentDraftOwnerId, } from "../textAttachmentClaims"; @@ -177,18 +176,18 @@ export function useThreadActions() { resumeTextAttachmentUploadOwner(target.environmentId, draftOwnerId); return result; } - await detachTextAttachmentClaimOwner(target.environmentId, draftOwnerId); - await Promise.all( - textAttachmentClaims(target, discardedPrompt).map(({ path }) => - retryTextAttachmentOperation(async () => { - const released = await releaseTextAttachment({ - environmentId: target.environmentId, - input: { path, draftOwnerId }, - }); - return detachedTextAttachmentReleaseComplete(released); - }), - ), - ); + await releaseTextAttachmentClaimsInBackground({ + environmentId: target.environmentId, + claims: textAttachmentClaims(target, discardedPrompt), + draftOwnerIds: [draftOwnerId], + release: async ({ path, draftOwnerId: ownerId }) => { + const released = await releaseTextAttachment({ + environmentId: target.environmentId, + input: { path, draftOwnerId: ownerId }, + }); + return detachedTextAttachmentReleaseComplete(released); + }, + }); clearComposerDraftForThread(target); tombstoneTextAttachmentUploadOwner(target.environmentId, draftOwnerId); if (result._tag === "Success") { @@ -286,21 +285,18 @@ export function useThreadActions() { ); return deleteResult; } - await detachTextAttachmentClaimOwner( - threadRef.environmentId, - textAttachmentDraftOwnerId(threadRef), - ); - await Promise.all( - textAttachmentClaims(threadRef, discardedPrompt).map(({ path, draftOwnerId }) => - retryTextAttachmentOperation(async () => { - const result = await releaseTextAttachment({ - environmentId: threadRef.environmentId, - input: { path, draftOwnerId }, - }); - return detachedTextAttachmentReleaseComplete(result); - }), - ), - ); + await releaseTextAttachmentClaimsInBackground({ + environmentId: threadRef.environmentId, + claims: textAttachmentClaims(threadRef, discardedPrompt), + draftOwnerIds: [textAttachmentDraftOwnerId(threadRef)], + release: async ({ path, draftOwnerId }) => { + const result = await releaseTextAttachment({ + environmentId: threadRef.environmentId, + input: { path, draftOwnerId }, + }); + return detachedTextAttachmentReleaseComplete(result); + }, + }); refreshArchivedThreadsForEnvironment(threadRef.environmentId); clearComposerDraftForThread(threadRef); clearProjectDraftThreadById( diff --git a/apps/web/src/textAttachmentClaims.test.ts b/apps/web/src/textAttachmentClaims.test.ts index 1e5c36c15fa..7a0d1b8e21b 100644 --- a/apps/web/src/textAttachmentClaims.test.ts +++ b/apps/web/src/textAttachmentClaims.test.ts @@ -15,7 +15,9 @@ import { fenceTextAttachmentUploadOwner, getTextAttachmentClaimReconciler, pauseTextAttachmentClaimEnvironment, + pendingTextAttachmentClaimReleases, reconcileTextAttachmentClaimsEnvironment, + releaseTextAttachmentClaimsInBackground, resetTextAttachmentClaimRegistryForTest, resumeTextAttachmentClaimEnvironment, resumeTextAttachmentUploadEnvironment, @@ -261,6 +263,95 @@ describe("text attachment claims", () => { expect(releaseRpc).toHaveBeenCalledOnce(); }); + it("keeps destructive owner releases retrying after the foreground deadline", async () => { + vi.useFakeTimers(); + const environmentId = EnvironmentId.make("destructive-release-outbox"); + const claims = [ + { path: `${PATH}.normal`, draftOwnerId: "thread:env:normal" }, + { path: `${PATH}.archived`, draftOwnerId: "thread:env:archived" }, + { path: `${PATH}.project`, draftOwnerId: "draft:project" }, + { path: `${PATH}.fenced-upload`, draftOwnerId: "draft:fenced-upload" }, + ]; + const attempts = new Map(); + const release = vi.fn(async (claim: (typeof claims)[number]) => { + const next = (attempts.get(claim.path) ?? 0) + 1; + attempts.set(claim.path, next); + return next > 1; + }); + + const foreground = releaseTextAttachmentClaimsInBackground({ + environmentId, + claims, + release, + foregroundWaitMs: 25, + retryDelayMs: 100, + }); + await vi.advanceTimersByTimeAsync(25); + await foreground; + + expect(pendingTextAttachmentClaimReleases(environmentId)).toEqual(claims); + expect(release).toHaveBeenCalledTimes(4); + + await vi.advanceTimersByTimeAsync(100); + + expect(pendingTextAttachmentClaimReleases(environmentId)).toEqual([]); + expect(release).toHaveBeenCalledTimes(8); + }); + + it("times out a hung background release and retries it", async () => { + vi.useFakeTimers(); + const environmentId = EnvironmentId.make("hung-release-outbox"); + const releaseClaim = { path: PATH, draftOwnerId: "thread:env:hung" }; + const release = vi + .fn<(claim: typeof releaseClaim) => Promise>() + .mockImplementationOnce(() => new Promise(() => undefined)) + .mockResolvedValueOnce(true); + + const foreground = releaseTextAttachmentClaimsInBackground({ + environmentId, + claims: [releaseClaim], + release, + foregroundWaitMs: 10, + operationTimeoutMs: 50, + retryDelayMs: 100, + }); + await vi.advanceTimersByTimeAsync(10); + await foreground; + expect(pendingTextAttachmentClaimReleases(environmentId)).toEqual([releaseClaim]); + + await vi.advanceTimersByTimeAsync(140); + + expect(pendingTextAttachmentClaimReleases(environmentId)).toEqual([]); + expect(release).toHaveBeenCalledTimes(2); + }); + + it("releases confirmed owner paths even when the cleared prompt has no links", async () => { + const environmentId = EnvironmentId.make("destroyed-owner-with-stale-confirmation"); + const draftOwnerId = "thread:env:destroyed"; + const reconciler = getTextAttachmentClaimReconciler({ + environmentId, + draftOwnerId, + operations: { + claim: vi.fn(async () => true), + release: vi.fn(async () => false), + }, + }); + reconciler.confirmPaths([PATH]); + reconciler.setDesiredPaths([PATH]); + await reconciler.settled(); + const release = vi.fn(async (_path: string, _draftOwnerId: string) => true); + + await releaseTextAttachmentClaimsInBackground({ + environmentId, + claims: [], + draftOwnerIds: [draftOwnerId], + release: ({ path, draftOwnerId: ownerId }) => release(path, ownerId), + }); + + expect(release).toHaveBeenCalledWith(PATH, draftOwnerId); + expect(pendingTextAttachmentClaimReleases(environmentId)).toEqual([]); + }); + it("cancels pending retry timers when disposed on unmount", async () => { vi.useFakeTimers(); const claim = vi.fn().mockResolvedValue(false); diff --git a/apps/web/src/textAttachmentClaims.ts b/apps/web/src/textAttachmentClaims.ts index 839f7b4a2d8..00b91b75cf7 100644 --- a/apps/web/src/textAttachmentClaims.ts +++ b/apps/web/src/textAttachmentClaims.ts @@ -30,10 +30,11 @@ export function textAttachmentClaims( } export class TextAttachmentClaimReconciler { - readonly #claim: (path: string) => Promise; - readonly #release: (path: string) => Promise; + #claim: (path: string) => Promise; + #release: (path: string) => Promise; readonly #retryDelayMs: number; readonly #maxRetryDelayMs: number; + readonly #operationTimeoutMs: number; #desired = new Set(); #confirmed = new Set(); #queue: Promise = Promise.resolve(); @@ -47,17 +48,27 @@ export class TextAttachmentClaimReconciler { release: (path: string) => Promise; retryDelayMs?: number; maxRetryDelayMs?: number; + operationTimeoutMs?: number; }) { this.#claim = options.claim; this.#release = options.release; this.#retryDelayMs = options.retryDelayMs ?? 250; this.#maxRetryDelayMs = options.maxRetryDelayMs ?? 30_000; + this.#operationTimeoutMs = options.operationTimeoutMs ?? 10_000; } setDesiredPrompt(prompt: string): void { this.setDesiredPaths(textAttachmentPaths(prompt)); } + setOperations(operations: { + claim: (path: string) => Promise; + release: (path: string) => Promise; + }): void { + this.#claim = operations.claim; + this.#release = operations.release; + } + setDesiredPaths(paths: Iterable): void { if (this.#disposed) return; this.#desired = new Set(paths); @@ -122,12 +133,12 @@ export class TextAttachmentClaimReconciler { let failed = false; for (const path of this.#desired) { if (this.#confirmed.has(path)) continue; - if (await this.#claim(path)) this.#confirmed.add(path); + if (await this.#runOperation(() => this.#claim(path))) this.#confirmed.add(path); else failed = true; } for (const path of this.#confirmed) { if (this.#desired.has(path)) continue; - if (await this.#release(path)) this.#confirmed.delete(path); + if (await this.#runOperation(() => this.#release(path))) this.#confirmed.delete(path); else failed = true; } if (!failed) { @@ -151,6 +162,20 @@ export class TextAttachmentClaimReconciler { clearTimeout(this.#retryTimer); this.#retryTimer = null; } + + async #runOperation(operation: () => Promise): Promise { + let timeout: ReturnType | null = null; + return Promise.race([ + Promise.resolve() + .then(operation) + .catch(() => false), + new Promise((resolve) => { + timeout = setTimeout(() => resolve(false), this.#operationTimeoutMs); + }), + ]).finally(() => { + if (timeout !== null) clearTimeout(timeout); + }); + } } export async function retryTextAttachmentOperation( @@ -178,6 +203,11 @@ export function detachedTextAttachmentReleaseComplete(result: { readonly _tag: s return result._tag === "Success"; } +export interface TextAttachmentClaimRelease { + readonly path: string; + readonly draftOwnerId: string; +} + export interface TextAttachmentClaimOperations { claim: (path: string, draftOwnerId: string) => Promise; release: (path: string, draftOwnerId: string) => Promise; @@ -196,18 +226,96 @@ export function getTextAttachmentClaimReconciler(input: { environmentId: EnvironmentId; draftOwnerId: string; operations: TextAttachmentClaimOperations; + retryDelayMs?: number; + maxRetryDelayMs?: number; + operationTimeoutMs?: number; }): TextAttachmentClaimReconciler { const key = textAttachmentClaimRegistryKey(input.environmentId, input.draftOwnerId); const existing = textAttachmentClaimReconcilerRegistry.get(key); - if (existing) return existing; + if (existing) { + existing.setOperations({ + claim: (path) => input.operations.claim(path, input.draftOwnerId), + release: (path) => input.operations.release(path, input.draftOwnerId), + }); + return existing; + } const reconciler = new TextAttachmentClaimReconciler({ claim: (path) => input.operations.claim(path, input.draftOwnerId), release: (path) => input.operations.release(path, input.draftOwnerId), + ...(input.retryDelayMs === undefined ? {} : { retryDelayMs: input.retryDelayMs }), + ...(input.maxRetryDelayMs === undefined ? {} : { maxRetryDelayMs: input.maxRetryDelayMs }), + ...(input.operationTimeoutMs === undefined + ? {} + : { operationTimeoutMs: input.operationTimeoutMs }), }); textAttachmentClaimReconcilerRegistry.set(key, reconciler); return reconciler; } +export async function releaseTextAttachmentClaimsInBackground(input: { + environmentId: EnvironmentId; + claims: ReadonlyArray; + draftOwnerIds?: ReadonlyArray; + release: (claim: TextAttachmentClaimRelease) => Promise; + foregroundWaitMs?: number; + retryDelayMs?: number; + maxRetryDelayMs?: number; + operationTimeoutMs?: number; +}): Promise { + // Reuse the module-level reconciler registry as a release outbox. It outlives + // the composer or sidebar that initiated teardown and keeps retrying after + // their persisted draft state has been cleared. + const claimsByOwner = new Map>(); + for (const draftOwnerId of input.draftOwnerIds ?? []) { + claimsByOwner.set(draftOwnerId, new Set()); + } + for (const { path, draftOwnerId } of input.claims) { + const paths = claimsByOwner.get(draftOwnerId) ?? new Set(); + paths.add(path); + claimsByOwner.set(draftOwnerId, paths); + } + const reconciliations = [...claimsByOwner].map(([draftOwnerId, paths]) => { + const reconciler = getTextAttachmentClaimReconciler({ + environmentId: input.environmentId, + draftOwnerId, + operations: { + claim: async () => false, + release: (path, ownerId) => input.release({ path, draftOwnerId: ownerId }), + }, + ...(input.retryDelayMs === undefined ? {} : { retryDelayMs: input.retryDelayMs }), + ...(input.maxRetryDelayMs === undefined ? {} : { maxRetryDelayMs: input.maxRetryDelayMs }), + ...(input.operationTimeoutMs === undefined + ? {} + : { operationTimeoutMs: input.operationTimeoutMs }), + }); + reconciler.confirmPaths(paths); + reconciler.setDesiredPaths([]); + return reconciler.settled(); + }); + if (reconciliations.length === 0) return; + let timeout: ReturnType | null = null; + await Promise.race([ + Promise.all(reconciliations), + new Promise((resolve) => { + timeout = setTimeout(resolve, input.foregroundWaitMs ?? 1_000); + }), + ]).finally(() => { + if (timeout !== null) clearTimeout(timeout); + }); +} + +export function pendingTextAttachmentClaimReleases( + environmentId: EnvironmentId, +): TextAttachmentClaimRelease[] { + const prefix = `${environmentId}:`; + return [...textAttachmentClaimReconcilerRegistry].flatMap(([key, reconciler]) => { + if (!key.startsWith(prefix)) return []; + const draftOwnerId = key.slice(prefix.length); + const { confirmed, desired } = reconciler.snapshot(); + return [...confirmed].flatMap((path) => (desired.has(path) ? [] : [{ path, draftOwnerId }])); + }); +} + export function reconcileTextAttachmentClaimsEnvironment( environmentId: EnvironmentId, entries: ReadonlyArray<{ target: ComposerThreadTarget; prompt: string }>, From 80f395d3bdeff80c366db066aa0d131bbfce37ae Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 19:05:42 -0400 Subject: [PATCH 66/79] Honor CommonMark code block boundaries --- packages/shared/src/markdownLinks.test.ts | 18 ++++++++++++++++++ packages/shared/src/markdownLinks.ts | 17 ++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/markdownLinks.test.ts b/packages/shared/src/markdownLinks.test.ts index 908f8316e6b..f6f4d560cd9 100644 --- a/packages/shared/src/markdownLinks.test.ts +++ b/packages/shared/src/markdownLinks.test.ts @@ -35,4 +35,22 @@ describe("markdownLinkDestinations", () => { ).toEqual(["/real"]); expect(markdownLinkDestinations("unmatched ` [still-real](/real)")).toEqual(["/real"]); }); + + it("requires a valid closing fence and skips indented code blocks", () => { + expect( + markdownLinkDestinations( + [ + "```", + "[inside](/inside)", + "```not-a-close", + "[still-inside](/still-inside)", + "``` ", + "[outside](/outside)", + " [space-indented](/space-indented)", + "\t[tab-indented](/tab-indented)", + "[final](/final)", + ].join("\n"), + ), + ).toEqual(["/outside", "/final"]); + }); }); diff --git a/packages/shared/src/markdownLinks.ts b/packages/shared/src/markdownLinks.ts index b221f04ee75..e3de625365a 100644 --- a/packages/shared/src/markdownLinks.ts +++ b/packages/shared/src/markdownLinks.ts @@ -26,7 +26,14 @@ function fenceEnd(markdown: string, start: number, delimiter: string, runLength: delimiterRunLength(markdown, cursor, delimiter) >= runLength ) { const lineEnd = markdown.indexOf("\n", cursor); - return lineEnd < 0 ? markdown.length : lineEnd + 1; + const closingRunLength = delimiterRunLength(markdown, cursor, delimiter); + const suffix = markdown.slice( + cursor + closingRunLength, + lineEnd < 0 ? markdown.length : lineEnd, + ); + if (/^[ \t]*$/.test(suffix)) { + return lineEnd < 0 ? markdown.length : lineEnd + 1; + } } const nextLine = markdown.indexOf("\n", lineStart); if (nextLine < 0) return markdown.length; @@ -57,6 +64,14 @@ export function markdownLinkDestinations(markdown: string): ReadonlyArray = []; let cursor = 0; while (cursor < markdown.length) { + if ( + (cursor === 0 || markdown[cursor - 1] === "\n") && + (markdown[cursor] === "\t" || markdown.startsWith(" ", cursor)) + ) { + const lineEnd = markdown.indexOf("\n", cursor); + cursor = lineEnd < 0 ? markdown.length : lineEnd + 1; + continue; + } const character = markdown[cursor]; if (character === "\\") { cursor += 2; From 2588e331872bcc28df1a7886eff66aa2dd4d0026 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 19:07:35 -0400 Subject: [PATCH 67/79] Write retained attachment metadata atomically --- .../src/orchestration/Layers/ProjectionPipeline.test.ts | 8 ++++++++ .../server/src/orchestration/Layers/ProjectionPipeline.ts | 3 ++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index ca615a5965b..77f37fe78c6 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -203,6 +203,14 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-text-expiry-swe Effect.succeed(new Set(relativePath ? [relativePath] : [])), ); + const directoryEntries = yield* fileSystem.readDirectory(path.dirname(attachmentPath), { + recursive: false, + }); + assert.deepEqual( + [...directoryEntries].sort(), + [TEXT_ATTACHMENT_METADATA_FILE, "retained.txt"].sort(), + ); + assert.isFalse( releaseTextAttachment({ attachmentsDir, diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index b7c605138af..1fd788daf55 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -48,6 +48,7 @@ import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/Projectio import { ProjectionThreadRepositoryLive } from "../../persistence/Layers/ProjectionThreads.ts"; import { ServerConfig } from "../../config.ts"; import { SAFE_IMAGE_FILE_EXTENSIONS } from "../../imageMime.ts"; +import { writeFileStringAtomically } from "../../atomicWrite.ts"; import { withTextAttachmentMutationLock } from "../../textAttachmentMutationLock.ts"; import { OrchestrationProjectionPipeline, @@ -672,7 +673,7 @@ const removeDueTextAttachments = Effect.fn("removeDueTextAttachments")(function* ...metadataResult.success, deleteAfter: null, }); - yield* fileSystem.writeFileString(metadataPath, encoded); + yield* writeFileStringAtomically({ filePath: metadataPath, contents: encoded }); } yield* fileSystem.remove(candidate.markerPath, { force: true }); return; From e64343cf81175bf3ccb5c698af2118844dea19ea Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 19:12:17 -0400 Subject: [PATCH 68/79] Persist pending attachment releases --- apps/web/src/components/chat/ChatComposer.tsx | 2 +- apps/web/src/composerDraftStore.test.ts | 25 +++++ apps/web/src/composerDraftStore.ts | 106 +++++++++++++++++- apps/web/src/textAttachmentClaims.test.ts | 47 +++++++- apps/web/src/textAttachmentClaims.ts | 74 +++++++++--- 5 files changed, 238 insertions(+), 16 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index d887c12b516..08fc72fe6d9 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -961,7 +961,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) environmentId, input: { path, draftOwnerId }, }); - return result._tag === "Success" && result.value.released; + return result._tag === "Success"; }, }), [claimTextAttachment, environmentId, releaseTextAttachment], diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index a46abc2f294..d231ecc853f 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -518,6 +518,24 @@ describe("composerDraftStore terminal contexts", () => { }, draftThreadsByThreadId: "not-an-object", projectDraftThreadIdByProjectKey: "not-an-object", + pendingTextAttachmentReleases: [ + { + environmentId: TEST_ENVIRONMENT_ID, + path: "/tmp/attachment.txt", + draftOwnerId: "thread:env:valid", + }, + { + environmentId: TEST_ENVIRONMENT_ID, + path: "/tmp/attachment.txt", + draftOwnerId: "thread:env:valid", + }, + { environmentId: 42, path: null, draftOwnerId: [] }, + { + environmentId: TEST_ENVIRONMENT_ID, + path: "x".repeat(9_000), + draftOwnerId: "thread:env:oversized", + }, + ], }, useComposerDraftStore.getInitialState(), ); @@ -525,6 +543,13 @@ describe("composerDraftStore terminal contexts", () => { expect(mergedState.draftsByThreadKey[threadKeyFor(threadId)]).toBeUndefined(); expect(mergedState.draftThreadsByThreadKey).toEqual({}); expect(mergedState.logicalProjectDraftThreadKeyByLogicalProjectKey).toEqual({}); + expect(mergedState.pendingTextAttachmentReleases).toEqual([ + { + environmentId: TEST_ENVIRONMENT_ID, + path: "/tmp/attachment.txt", + draftOwnerId: "thread:env:valid", + }, + ]); }); }); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index c68ced1b043..cc9b49cf9e0 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -57,7 +57,7 @@ const isProviderDriverKind = Schema.is(ProviderDriverKind); const isReviewCommentContext = Schema.is(ReviewCommentContextSchema); export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1"; -const COMPOSER_DRAFT_STORAGE_VERSION = 8; +const COMPOSER_DRAFT_STORAGE_VERSION = 9; const DraftThreadEnvModeSchema = Schema.Literals(["local", "worktree"]); export type DraftThreadEnvMode = typeof DraftThreadEnvModeSchema.Type; @@ -227,6 +227,22 @@ const PersistedDraftThreadState = Schema.Struct({ }); type PersistedDraftThreadState = typeof PersistedDraftThreadState.Type; +export interface PendingTextAttachmentRelease { + readonly environmentId: EnvironmentId; + readonly path: string; + readonly draftOwnerId: string; +} + +const PersistedTextAttachmentRelease = Schema.Struct({ + environmentId: Schema.String, + path: Schema.String, + draftOwnerId: Schema.String, +}); +const MAX_PENDING_TEXT_ATTACHMENT_RELEASES = 10_000; +const MAX_PENDING_TEXT_ATTACHMENT_ENVIRONMENT_LENGTH = 1_024; +const MAX_PENDING_TEXT_ATTACHMENT_PATH_LENGTH = 8_192; +const MAX_PENDING_TEXT_ATTACHMENT_OWNER_LENGTH = 1_024; + const PersistedComposerDraftStoreState = Schema.Struct({ draftsByThreadKey: Schema.Record(Schema.String, PersistedComposerThreadDraftState), draftThreadsByThreadKey: Schema.Record(Schema.String, PersistedDraftThreadState), @@ -235,6 +251,7 @@ const PersistedComposerDraftStoreState = Schema.Struct({ Schema.Record(ProviderInstanceId, ModelSelection), ), stickyActiveProvider: Schema.optionalKey(Schema.NullOr(ProviderInstanceId)), + pendingTextAttachmentReleases: Schema.optionalKey(Schema.Array(PersistedTextAttachmentRelease)), }); type PersistedComposerDraftStoreState = typeof PersistedComposerDraftStoreState.Type; @@ -330,6 +347,7 @@ interface ComposerDraftStoreState { logicalProjectDraftThreadKeyByLogicalProjectKey: Record; stickyModelSelectionByProvider: Partial>; stickyActiveProvider: ProviderInstanceId | null; + pendingTextAttachmentReleases: PendingTextAttachmentRelease[]; /** Returns the editable composer content for a draft session or server thread. */ getComposerDraft: (target: ComposerThreadTarget) => ComposerThreadDraftState | null; /** Looks up the active draft session for a logical project identity. */ @@ -549,6 +567,7 @@ const EMPTY_PERSISTED_DRAFT_STORE_STATE = Object.freeze(); + for (const candidate of value) { + if (releases.length >= MAX_PENDING_TEXT_ATTACHMENT_RELEASES) break; + if (!candidate || typeof candidate !== "object") continue; + const { environmentId, path, draftOwnerId } = candidate as Record; + if ( + typeof environmentId !== "string" || + environmentId.length === 0 || + environmentId.length > MAX_PENDING_TEXT_ATTACHMENT_ENVIRONMENT_LENGTH || + typeof path !== "string" || + path.length === 0 || + path.length > MAX_PENDING_TEXT_ATTACHMENT_PATH_LENGTH || + typeof draftOwnerId !== "string" || + draftOwnerId.length === 0 || + draftOwnerId.length > MAX_PENDING_TEXT_ATTACHMENT_OWNER_LENGTH + ) { + continue; + } + const key = `${environmentId}\u0000${draftOwnerId}\u0000${path}`; + if (seen.has(key)) continue; + seen.add(key); + releases.push({ environmentId: environmentId as EnvironmentId, path, draftOwnerId }); + } + return releases; +} + function partializeComposerDraftStoreState( state: ComposerDraftStoreState, ): PersistedComposerDraftStoreState { @@ -1907,6 +1958,7 @@ function partializeComposerDraftStoreState( state.stickyModelSelectionByProvider, ), stickyActiveProvider: state.stickyActiveProvider, + pendingTextAttachmentReleases: state.pendingTextAttachmentReleases, }; } @@ -1977,6 +2029,9 @@ function normalizeCurrentPersistedComposerDraftStoreState( logicalProjectDraftThreadKeyByLogicalProjectKey, stickyModelSelectionByProvider: compactModelSelectionByProvider(stickyModelSelectionByProvider), stickyActiveProvider, + pendingTextAttachmentReleases: normalizePendingTextAttachmentReleases( + normalizedPersistedState.pendingTextAttachmentReleases, + ), }; } @@ -2177,6 +2232,7 @@ const composerDraftStore = create()( logicalProjectDraftThreadKeyByLogicalProjectKey: {}, stickyModelSelectionByProvider: {}, stickyActiveProvider: null, + pendingTextAttachmentReleases: [], getComposerDraft: (target) => getComposerDraftState(get(), target), getDraftThreadByLogicalProjectKey: (logicalProjectKey) => { return get().getDraftSessionByLogicalProjectKey(logicalProjectKey); @@ -3366,6 +3422,9 @@ const composerDraftStore = create()( normalizedPersisted.logicalProjectDraftThreadKeyByLogicalProjectKey, stickyModelSelectionByProvider: normalizedPersisted.stickyModelSelectionByProvider ?? {}, stickyActiveProvider: normalizedPersisted.stickyActiveProvider ?? null, + pendingTextAttachmentReleases: normalizePendingTextAttachmentReleases( + normalizedPersisted.pendingTextAttachmentReleases, + ), }; }, }, @@ -3458,6 +3517,48 @@ export function composerDraftTargetsProject( }); } +function pendingTextAttachmentReleaseKey(release: PendingTextAttachmentRelease): string { + return `${release.environmentId}\u0000${release.draftOwnerId}\u0000${release.path}`; +} + +export function pendingTextAttachmentReleasesEnvironment( + environmentId: EnvironmentId, +): PendingTextAttachmentRelease[] { + return useComposerDraftStore + .getState() + .pendingTextAttachmentReleases.filter((release) => release.environmentId === environmentId); +} + +export function persistPendingTextAttachmentReleases( + releases: ReadonlyArray, +): void { + if (releases.length === 0) return; + useComposerDraftStore.setState((state) => { + const next = [...state.pendingTextAttachmentReleases]; + const seen = new Set(next.map(pendingTextAttachmentReleaseKey)); + for (const release of releases) { + const normalized = normalizePendingTextAttachmentReleases([release])[0]; + if (!normalized) continue; + const key = pendingTextAttachmentReleaseKey(normalized); + if (seen.has(key)) continue; + seen.add(key); + next.push(normalized); + } + return { pendingTextAttachmentReleases: next }; + }); + composerDebouncedStorage.flush(); +} + +export function completePendingTextAttachmentRelease(release: PendingTextAttachmentRelease): void { + const completedKey = pendingTextAttachmentReleaseKey(release); + useComposerDraftStore.setState((state) => ({ + pendingTextAttachmentReleases: state.pendingTextAttachmentReleases.filter( + (candidate) => pendingTextAttachmentReleaseKey(candidate) !== completedKey, + ), + })); + composerDebouncedStorage.flush(); +} + export function clearComposerDraftsEnvironment(environmentId: EnvironmentId): void { useComposerDraftStore.setState((state) => { const removedThreadKeys = composerDraftKeysEnvironment(state, environmentId); @@ -3489,6 +3590,9 @@ export function clearComposerDraftsEnvironment(environmentId: EnvironmentId): vo draftsByThreadKey: nextDrafts, draftThreadsByThreadKey: nextDraftThreads, logicalProjectDraftThreadKeyByLogicalProjectKey: nextLogicalMappings, + pendingTextAttachmentReleases: state.pendingTextAttachmentReleases.filter( + (release) => release.environmentId !== environmentId, + ), }; }); composerDebouncedStorage.flush(); diff --git a/apps/web/src/textAttachmentClaims.test.ts b/apps/web/src/textAttachmentClaims.test.ts index 7a0d1b8e21b..4a38056e572 100644 --- a/apps/web/src/textAttachmentClaims.test.ts +++ b/apps/web/src/textAttachmentClaims.test.ts @@ -2,7 +2,7 @@ import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { afterEach, describe, expect, it, vi } from "vite-plus/test"; -import { DraftId } from "./composerDraftStore"; +import { DraftId, useComposerDraftStore } from "./composerDraftStore"; import { textAttachmentClaimChanges, textAttachmentClaims, @@ -30,6 +30,7 @@ const PATH = "/var/t3-data/attachments/text/12345678-1234-1234-1234-123456789abc afterEach(() => { resetTextAttachmentClaimRegistryForTest(); + useComposerDraftStore.setState({ pendingTextAttachmentReleases: [] }); vi.useRealTimers(); }); @@ -352,6 +353,50 @@ describe("text attachment claims", () => { expect(pendingTextAttachmentClaimReleases(environmentId)).toEqual([]); }); + it("reconstructs the release outbox after a module restart and retries on reconnect", async () => { + vi.useFakeTimers(); + const environmentId = EnvironmentId.make("restarted-release-outbox"); + const claim = { path: PATH, draftOwnerId: "thread:env:restarted" }; + const firstRelease = vi.fn(async () => false); + const foreground = releaseTextAttachmentClaimsInBackground({ + environmentId, + claims: [claim], + release: firstRelease, + foregroundWaitMs: 10, + retryDelayMs: 10_000, + }); + await vi.advanceTimersByTimeAsync(10); + await foreground; + + const persistApi = useComposerDraftStore.persist as unknown as { + getOptions: () => { + partialize: (state: ReturnType) => unknown; + merge: ( + persistedState: unknown, + currentState: ReturnType, + ) => ReturnType; + }; + }; + const options = persistApi.getOptions(); + const persistedState = options.partialize(useComposerDraftStore.getState()); + resetTextAttachmentClaimRegistryForTest(); + useComposerDraftStore.setState({ pendingTextAttachmentReleases: [] }); + const hydrated = options.merge(persistedState, useComposerDraftStore.getInitialState()); + useComposerDraftStore.setState({ + pendingTextAttachmentReleases: hydrated.pendingTextAttachmentReleases, + }); + const reconnectRelease = vi.fn(async (_path: string, _draftOwnerId: string) => true); + const operations = { + claim: vi.fn(async () => true), + release: (path: string, draftOwnerId: string) => reconnectRelease(path, draftOwnerId), + }; + + reconcileTextAttachmentClaimsEnvironment(environmentId, [], operations); + await vi.waitFor(() => expect(pendingTextAttachmentClaimReleases(environmentId)).toEqual([])); + + expect(reconnectRelease).toHaveBeenCalledWith(PATH, claim.draftOwnerId); + }); + it("cancels pending retry timers when disposed on unmount", async () => { vi.useFakeTimers(); const claim = vi.fn().mockResolvedValue(false); diff --git a/apps/web/src/textAttachmentClaims.ts b/apps/web/src/textAttachmentClaims.ts index 00b91b75cf7..c6bdc02dd29 100644 --- a/apps/web/src/textAttachmentClaims.ts +++ b/apps/web/src/textAttachmentClaims.ts @@ -1,6 +1,12 @@ import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; -import type { ComposerThreadTarget, DraftId } from "./composerDraftStore"; +import { + completePendingTextAttachmentRelease, + pendingTextAttachmentReleasesEnvironment, + persistPendingTextAttachmentReleases, + type ComposerThreadTarget, + type DraftId, +} from "./composerDraftStore"; import { textAttachmentPaths } from "./textAttachmentPaths"; export function textAttachmentDraftOwnerId(target: ScopedThreadRef | DraftId): string { @@ -262,9 +268,6 @@ export async function releaseTextAttachmentClaimsInBackground(input: { maxRetryDelayMs?: number; operationTimeoutMs?: number; }): Promise { - // Reuse the module-level reconciler registry as a release outbox. It outlives - // the composer or sidebar that initiated teardown and keeps retrying after - // their persisted draft state has been cleared. const claimsByOwner = new Map>(); for (const draftOwnerId of input.draftOwnerIds ?? []) { claimsByOwner.set(draftOwnerId, new Set()); @@ -274,13 +277,18 @@ export async function releaseTextAttachmentClaimsInBackground(input: { paths.add(path); claimsByOwner.set(draftOwnerId, paths); } - const reconciliations = [...claimsByOwner].map(([draftOwnerId, paths]) => { + const owners = [...claimsByOwner].map(([draftOwnerId, paths]) => { const reconciler = getTextAttachmentClaimReconciler({ environmentId: input.environmentId, draftOwnerId, operations: { claim: async () => false, - release: (path, ownerId) => input.release({ path, draftOwnerId: ownerId }), + release: async (path, ownerId) => { + const release = { environmentId: input.environmentId, path, draftOwnerId: ownerId }; + const released = await input.release(release); + if (released) completePendingTextAttachmentRelease(release); + return released; + }, }, ...(input.retryDelayMs === undefined ? {} : { retryDelayMs: input.retryDelayMs }), ...(input.maxRetryDelayMs === undefined ? {} : { maxRetryDelayMs: input.maxRetryDelayMs }), @@ -288,6 +296,19 @@ export async function releaseTextAttachmentClaimsInBackground(input: { ? {} : { operationTimeoutMs: input.operationTimeoutMs }), }); + for (const path of reconciler.snapshot().confirmed) paths.add(path); + return { draftOwnerId, paths, reconciler }; + }); + persistPendingTextAttachmentReleases( + owners.flatMap(({ draftOwnerId, paths }) => + [...paths].map((path) => ({ + environmentId: input.environmentId, + path, + draftOwnerId, + })), + ), + ); + const reconciliations = owners.map(({ paths, reconciler }) => { reconciler.confirmPaths(paths); reconciler.setDesiredPaths([]); return reconciler.settled(); @@ -307,13 +328,39 @@ export async function releaseTextAttachmentClaimsInBackground(input: { export function pendingTextAttachmentClaimReleases( environmentId: EnvironmentId, ): TextAttachmentClaimRelease[] { - const prefix = `${environmentId}:`; - return [...textAttachmentClaimReconcilerRegistry].flatMap(([key, reconciler]) => { - if (!key.startsWith(prefix)) return []; - const draftOwnerId = key.slice(prefix.length); - const { confirmed, desired } = reconciler.snapshot(); - return [...confirmed].flatMap((path) => (desired.has(path) ? [] : [{ path, draftOwnerId }])); - }); + return pendingTextAttachmentReleasesEnvironment(environmentId).map(({ path, draftOwnerId }) => ({ + path, + draftOwnerId, + })); +} + +function restorePendingTextAttachmentClaimReleases( + environmentId: EnvironmentId, + operations: TextAttachmentClaimOperations, +): void { + const claimsByOwner = new Map>(); + for (const { path, draftOwnerId } of pendingTextAttachmentReleasesEnvironment(environmentId)) { + const paths = claimsByOwner.get(draftOwnerId) ?? new Set(); + paths.add(path); + claimsByOwner.set(draftOwnerId, paths); + } + for (const [draftOwnerId, paths] of claimsByOwner) { + const reconciler = getTextAttachmentClaimReconciler({ + environmentId, + draftOwnerId, + operations: { + claim: operations.claim, + release: async (path, ownerId) => { + const release = { environmentId, path, draftOwnerId: ownerId }; + const released = await operations.release(path, ownerId); + if (released) completePendingTextAttachmentRelease(release); + return released; + }, + }, + }); + reconciler.confirmPaths(paths); + reconciler.setDesiredPaths([]); + } } export function reconcileTextAttachmentClaimsEnvironment( @@ -331,6 +378,7 @@ export function reconcileTextAttachmentClaimsEnvironment( reconciler.setDesiredPrompt(prompt); reconciler.reconcileNow(); } + restorePendingTextAttachmentClaimReleases(environmentId, operations); } export async function detachTextAttachmentClaimOwner( From 8292bdc520e11808803e62c48671354694801d18 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 19:16:44 -0400 Subject: [PATCH 69/79] Respect paragraph context for indented Markdown --- apps/server/src/attachmentStore.test.ts | 6 ++++++ packages/shared/src/markdownLinks.test.ts | 17 +++++++++++++++++ packages/shared/src/markdownLinks.ts | 20 ++++++++++++++------ 3 files changed, 37 insertions(+), 6 deletions(-) diff --git a/apps/server/src/attachmentStore.test.ts b/apps/server/src/attachmentStore.test.ts index e612df9b16f..d349bd90019 100644 --- a/apps/server/src/attachmentStore.test.ts +++ b/apps/server/src/attachmentStore.test.ts @@ -171,6 +171,12 @@ describe("attachmentStore", () => { ].join("\n"), }), ).toEqual(new Set()); + expect( + collectTextAttachmentRelativePaths({ + attachmentsDir, + text: `paragraph\n [continued](${attachmentPath})`, + }), + ).toEqual(new Set([NodePath.relative(attachmentsDir, attachmentPath).replaceAll("\\", "/")])); }); it("persists draft claims across reconciliation and restart-style reloads", () => { diff --git a/packages/shared/src/markdownLinks.test.ts b/packages/shared/src/markdownLinks.test.ts index f6f4d560cd9..6b37cc40c5e 100644 --- a/packages/shared/src/markdownLinks.test.ts +++ b/packages/shared/src/markdownLinks.test.ts @@ -46,6 +46,7 @@ describe("markdownLinkDestinations", () => { "[still-inside](/still-inside)", "``` ", "[outside](/outside)", + "", " [space-indented](/space-indented)", "\t[tab-indented](/tab-indented)", "[final](/final)", @@ -53,4 +54,20 @@ describe("markdownLinkDestinations", () => { ), ).toEqual(["/outside", "/final"]); }); + + it("allows indented paragraph and list continuations", () => { + expect( + markdownLinkDestinations( + [ + "paragraph", + " [paragraph-continuation](/paragraph)", + "- list item", + "\t[list-continuation](/list)", + "", + " [actual-code](/code)", + "\t[more-code](/more-code)", + ].join("\n"), + ), + ).toEqual(["/paragraph", "/list"]); + }); }); diff --git a/packages/shared/src/markdownLinks.ts b/packages/shared/src/markdownLinks.ts index e3de625365a..69db3a9ee12 100644 --- a/packages/shared/src/markdownLinks.ts +++ b/packages/shared/src/markdownLinks.ts @@ -63,14 +63,21 @@ function balancedEnd(markdown: string, start: number, opening: string, closing: export function markdownLinkDestinations(markdown: string): ReadonlyArray { const destinations: Array = []; let cursor = 0; + let paragraphActive = false; while (cursor < markdown.length) { - if ( - (cursor === 0 || markdown[cursor - 1] === "\n") && - (markdown[cursor] === "\t" || markdown.startsWith(" ", cursor)) - ) { + if (cursor === 0 || markdown[cursor - 1] === "\n") { const lineEnd = markdown.indexOf("\n", cursor); - cursor = lineEnd < 0 ? markdown.length : lineEnd + 1; - continue; + const line = markdown.slice(cursor, lineEnd < 0 ? markdown.length : lineEnd); + if (/^[ \t]*$/.test(line)) { + paragraphActive = false; + cursor = lineEnd < 0 ? markdown.length : lineEnd + 1; + continue; + } + if (!paragraphActive && (markdown[cursor] === "\t" || markdown.startsWith(" ", cursor))) { + cursor = lineEnd < 0 ? markdown.length : lineEnd + 1; + continue; + } + paragraphActive = true; } const character = markdown[cursor]; if (character === "\\") { @@ -80,6 +87,7 @@ export function markdownLinkDestinations(markdown: string): ReadonlyArray= 3 && isFenceStart(markdown, cursor)) { + paragraphActive = false; cursor = fenceEnd(markdown, cursor, character, runLength); continue; } From dd419da174ee440b599cab2166565837ea9d2d85 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 19:18:26 -0400 Subject: [PATCH 70/79] Restore attachment releases for all environments --- apps/web/src/components/ChatView.tsx | 1 - .../TextAttachmentClaimLifecycle.test.ts | 65 +++++++++++++++++++ .../TextAttachmentClaimLifecycle.tsx | 65 +++++++++++++++++++ apps/web/src/components/chat/ChatComposer.tsx | 14 ---- apps/web/src/routes/__root.tsx | 2 + 5 files changed, 132 insertions(+), 15 deletions(-) create mode 100644 apps/web/src/components/TextAttachmentClaimLifecycle.test.ts create mode 100644 apps/web/src/components/TextAttachmentClaimLifecycle.tsx diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7bdd48112ec..fb4eab1fa8b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -5175,7 +5175,6 @@ function ChatViewContent(props: ChatViewProps) { isServerThread={isServerThread} isLocalDraftThread={isLocalDraftThread} phase={phase} - connectionPhase={activeEnvironmentConnectionPhase} isConnecting={isConnecting} isSendBusy={isSendBusy} isPreparingWorktree={isPreparingWorktree} diff --git a/apps/web/src/components/TextAttachmentClaimLifecycle.test.ts b/apps/web/src/components/TextAttachmentClaimLifecycle.test.ts new file mode 100644 index 00000000000..f505d540134 --- /dev/null +++ b/apps/web/src/components/TextAttachmentClaimLifecycle.test.ts @@ -0,0 +1,65 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { + pendingTextAttachmentReleasesEnvironment, + persistPendingTextAttachmentReleases, + useComposerDraftStore, +} from "../composerDraftStore"; +import { resetTextAttachmentClaimRegistryForTest } from "../textAttachmentClaims"; +import { reconcileConnectedTextAttachmentClaimEnvironments } from "./TextAttachmentClaimLifecycle"; + +afterEach(() => { + resetTextAttachmentClaimRegistryForTest(); + useComposerDraftStore.setState({ pendingTextAttachmentReleases: [] }); +}); + +describe("TextAttachmentClaimLifecycle", () => { + it("restores a background environment outbox without a mounted composer", async () => { + const visibleEnvironmentId = EnvironmentId.make("visible-environment"); + const backgroundEnvironmentId = EnvironmentId.make("background-environment"); + const pendingRelease = { + environmentId: backgroundEnvironmentId, + path: "/tmp/background-attachment.txt", + draftOwnerId: "thread:background:deleted", + }; + persistPendingTextAttachmentReleases([pendingRelease]); + resetTextAttachmentClaimRegistryForTest(); + const release = vi.fn( + async (_environmentId: EnvironmentId, _path: string, _draftOwnerId: string) => true, + ); + const operationsForEnvironment = (environmentId: EnvironmentId) => ({ + claim: vi.fn(async () => true), + release: (path: string, draftOwnerId: string) => release(environmentId, path, draftOwnerId), + }); + + reconcileConnectedTextAttachmentClaimEnvironments( + [ + { environmentId: visibleEnvironmentId, connection: { phase: "connected" } }, + { environmentId: backgroundEnvironmentId, connection: { phase: "connecting" } }, + ], + operationsForEnvironment, + ); + expect(release).not.toHaveBeenCalled(); + expect(pendingTextAttachmentReleasesEnvironment(backgroundEnvironmentId)).toEqual([ + pendingRelease, + ]); + + reconcileConnectedTextAttachmentClaimEnvironments( + [ + { environmentId: visibleEnvironmentId, connection: { phase: "connected" } }, + { environmentId: backgroundEnvironmentId, connection: { phase: "connected" } }, + ], + operationsForEnvironment, + ); + await vi.waitFor(() => + expect(pendingTextAttachmentReleasesEnvironment(backgroundEnvironmentId)).toEqual([]), + ); + + expect(release).toHaveBeenCalledWith( + backgroundEnvironmentId, + pendingRelease.path, + pendingRelease.draftOwnerId, + ); + }); +}); diff --git a/apps/web/src/components/TextAttachmentClaimLifecycle.tsx b/apps/web/src/components/TextAttachmentClaimLifecycle.tsx new file mode 100644 index 00000000000..67258e158a9 --- /dev/null +++ b/apps/web/src/components/TextAttachmentClaimLifecycle.tsx @@ -0,0 +1,65 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import { useCallback, useEffect } from "react"; + +import { composerDraftEntriesEnvironment } from "../composerDraftStore"; +import { useEnvironments } from "../state/environments"; +import { assetEnvironment } from "../state/assets"; +import { useAtomCommand } from "../state/use-atom-command"; +import { + reconcileTextAttachmentClaimsEnvironment, + type TextAttachmentClaimOperations, +} from "../textAttachmentClaims"; + +interface TextAttachmentClaimLifecycleEnvironment { + readonly environmentId: EnvironmentId; + readonly connection: { readonly phase: string }; +} + +export function reconcileConnectedTextAttachmentClaimEnvironments( + environments: ReadonlyArray, + operationsForEnvironment: (environmentId: EnvironmentId) => TextAttachmentClaimOperations, +): void { + for (const environment of environments) { + if (environment.connection.phase !== "connected") continue; + reconcileTextAttachmentClaimsEnvironment( + environment.environmentId, + composerDraftEntriesEnvironment(environment.environmentId), + operationsForEnvironment(environment.environmentId), + ); + } +} + +export function TextAttachmentClaimLifecycle() { + const { environments } = useEnvironments(); + const claimTextAttachment = useAtomCommand(assetEnvironment.claimTextAttachment, { + reportFailure: false, + }); + const releaseTextAttachment = useAtomCommand(assetEnvironment.releaseTextAttachment, { + reportFailure: false, + }); + const operationsForEnvironment = useCallback( + (environmentId: EnvironmentId): TextAttachmentClaimOperations => ({ + claim: async (path, draftOwnerId) => { + const result = await claimTextAttachment({ + environmentId, + input: { path, draftOwnerId }, + }); + return result._tag === "Success" && result.value.claimed; + }, + release: async (path, draftOwnerId) => { + const result = await releaseTextAttachment({ + environmentId, + input: { path, draftOwnerId }, + }); + return result._tag === "Success"; + }, + }), + [claimTextAttachment, releaseTextAttachment], + ); + + useEffect(() => { + reconcileConnectedTextAttachmentClaimEnvironments(environments, operationsForEnvironment); + }, [environments, operationsForEnvironment]); + + return null; +} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 08fc72fe6d9..8c0630cdfa1 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -44,7 +44,6 @@ import { } from "../../composer-logic"; import { deriveComposerSendState, readFileAsDataUrl } from "../ChatView.logic"; import { - composerDraftEntriesEnvironment, type ComposerImageAttachment, type DraftId, type PersistedComposerImageAttachment, @@ -92,7 +91,6 @@ import { textAttachmentClaimChanges, textAttachmentDraftOwnerId, getTextAttachmentClaimReconciler as getRegisteredTextAttachmentClaimReconciler, - reconcileTextAttachmentClaimsEnvironment, releaseTextAttachmentClaimsInBackground, runTextAttachmentUpload, } from "../../textAttachmentClaims"; @@ -472,7 +470,6 @@ export interface ChatComposerProps { // Session phase phase: SessionPhase; - connectionPhase: EnvironmentConnectionPresentation["phase"]; isConnecting: boolean; isSendBusy: boolean; isPreparingWorktree: boolean; @@ -582,7 +579,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isServerThread: _isServerThread, isLocalDraftThread: _isLocalDraftThread, phase, - connectionPhase, isConnecting, isSendBusy, isPreparingWorktree, @@ -980,16 +976,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) getTextAttachmentClaimReconciler().setDesiredPrompt(prompt); }, [getTextAttachmentClaimReconciler, prompt, textAttachmentClaimsHeld]); - useEffect(() => { - if (connectionPhase === "connected") { - reconcileTextAttachmentClaimsEnvironment( - environmentId, - composerDraftEntriesEnvironment(environmentId), - textAttachmentClaimOperations, - ); - } - }, [connectionPhase, environmentId, textAttachmentClaimOperations]); - // ------------------------------------------------------------------ // Derived: composer send state // ------------------------------------------------------------------ diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 69739ce4570..a74dba2795a 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -19,6 +19,7 @@ import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstall import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog"; import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification"; import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator"; +import { TextAttachmentClaimLifecycle } from "../components/TextAttachmentClaimLifecycle"; import { Button } from "../components/ui/button"; import { AnchoredToastProvider, @@ -133,6 +134,7 @@ function RootRouteView() { + {primaryEnvironmentAuthenticated ? : null} {primaryEnvironmentAuthenticated ? : null} {appShell} From 12d3a1bb937162bbd7a7eb3cf50989a2ddaa77b5 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 19:21:05 -0400 Subject: [PATCH 71/79] Bound pending attachment release storage --- apps/web/src/components/chat/ChatComposer.tsx | 2 +- apps/web/src/composerDraftStore.test.ts | 51 +++++++++++++++++++ apps/web/src/composerDraftStore.ts | 17 +++++-- apps/web/src/textAttachmentClaims.ts | 18 +++++-- 4 files changed, 81 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 8c0630cdfa1..6c4ffc895b6 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1876,7 +1876,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }); return released._tag === "Success"; }, - }), + }).then(() => undefined), }); }) .catch(() => null); diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index d231ecc853f..2b0ca414874 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -69,6 +69,8 @@ import { markPromotedDraftThreadByRef, markPromotedDraftThreads, markPromotedDraftThreadsByRef, + MAX_PENDING_TEXT_ATTACHMENT_RELEASES, + persistPendingTextAttachmentReleases, type ComposerImageAttachment, useComposerDraftStore, DraftId, @@ -154,6 +156,55 @@ function providerModelOptions( } const TEST_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); + +describe("composerDraftStore text attachment release outbox capacity", () => { + beforeEach(() => { + useComposerDraftStore.setState({ pendingTextAttachmentReleases: [] }); + removeLocalStorageItem(COMPOSER_DRAFT_STORAGE_KEY); + }); + + afterEach(() => { + useComposerDraftStore.setState({ pendingTextAttachmentReleases: [] }); + removeLocalStorageItem(COMPOSER_DRAFT_STORAGE_KEY); + vi.restoreAllMocks(); + }); + + it("rejects the newest live append without truncating durable records", async () => { + const existing = Array.from({ length: MAX_PENDING_TEXT_ATTACHMENT_RELEASES }, (_, index) => ({ + environmentId: TEST_ENVIRONMENT_ID, + path: `/tmp/attachment-${index}.txt`, + draftOwnerId: `thread:env:${index}`, + })); + expect(persistPendingTextAttachmentReleases(existing).accepted).toBe(true); + const rejected = { + environmentId: TEST_ENVIRONMENT_ID, + path: "/tmp/rejected-attachment.txt", + draftOwnerId: "thread:env:rejected", + }; + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + + const result = persistPendingTextAttachmentReleases([rejected]); + + expect(result).toEqual({ accepted: false, rejected: [rejected] }); + expect(warn).toHaveBeenCalledOnce(); + expect(useComposerDraftStore.getState().pendingTextAttachmentReleases).toEqual(existing); + + const persistApi = useComposerDraftStore.persist as unknown as { + getOptions: () => { + storage: { + getItem: (key: string) => Promise | unknown; + }; + }; + }; + const stored = (await persistApi.getOptions().storage.getItem(COMPOSER_DRAFT_STORAGE_KEY)) as { + state: { pendingTextAttachmentReleases: unknown[] }; + }; + expect(stored.state.pendingTextAttachmentReleases).toHaveLength( + MAX_PENDING_TEXT_ATTACHMENT_RELEASES, + ); + expect(stored.state.pendingTextAttachmentReleases).not.toContainEqual(rejected); + }); +}); const OTHER_TEST_ENVIRONMENT_ID = EnvironmentId.make("environment-remote"); const LEGACY_TEST_ENVIRONMENT_ID = EnvironmentId.make("__legacy__"); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index cc9b49cf9e0..3681a228d63 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -238,7 +238,7 @@ const PersistedTextAttachmentRelease = Schema.Struct({ path: Schema.String, draftOwnerId: Schema.String, }); -const MAX_PENDING_TEXT_ATTACHMENT_RELEASES = 10_000; +export const MAX_PENDING_TEXT_ATTACHMENT_RELEASES = 1_000; const MAX_PENDING_TEXT_ATTACHMENT_ENVIRONMENT_LENGTH = 1_024; const MAX_PENDING_TEXT_ATTACHMENT_PATH_LENGTH = 8_192; const MAX_PENDING_TEXT_ATTACHMENT_OWNER_LENGTH = 1_024; @@ -3531,8 +3531,9 @@ export function pendingTextAttachmentReleasesEnvironment( export function persistPendingTextAttachmentReleases( releases: ReadonlyArray, -): void { - if (releases.length === 0) return; +): { readonly accepted: boolean; readonly rejected: ReadonlyArray } { + if (releases.length === 0) return { accepted: true, rejected: [] }; + const rejected: PendingTextAttachmentRelease[] = []; useComposerDraftStore.setState((state) => { const next = [...state.pendingTextAttachmentReleases]; const seen = new Set(next.map(pendingTextAttachmentReleaseKey)); @@ -3541,12 +3542,22 @@ export function persistPendingTextAttachmentReleases( if (!normalized) continue; const key = pendingTextAttachmentReleaseKey(normalized); if (seen.has(key)) continue; + if (next.length >= MAX_PENDING_TEXT_ATTACHMENT_RELEASES) { + rejected.push(normalized); + continue; + } seen.add(key); next.push(normalized); } return { pendingTextAttachmentReleases: next }; }); composerDebouncedStorage.flush(); + if (rejected.length > 0) { + console.warn("Text attachment release outbox is full; new releases remain memory-only.", { + rejectedCount: rejected.length, + }); + } + return { accepted: rejected.length === 0, rejected }; } export function completePendingTextAttachmentRelease(release: PendingTextAttachmentRelease): void { diff --git a/apps/web/src/textAttachmentClaims.ts b/apps/web/src/textAttachmentClaims.ts index c6bdc02dd29..e6d59e03a88 100644 --- a/apps/web/src/textAttachmentClaims.ts +++ b/apps/web/src/textAttachmentClaims.ts @@ -267,7 +267,10 @@ export async function releaseTextAttachmentClaimsInBackground(input: { retryDelayMs?: number; maxRetryDelayMs?: number; operationTimeoutMs?: number; -}): Promise { +}): Promise<{ + readonly durable: boolean; + readonly rejected: ReadonlyArray; +}> { const claimsByOwner = new Map>(); for (const draftOwnerId of input.draftOwnerIds ?? []) { claimsByOwner.set(draftOwnerId, new Set()); @@ -299,7 +302,7 @@ export async function releaseTextAttachmentClaimsInBackground(input: { for (const path of reconciler.snapshot().confirmed) paths.add(path); return { draftOwnerId, paths, reconciler }; }); - persistPendingTextAttachmentReleases( + const persistence = persistPendingTextAttachmentReleases( owners.flatMap(({ draftOwnerId, paths }) => [...paths].map((path) => ({ environmentId: input.environmentId, @@ -313,7 +316,12 @@ export async function releaseTextAttachmentClaimsInBackground(input: { reconciler.setDesiredPaths([]); return reconciler.settled(); }); - if (reconciliations.length === 0) return; + if (reconciliations.length === 0) { + return { + durable: persistence.accepted, + rejected: persistence.rejected.map(({ path, draftOwnerId }) => ({ path, draftOwnerId })), + }; + } let timeout: ReturnType | null = null; await Promise.race([ Promise.all(reconciliations), @@ -323,6 +331,10 @@ export async function releaseTextAttachmentClaimsInBackground(input: { ]).finally(() => { if (timeout !== null) clearTimeout(timeout); }); + return { + durable: persistence.accepted, + rejected: persistence.rejected.map(({ path, draftOwnerId }) => ({ path, draftOwnerId })), + }; } export function pendingTextAttachmentClaimReleases( From 48ce3ec46f97626ba2052de24444cf1077ccc07a Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 19:26:00 -0400 Subject: [PATCH 72/79] Use CommonMark parsing for attachment links --- packages/shared/package.json | 1 + packages/shared/src/markdownLinks.test.ts | 31 +++++- packages/shared/src/markdownLinks.ts | 123 ++-------------------- pnpm-lock.yaml | 3 + 4 files changed, 43 insertions(+), 115 deletions(-) diff --git a/packages/shared/package.json b/packages/shared/package.json index 9a0af6f2071..abf32c7e959 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -198,6 +198,7 @@ "@t3tools/contracts": "workspace:*", "effect": "catalog:", "jose": "catalog:", + "mdast-util-from-markdown": "^2.0.3", "yaml": "catalog:" }, "devDependencies": { diff --git a/packages/shared/src/markdownLinks.test.ts b/packages/shared/src/markdownLinks.test.ts index 6b37cc40c5e..41d41b40200 100644 --- a/packages/shared/src/markdownLinks.test.ts +++ b/packages/shared/src/markdownLinks.test.ts @@ -63,11 +63,36 @@ describe("markdownLinkDestinations", () => { " [paragraph-continuation](/paragraph)", "- list item", "\t[list-continuation](/list)", - "", - " [actual-code](/code)", - "\t[more-code](/more-code)", ].join("\n"), ), ).toEqual(["/paragraph", "/list"]); + expect(markdownLinkDestinations(" [actual-code](/code)\n\t[more-code](/more-code)")).toEqual( + [], + ); + }); + + it("keeps block constructs from activating paragraph continuation", () => { + expect( + markdownLinkDestinations( + [ + "# heading", + " [after-heading-code](/after-heading-code)", + "", + "---", + " [after-break-code](/after-break-code)", + "", + "> [quoted-code](/quoted-code)", + "", + "- list item", + "", + " [list-code](/list-code)", + "- next item", + " [list-paragraph](/list-paragraph)", + "", + "paragraph", + " [paragraph-continuation](/paragraph-continuation)", + ].join("\n"), + ), + ).toEqual(["/list-paragraph", "/paragraph-continuation"]); }); }); diff --git a/packages/shared/src/markdownLinks.ts b/packages/shared/src/markdownLinks.ts index 69db3a9ee12..88d6aea4d17 100644 --- a/packages/shared/src/markdownLinks.ts +++ b/packages/shared/src/markdownLinks.ts @@ -1,120 +1,19 @@ -function delimiterRunLength(markdown: string, start: number, delimiter: string): number { - let end = start; - while (markdown[end] === delimiter) end += 1; - return end - start; -} - -function isFenceStart(markdown: string, index: number): boolean { - const lineStart = markdown.lastIndexOf("\n", index - 1) + 1; - const prefix = markdown.slice(lineStart, index); - return prefix.length <= 3 && /^ *$/.test(prefix); -} - -function fenceEnd(markdown: string, start: number, delimiter: string, runLength: number): number { - let lineStart = markdown.indexOf("\n", start) + 1; - if (lineStart === 0) return markdown.length; - while (lineStart < markdown.length) { - let cursor = lineStart; - let spaces = 0; - while (markdown[cursor] === " " && spaces < 4) { - cursor += 1; - spaces += 1; - } - if ( - spaces <= 3 && - markdown[cursor] === delimiter && - delimiterRunLength(markdown, cursor, delimiter) >= runLength - ) { - const lineEnd = markdown.indexOf("\n", cursor); - const closingRunLength = delimiterRunLength(markdown, cursor, delimiter); - const suffix = markdown.slice( - cursor + closingRunLength, - lineEnd < 0 ? markdown.length : lineEnd, - ); - if (/^[ \t]*$/.test(suffix)) { - return lineEnd < 0 ? markdown.length : lineEnd + 1; - } - } - const nextLine = markdown.indexOf("\n", lineStart); - if (nextLine < 0) return markdown.length; - lineStart = nextLine + 1; - } - return markdown.length; -} +import { fromMarkdown } from "mdast-util-from-markdown"; -function balancedEnd(markdown: string, start: number, opening: string, closing: string): number { - let depth = 1; - for (let cursor = start; cursor < markdown.length; cursor += 1) { - const character = markdown[cursor]; - if (character === "\\") { - cursor += 1; - continue; - } - if (character === opening) { - depth += 1; - } else if (character === closing) { - depth -= 1; - if (depth === 0) return cursor; - } - } - return -1; +interface MarkdownNode { + readonly type: string; + readonly url?: string; + readonly children?: ReadonlyArray; } export function markdownLinkDestinations(markdown: string): ReadonlyArray { const destinations: Array = []; - let cursor = 0; - let paragraphActive = false; - while (cursor < markdown.length) { - if (cursor === 0 || markdown[cursor - 1] === "\n") { - const lineEnd = markdown.indexOf("\n", cursor); - const line = markdown.slice(cursor, lineEnd < 0 ? markdown.length : lineEnd); - if (/^[ \t]*$/.test(line)) { - paragraphActive = false; - cursor = lineEnd < 0 ? markdown.length : lineEnd + 1; - continue; - } - if (!paragraphActive && (markdown[cursor] === "\t" || markdown.startsWith(" ", cursor))) { - cursor = lineEnd < 0 ? markdown.length : lineEnd + 1; - continue; - } - paragraphActive = true; - } - const character = markdown[cursor]; - if (character === "\\") { - cursor += 2; - continue; - } - if (character === "`" || character === "~") { - const runLength = delimiterRunLength(markdown, cursor, character); - if (runLength >= 3 && isFenceStart(markdown, cursor)) { - paragraphActive = false; - cursor = fenceEnd(markdown, cursor, character, runLength); - continue; - } - if (character === "`") { - const delimiter = "`".repeat(runLength); - const end = markdown.indexOf(delimiter, cursor + runLength); - cursor = end < 0 ? cursor + runLength : end + runLength; - continue; - } - } - if (character !== "[") { - cursor += 1; - continue; - } - const labelEnd = balancedEnd(markdown, cursor + 1, "[", "]"); - if (labelEnd < 0 || markdown[labelEnd + 1] !== "(") { - cursor += 1; - continue; - } - const destinationStart = labelEnd + 2; - const destinationEnd = balancedEnd(markdown, destinationStart, "(", ")"); - if (destinationEnd < 0 || destinationEnd === destinationStart) { - cursor = destinationStart; - continue; + const visit = (node: MarkdownNode): void => { + if (node.type === "link" && typeof node.url === "string") { + destinations.push(node.url); } - destinations.push(markdown.slice(destinationStart, destinationEnd)); - cursor = destinationEnd + 1; - } + for (const child of node.children ?? []) visit(child); + }; + visit(fromMarkdown(markdown)); return destinations; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ee737f9e750..0b785e8e5b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -819,6 +819,9 @@ importers: jose: specifier: 'catalog:' version: 6.2.2 + mdast-util-from-markdown: + specifier: ^2.0.3 + version: 2.0.3 yaml: specifier: ^2.9.0 version: 2.9.0 From e545ecd548fcadc75be405e329b2ca7652df74cf Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 19:28:16 -0400 Subject: [PATCH 73/79] Coalesce attachment claim reconciliation --- .../TextAttachmentClaimLifecycle.tsx | 22 +++++- apps/web/src/textAttachmentClaims.test.ts | 31 ++++++++ apps/web/src/textAttachmentClaims.ts | 76 +++++++++++++++---- 3 files changed, 114 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/TextAttachmentClaimLifecycle.tsx b/apps/web/src/components/TextAttachmentClaimLifecycle.tsx index 67258e158a9..27bf1de36e0 100644 --- a/apps/web/src/components/TextAttachmentClaimLifecycle.tsx +++ b/apps/web/src/components/TextAttachmentClaimLifecycle.tsx @@ -1,5 +1,5 @@ import type { EnvironmentId } from "@t3tools/contracts"; -import { useCallback, useEffect } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { composerDraftEntriesEnvironment } from "../composerDraftStore"; import { useEnvironments } from "../state/environments"; @@ -18,6 +18,7 @@ interface TextAttachmentClaimLifecycleEnvironment { export function reconcileConnectedTextAttachmentClaimEnvironments( environments: ReadonlyArray, operationsForEnvironment: (environmentId: EnvironmentId) => TextAttachmentClaimOperations, + forceEnvironmentIds: ReadonlySet = new Set(), ): void { for (const environment of environments) { if (environment.connection.phase !== "connected") continue; @@ -25,12 +26,14 @@ export function reconcileConnectedTextAttachmentClaimEnvironments( environment.environmentId, composerDraftEntriesEnvironment(environment.environmentId), operationsForEnvironment(environment.environmentId), + { force: forceEnvironmentIds.has(environment.environmentId) }, ); } } export function TextAttachmentClaimLifecycle() { const { environments } = useEnvironments(); + const connectedEnvironmentIdsRef = useRef(new Set()); const claimTextAttachment = useAtomCommand(assetEnvironment.claimTextAttachment, { reportFailure: false, }); @@ -58,7 +61,22 @@ export function TextAttachmentClaimLifecycle() { ); useEffect(() => { - reconcileConnectedTextAttachmentClaimEnvironments(environments, operationsForEnvironment); + const connectedEnvironmentIds = new Set( + environments.flatMap((environment) => + environment.connection.phase === "connected" ? [environment.environmentId] : [], + ), + ); + const newlyConnectedEnvironmentIds = new Set( + [...connectedEnvironmentIds].filter( + (environmentId) => !connectedEnvironmentIdsRef.current.has(environmentId), + ), + ); + reconcileConnectedTextAttachmentClaimEnvironments( + environments, + operationsForEnvironment, + newlyConnectedEnvironmentIds, + ); + connectedEnvironmentIdsRef.current = connectedEnvironmentIds; }, [environments, operationsForEnvironment]); return null; diff --git a/apps/web/src/textAttachmentClaims.test.ts b/apps/web/src/textAttachmentClaims.test.ts index 4a38056e572..065003520aa 100644 --- a/apps/web/src/textAttachmentClaims.test.ts +++ b/apps/web/src/textAttachmentClaims.test.ts @@ -112,6 +112,36 @@ describe("text attachment claims", () => { expect(reconciler.snapshot().confirmed).toEqual(new Set([PATH])); }); + it("coalesces mounted composer and lifecycle reconciliation for unchanged paths", async () => { + vi.useFakeTimers(); + const environmentId = EnvironmentId.make("coalesced-lifecycle-environment"); + const draft = DraftId.make("coalesced-draft"); + const draftOwnerId = textAttachmentDraftOwnerId(draft); + const operations = { + claim: vi.fn(async () => false), + release: vi.fn(async () => true), + }; + const reconciler = getTextAttachmentClaimReconciler({ + environmentId, + draftOwnerId, + operations, + }); + const entries = [{ target: draft, prompt: `[shared.txt](${PATH})` }]; + + reconciler.setDesiredPrompt(entries[0]!.prompt); + reconcileTextAttachmentClaimsEnvironment(environmentId, entries, operations); + reconcileTextAttachmentClaimsEnvironment(environmentId, entries, operations); + await reconciler.settled(); + expect(operations.claim).toHaveBeenCalledOnce(); + + reconcileTextAttachmentClaimsEnvironment(environmentId, entries, operations); + await vi.advanceTimersByTimeAsync(249); + expect(operations.claim).toHaveBeenCalledOnce(); + + await vi.advanceTimersByTimeAsync(1); + await vi.waitFor(() => expect(operations.claim).toHaveBeenCalledTimes(2)); + }); + it("reconciles immediately when a connection resumes", async () => { vi.useFakeTimers(); const claim = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true); @@ -445,6 +475,7 @@ describe("text attachment claims", () => { environmentId, [{ target: draft, prompt: `[shared.txt](${PATH})` }], operations, + { force: true }, ); await background.settled(); diff --git a/apps/web/src/textAttachmentClaims.ts b/apps/web/src/textAttachmentClaims.ts index e6d59e03a88..eb677d9238b 100644 --- a/apps/web/src/textAttachmentClaims.ts +++ b/apps/web/src/textAttachmentClaims.ts @@ -46,6 +46,8 @@ export class TextAttachmentClaimReconciler { #queue: Promise = Promise.resolve(); #retryTimer: ReturnType | null = null; #retryCount = 0; + #reconcilePending = false; + #reconcileRequested = false; #disposed = false; #paused = false; @@ -63,8 +65,8 @@ export class TextAttachmentClaimReconciler { this.#operationTimeoutMs = options.operationTimeoutMs ?? 10_000; } - setDesiredPrompt(prompt: string): void { - this.setDesiredPaths(textAttachmentPaths(prompt)); + setDesiredPrompt(prompt: string): boolean { + return this.setDesiredPaths(textAttachmentPaths(prompt)); } setOperations(operations: { @@ -75,12 +77,20 @@ export class TextAttachmentClaimReconciler { this.#release = operations.release; } - setDesiredPaths(paths: Iterable): void { - if (this.#disposed) return; - this.#desired = new Set(paths); + setDesiredPaths(paths: Iterable): boolean { + if (this.#disposed) return false; + const nextDesired = new Set(paths); + if ( + nextDesired.size === this.#desired.size && + [...nextDesired].every((path) => this.#desired.has(path)) + ) { + return false; + } + this.#desired = nextDesired; this.#retryCount = 0; this.#clearRetry(); - if (!this.#paused) this.#enqueueReconcile(); + if (!this.#paused) this.#enqueueReconcile(true); + return true; } confirmPaths(paths: Iterable): void { @@ -95,19 +105,29 @@ export class TextAttachmentClaimReconciler { reconcileNow(): void { if (this.#disposed || this.#paused) return; + if (this.#reconcilePending) return; this.#retryCount = 0; this.#clearRetry(); - this.#enqueueReconcile(); + this.#enqueueReconcile(true); + } + + reconcileIfNeeded(): void { + if (this.#disposed || this.#paused || this.#retryTimer !== null || !this.#hasPendingChanges()) { + return; + } + this.#enqueueReconcile(false); } dispose(): void { this.#disposed = true; + this.#reconcileRequested = false; this.#clearRetry(); } async pause(): Promise { if (this.#disposed) return; this.#paused = true; + this.#reconcileRequested = false; this.#clearRetry(); await this.#queue; } @@ -129,9 +149,24 @@ export class TextAttachmentClaimReconciler { await this.#queue; } - #enqueueReconcile(): void { + #enqueueReconcile(requestAfterPending: boolean): void { if (this.#disposed || this.#paused) return; - this.#queue = this.#queue.catch(() => undefined).then(() => this.#reconcile()); + if (this.#reconcilePending) { + if (requestAfterPending) this.#reconcileRequested = true; + return; + } + this.#reconcilePending = true; + this.#queue = this.#queue + .catch(() => undefined) + .then(() => this.#reconcile()) + .finally(() => { + this.#reconcilePending = false; + if (!this.#reconcileRequested || this.#disposed || this.#paused) return; + this.#reconcileRequested = false; + this.#retryCount = 0; + this.#clearRetry(); + this.#enqueueReconcile(false); + }); } async #reconcile(): Promise { @@ -159,7 +194,7 @@ export class TextAttachmentClaimReconciler { this.#retryCount += 1; this.#retryTimer = setTimeout(() => { this.#retryTimer = null; - this.#enqueueReconcile(); + this.#enqueueReconcile(false); }, delay); } @@ -182,6 +217,16 @@ export class TextAttachmentClaimReconciler { if (timeout !== null) clearTimeout(timeout); }); } + + #hasPendingChanges(): boolean { + for (const path of this.#desired) { + if (!this.#confirmed.has(path)) return true; + } + for (const path of this.#confirmed) { + if (!this.#desired.has(path)) return true; + } + return false; + } } export async function retryTextAttachmentOperation( @@ -314,6 +359,7 @@ export async function releaseTextAttachmentClaimsInBackground(input: { const reconciliations = owners.map(({ paths, reconciler }) => { reconciler.confirmPaths(paths); reconciler.setDesiredPaths([]); + reconciler.reconcileIfNeeded(); return reconciler.settled(); }); if (reconciliations.length === 0) { @@ -349,6 +395,7 @@ export function pendingTextAttachmentClaimReleases( function restorePendingTextAttachmentClaimReleases( environmentId: EnvironmentId, operations: TextAttachmentClaimOperations, + force: boolean, ): void { const claimsByOwner = new Map>(); for (const { path, draftOwnerId } of pendingTextAttachmentReleasesEnvironment(environmentId)) { @@ -372,6 +419,8 @@ function restorePendingTextAttachmentClaimReleases( }); reconciler.confirmPaths(paths); reconciler.setDesiredPaths([]); + if (force) reconciler.reconcileNow(); + else reconciler.reconcileIfNeeded(); } } @@ -379,6 +428,7 @@ export function reconcileTextAttachmentClaimsEnvironment( environmentId: EnvironmentId, entries: ReadonlyArray<{ target: ComposerThreadTarget; prompt: string }>, operations: TextAttachmentClaimOperations, + options: { readonly force?: boolean } = {}, ): void { for (const { target, prompt } of entries) { const draftOwnerId = textAttachmentDraftOwnerId(target); @@ -387,10 +437,10 @@ export function reconcileTextAttachmentClaimsEnvironment( draftOwnerId, operations, }); - reconciler.setDesiredPrompt(prompt); - reconciler.reconcileNow(); + const desiredChanged = reconciler.setDesiredPrompt(prompt); + if (options.force === true && !desiredChanged) reconciler.reconcileNow(); } - restorePendingTextAttachmentClaimReleases(environmentId, operations); + restorePendingTextAttachmentClaimReleases(environmentId, operations, options.force === true); } export async function detachTextAttachmentClaimOwner( From ea6032a440e5740c84262cb8a305b3ba42c325a7 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 19:31:35 -0400 Subject: [PATCH 74/79] Retry forced reconciliation after in-flight work --- apps/web/src/textAttachmentClaims.test.ts | 30 +++++++++++++++++++++++ apps/web/src/textAttachmentClaims.ts | 5 +++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/apps/web/src/textAttachmentClaims.test.ts b/apps/web/src/textAttachmentClaims.test.ts index 065003520aa..b322edb5b88 100644 --- a/apps/web/src/textAttachmentClaims.test.ts +++ b/apps/web/src/textAttachmentClaims.test.ts @@ -142,6 +142,36 @@ describe("text attachment claims", () => { await vi.waitFor(() => expect(operations.claim).toHaveBeenCalledTimes(2)); }); + it("runs one immediate follow-up when reconnect arrives during an in-flight claim", async () => { + vi.useFakeTimers(); + const environmentId = EnvironmentId.make("in-flight-reconnect-environment"); + const draft = DraftId.make("in-flight-reconnect-draft"); + let finishFirstClaim: (claimed: boolean) => void = () => undefined; + const operations = { + claim: vi + .fn<(path: string, draftOwnerId: string) => Promise>() + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishFirstClaim = resolve; + }), + ) + .mockResolvedValueOnce(true), + release: vi.fn(async () => true), + }; + const entries = [{ target: draft, prompt: `[shared.txt](${PATH})` }]; + + reconcileTextAttachmentClaimsEnvironment(environmentId, entries, operations); + await vi.waitFor(() => expect(operations.claim).toHaveBeenCalledOnce()); + reconcileTextAttachmentClaimsEnvironment(environmentId, entries, operations, { force: true }); + finishFirstClaim(false); + + await vi.waitFor(() => expect(operations.claim).toHaveBeenCalledTimes(2)); + await vi.advanceTimersByTimeAsync(1_000); + + expect(operations.claim).toHaveBeenCalledTimes(2); + }); + it("reconciles immediately when a connection resumes", async () => { vi.useFakeTimers(); const claim = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true); diff --git a/apps/web/src/textAttachmentClaims.ts b/apps/web/src/textAttachmentClaims.ts index eb677d9238b..bc210d3787b 100644 --- a/apps/web/src/textAttachmentClaims.ts +++ b/apps/web/src/textAttachmentClaims.ts @@ -105,9 +105,12 @@ export class TextAttachmentClaimReconciler { reconcileNow(): void { if (this.#disposed || this.#paused) return; - if (this.#reconcilePending) return; this.#retryCount = 0; this.#clearRetry(); + if (this.#reconcilePending) { + this.#reconcileRequested = true; + return; + } this.#enqueueReconcile(true); } From 47c23be3ab500f26108e37729a50566f46c05c0c Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 19:37:08 -0400 Subject: [PATCH 75/79] Reserve text attachment metadata filenames --- apps/server/src/attachmentStore.test.ts | 38 +++++++++++++++++++++++++ apps/server/src/attachmentStore.ts | 4 +++ 2 files changed, 42 insertions(+) diff --git a/apps/server/src/attachmentStore.test.ts b/apps/server/src/attachmentStore.test.ts index d349bd90019..08dc3a9c49e 100644 --- a/apps/server/src/attachmentStore.test.ts +++ b/apps/server/src/attachmentStore.test.ts @@ -136,6 +136,44 @@ describe("attachmentStore", () => { expect(NodePath.basename(longPath)).toMatch(/\.tsx$/); }); + it("reserves internal text attachment metadata basenames", () => { + const attachmentsDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3code-text-internal-name-"), + ); + try { + const generatedPath = createTextAttachmentPath({ + attachmentsDir, + fileName: TEXT_ATTACHMENT_METADATA_FILE, + }); + expect(NodePath.basename(generatedPath)).toBe("_t3-attachment.json"); + + const attachmentPath = writeClaimedTextAttachment({ + attachmentsDir, + fileName: TEXT_ATTACHMENT_METADATA_FILE, + contents: "user attachment contents", + draftOwnerId: "internal-name-owner", + }); + const metadataPath = NodePath.join( + NodePath.dirname(attachmentPath), + TEXT_ATTACHMENT_METADATA_FILE, + ); + expect(NodePath.basename(attachmentPath)).toBe("_t3-attachment.json"); + expect(attachmentPath).not.toBe(metadataPath); + expect(NodeFS.readFileSync(attachmentPath, "utf8")).toBe("user attachment contents"); + expect(NodeFS.existsSync(metadataPath)).toBe(true); + expect( + releaseTextAttachment({ + attachmentsDir, + path: attachmentPath, + draftOwnerId: "internal-name-owner", + nowMs: 0, + }), + ).toBe(true); + } finally { + NodeFS.rmSync(attachmentsDir, { recursive: true, force: true }); + } + }); + it("validates and collects server-owned text attachment paths", () => { const attachmentsDir = NodePath.join(NodeOS.tmpdir(), "t3code-(attachments)"); const attachmentPath = createTextAttachmentPath({ attachmentsDir, fileName: "notes.txt" }); diff --git a/apps/server/src/attachmentStore.ts b/apps/server/src/attachmentStore.ts index 76b97b9aa07..94becb35927 100644 --- a/apps/server/src/attachmentStore.ts +++ b/apps/server/src/attachmentStore.ts @@ -16,6 +16,7 @@ const ATTACHMENT_FILENAME_EXTENSIONS = [...SAFE_IMAGE_FILE_EXTENSIONS, ".bin"]; const TEXT_ATTACHMENT_DIRECTORY = "text"; export const TEXT_ATTACHMENT_METADATA_FILE = ".t3-attachment.json"; export const TEXT_ATTACHMENT_PENDING_DIRECTORY = ".pending"; +const INTERNAL_TEXT_ATTACHMENT_FILE_NAME_PATTERN = /^\.t3-attachment\.json(?:\.|$)/i; export const TEXT_ATTACHMENT_DELETE_GRACE_MS = 60_000; const TEXT_ATTACHMENT_FILE_NAME_MAX_CHARS = 120; const WINDOWS_RESERVED_FILE_NAME_PATTERN = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i; @@ -129,6 +130,9 @@ export function createTextAttachmentPath(input: { if (WINDOWS_RESERVED_FILE_NAME_PATTERN.test(safeName)) { safeName = `_${safeName}`; } + if (INTERNAL_TEXT_ATTACHMENT_FILE_NAME_PATTERN.test(safeName)) { + safeName = `_${safeName.slice(1)}`; + } if (safeName.length > TEXT_ATTACHMENT_FILE_NAME_MAX_CHARS) { const extensionIndex = safeName.lastIndexOf("."); const extension = extensionIndex > 0 ? safeName.slice(extensionIndex).slice(0, 20) : ""; From e5e272f7fe1bfc0ba5c45a15bfcd68bfdcad4926 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 19:41:10 -0400 Subject: [PATCH 76/79] Include archived drafts in project removal --- apps/web/src/components/Sidebar.logic.test.ts | 30 +++++++++++ apps/web/src/components/Sidebar.logic.ts | 27 ++++++++++ apps/web/src/components/Sidebar.tsx | 54 +++++++++++++++---- apps/web/src/composerDraftStore.test.ts | 16 ++++-- 4 files changed, 114 insertions(+), 13 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 574e33d4dab..3017f8d227a 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { createThreadJumpHintVisibilityController, + getProjectRemovalThreadRefs, getSidebarThreadIdsToPrewarm, getVisibleSidebarThreadIds, resolveAdjacentThreadId, @@ -28,6 +29,7 @@ import { ProviderInstanceId, ThreadId, } from "@t3tools/contracts"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, @@ -37,6 +39,34 @@ import { const localEnvironmentId = EnvironmentId.make("environment-local"); +describe("getProjectRemovalThreadRefs", () => { + it("includes archived server threads with composer drafts", () => { + const projectId = ProjectId.make("project-a"); + const liveThreadId = ThreadId.make("thread-live"); + const archivedThreadId = ThreadId.make("thread-archived"); + + expect( + getProjectRemovalThreadRefs({ + environmentId: localEnvironmentId, + projectId, + liveThreads: [{ environmentId: localEnvironmentId, id: liveThreadId, projectId }], + archivedThreads: [ + { environmentId: localEnvironmentId, id: archivedThreadId, projectId }, + { environmentId: localEnvironmentId, id: liveThreadId, projectId }, + { + environmentId: localEnvironmentId, + id: ThreadId.make("thread-other-project"), + projectId: ProjectId.make("project-b"), + }, + ], + }), + ).toEqual([ + scopeThreadRef(localEnvironmentId, liveThreadId), + scopeThreadRef(localEnvironmentId, archivedThreadId), + ]); + }); +}); + describe("resolveSidebarStageBadgeLabel", () => { it("returns Nightly for nightly primary server versions", () => { expect( diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 4e7614ed551..ab9d1a7cbe1 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -10,6 +10,8 @@ import type { SidebarThreadSummary, Thread } from "../types"; import { cn } from "../lib/utils"; import { isLatestTurnSettled } from "../session-logic"; import { resolveServerBackedAppStageLabel } from "../branding.logic"; +import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment"; +import type { EnvironmentId, ProjectId, ScopedThreadRef, ThreadId } from "@t3tools/contracts"; export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 100; @@ -26,6 +28,31 @@ type SidebarProject = { export type ThreadTraversalDirection = "previous" | "next"; +export function getProjectRemovalThreadRefs(input: { + environmentId: EnvironmentId; + projectId: ProjectId; + liveThreads: ReadonlyArray<{ + environmentId: EnvironmentId; + id: ThreadId; + projectId: ProjectId; + }>; + archivedThreads: ReadonlyArray<{ + environmentId: EnvironmentId; + id: ThreadId; + projectId: ProjectId; + }>; +}): ScopedThreadRef[] { + const refs = new Map(); + for (const thread of [...input.liveThreads, ...input.archivedThreads]) { + if (thread.environmentId !== input.environmentId || thread.projectId !== input.projectId) { + continue; + } + const ref = scopeThreadRef(thread.environmentId, thread.id); + refs.set(scopedThreadKey(ref), ref); + } + return [...refs.values()]; +} + export interface ThreadStatusPill { label: | "Working" diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index c216f7e6f7c..02a9488a69e 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -76,6 +76,7 @@ import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstra import { isElectron } from "../env"; import { APP_STAGE_LABEL } from "../branding"; import { useOpenPrLink } from "../lib/openPullRequestLink"; +import { useArchivedThreadSnapshots } from "../lib/archivedThreadsState"; import { isTerminalFocused } from "../lib/terminalFocus"; import { isMacPlatform } from "../lib/utils"; import { @@ -195,6 +196,7 @@ import { useThreadSelectionStore } from "../threadSelectionStore"; import { useOpenAddProjectCommandPalette } from "../commandPaletteContext"; import { getSidebarThreadIdsToPrewarm, + getProjectRemovalThreadRefs, resolveAdjacentThreadId, isContextMenuPointerDown, isTrailingDoubleClick, @@ -1203,6 +1205,23 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const sidebarThreadByKeyRef = useRef(sidebarThreadByKey); sidebarThreadByKeyRef.current = sidebarThreadByKey; const projectThreads = sidebarThreads; + const archivedEnvironmentIds = useMemo( + () => [...new Set(project.memberProjects.map((member) => member.environmentId))], + [project.memberProjects], + ); + const { + snapshots: archivedSnapshots, + isLoading: isLoadingArchivedThreads, + error: archivedThreadsError, + refresh: refreshArchivedThreads, + } = useArchivedThreadSnapshots(archivedEnvironmentIds); + const archivedThreads = useMemo( + () => + archivedSnapshots.flatMap(({ environmentId, snapshot }) => + snapshot.threads.map((thread) => ({ ...thread, environmentId })), + ), + [archivedSnapshots], + ); const projectPreferenceKeys = useMemo(() => projectExpansionPreferenceKeys(project), [project]); const projectExpanded = useUiStateStore((state) => resolveProjectExpanded(state.projectExpandedById, projectPreferenceKeys), @@ -1246,7 +1265,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const counts = new Map( project.memberProjects.map((member) => [member.physicalProjectKey, 0] as const), ); - for (const thread of projectThreads) { + for (const thread of [...projectThreads, ...archivedThreads]) { const member = memberProjectByScopedKey.get( scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), ); @@ -1256,7 +1275,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec counts.set(member.physicalProjectKey, (counts.get(member.physicalProjectKey) ?? 0) + 1); } return counts; - }, [memberProjectByScopedKey, project.memberProjects, projectThreads]); + }, [archivedThreads, memberProjectByScopedKey, project.memberProjects, projectThreads]); const { projectStatus, visibleProjectThreads, orderedProjectThreadKeys } = useMemo(() => { const lastVisitedAtByThreadKey = new Map( @@ -1451,12 +1470,12 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec async (member: SidebarProjectGroupMember, options: { force?: boolean } = {}) => { const memberProjectRef = scopeProjectRef(member.environmentId, member.id); const draftStore = useComposerDraftStore.getState(); - const projectThreadRefs = projectThreads - .filter( - (thread) => - thread.environmentId === member.environmentId && thread.projectId === member.id, - ) - .map((thread) => scopeThreadRef(thread.environmentId, thread.id)); + const projectThreadRefs = getProjectRemovalThreadRefs({ + environmentId: member.environmentId, + projectId: member.id, + liveThreads: projectThreads, + archivedThreads, + }); const discardedTargets = composerDraftTargetsProject(memberProjectRef, projectThreadRefs); const discardedClaims = discardedTargets.flatMap((target) => textAttachmentClaims(target, draftStore.getComposerDraft(target)?.prompt ?? ""), @@ -1501,7 +1520,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec draftStore.clearProjectDraftThreadId(memberProjectRef); return result; }, - [deleteProject, projectThreads, releaseTextAttachment], + [archivedThreads, deleteProject, projectThreads, releaseTextAttachment], ); const handleRemoveProject = useCallback( @@ -1510,6 +1529,15 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec if (!api) { return; } + if (isLoadingArchivedThreads || archivedThreadsError !== null) { + if (archivedThreadsError !== null) refreshArchivedThreads(); + toastManager.add({ + type: "warning", + title: "Archived threads are still loading", + description: "Try removing the project again in a moment.", + }); + return; + } const memberProjectRef = scopeProjectRef(member.environmentId, member.id); const memberThreadCount = memberThreadCountByPhysicalKey.get(member.physicalProjectKey) ?? 0; @@ -1629,7 +1657,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ); } }, - [memberThreadCountByPhysicalKey, removeProject], + [ + archivedThreadsError, + isLoadingArchivedThreads, + memberThreadCountByPhysicalKey, + refreshArchivedThreads, + removeProject, + ], ); const handleProjectButtonContextMenu = useCallback( diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 2b0ca414874..916dc8386b4 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -837,21 +837,30 @@ describe("composerDraftStore project draft thread mapping", () => { ]); }); - it("collects every draft target removed with a forced project deletion", () => { + it("collects live and archived draft targets removed with a forced project deletion", () => { const store = useComposerDraftStore.getState(); const projectThreadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); + const archivedThreadRef = scopeThreadRef( + TEST_ENVIRONMENT_ID, + ThreadId.make("archived-project-thread"), + ); const retainedThreadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, otherThreadId); const projectDraftThreadId = ThreadId.make("project-draft-thread"); store.setPrompt(projectThreadRef, "project server-thread draft"); + store.setPrompt(archivedThreadRef, "archived server-thread draft"); store.setProjectDraftThreadId(projectRef, draftId, { threadId: projectDraftThreadId }); store.setPrompt(draftId, "project new-thread draft"); store.setPrompt(retainedThreadRef, "other project draft"); - const discardedTargets = composerDraftTargetsProject(projectRef, [projectThreadRef]); - expect(discardedTargets).toEqual([projectThreadRef, draftId]); + const discardedTargets = composerDraftTargetsProject(projectRef, [ + projectThreadRef, + archivedThreadRef, + ]); + expect(discardedTargets).toEqual([projectThreadRef, archivedThreadRef, draftId]); expect(discardedTargets.map((target) => store.getComposerDraft(target)?.prompt)).toEqual([ "project server-thread draft", + "archived server-thread draft", "project new-thread draft", ]); expect(composerDraftPromptsEnvironmentExcept(TEST_ENVIRONMENT_ID, discardedTargets)).toEqual([ @@ -860,6 +869,7 @@ describe("composerDraftStore project draft thread mapping", () => { for (const target of discardedTargets) store.clearDraftThread(target); expect(store.getComposerDraft(projectThreadRef)).toBeNull(); + expect(store.getComposerDraft(archivedThreadRef)).toBeNull(); expect(store.getComposerDraft(draftId)).toBeNull(); expect(store.getComposerDraft(retainedThreadRef)?.prompt).toBe("other project draft"); }); From d90a950e623d1b084c86a016d81eed1ad6847089 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 19:45:05 -0400 Subject: [PATCH 77/79] Confirm archived threads during project removal --- apps/web/src/components/Sidebar.logic.test.ts | 14 ++++++ apps/web/src/components/Sidebar.logic.ts | 28 ++++++++++++ apps/web/src/components/Sidebar.tsx | 43 +++++++------------ 3 files changed, 57 insertions(+), 28 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 3017f8d227a..6077262f543 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" import { createThreadJumpHintVisibilityController, getProjectRemovalThreadRefs, + getProjectRemovalConfirmationMessage, getSidebarThreadIdsToPrewarm, getVisibleSidebarThreadIds, resolveAdjacentThreadId, @@ -65,6 +66,19 @@ describe("getProjectRemovalThreadRefs", () => { scopeThreadRef(localEnvironmentId, archivedThreadId), ]); }); + + it("uses permanent-history copy for an archived-only project", () => { + const message = getProjectRemovalConfirmationMessage({ + title: "Archived project", + workspaceRoot: "/workspace/archived", + environmentLabel: null, + threadCount: 1, + }); + + expect(message).toContain("delete its 1 thread?"); + expect(message).toContain("permanently clears conversation history"); + expect(message).toContain("cannot be undone"); + }); }); describe("resolveSidebarStageBadgeLabel", () => { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index ab9d1a7cbe1..9bdbcff24c4 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -53,6 +53,34 @@ export function getProjectRemovalThreadRefs(input: { return [...refs.values()]; } +export function getProjectRemovalConfirmationMessage(input: { + title: string; + workspaceRoot: string; + environmentLabel: string | null; + threadCount: number; +}): string { + const context = [ + `Path: ${input.workspaceRoot}`, + ...(input.environmentLabel ? [`Environment: ${input.environmentLabel}`] : []), + ]; + if (input.threadCount > 0) { + return [ + `Remove project "${input.title}" and delete its ${input.threadCount} thread${ + input.threadCount === 1 ? "" : "s" + }?`, + ...context, + "This permanently clears conversation history for those threads.", + "This removes only this project entry.", + "This action cannot be undone.", + ].join("\n"); + } + return [ + `Remove project "${input.title}"?`, + ...context, + "This removes only this project entry.", + ].join("\n"); +} + export interface ThreadStatusPill { label: | "Working" diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 02a9488a69e..514cfab9ae6 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -197,6 +197,7 @@ import { useOpenAddProjectCommandPalette } from "../commandPaletteContext"; import { getSidebarThreadIdsToPrewarm, getProjectRemovalThreadRefs, + getProjectRemovalConfirmationMessage, resolveAdjacentThreadId, isContextMenuPointerDown, isTrailingDoubleClick, @@ -1557,35 +1558,20 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec window.setTimeout(resolve, 180); }); - const latestProjectThreads = Array.from( - sidebarThreadByKeyRef.current.values(), - ).filter( - (thread) => - thread.environmentId === memberProjectRef.environmentId && - thread.projectId === memberProjectRef.projectId, - ); + const latestProjectThreads = Array.from(sidebarThreadByKeyRef.current.values()); + const latestProjectThreadRefs = getProjectRemovalThreadRefs({ + environmentId: memberProjectRef.environmentId, + projectId: memberProjectRef.projectId, + liveThreads: latestProjectThreads, + archivedThreads, + }); const confirmed = await api.dialogs.confirm( - latestProjectThreads.length > 0 - ? [ - `Remove project "${member.title}" and delete its ${latestProjectThreads.length} thread${ - latestProjectThreads.length === 1 ? "" : "s" - }?`, - `Path: ${member.workspaceRoot}`, - ...(member.environmentLabel - ? [`Environment: ${member.environmentLabel}`] - : []), - "This permanently clears conversation history for those threads.", - "This removes only this project entry.", - "This action cannot be undone.", - ].join("\n") - : [ - `Remove project "${member.title}"?`, - `Path: ${member.workspaceRoot}`, - ...(member.environmentLabel - ? [`Environment: ${member.environmentLabel}`] - : []), - "This removes only this project entry.", - ].join("\n"), + getProjectRemovalConfirmationMessage({ + title: member.title, + workspaceRoot: member.workspaceRoot, + environmentLabel: member.environmentLabel, + threadCount: latestProjectThreadRefs.length, + }), ); if (!confirmed) { return; @@ -1659,6 +1645,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }, [ archivedThreadsError, + archivedThreads, isLoadingArchivedThreads, memberThreadCountByPhysicalKey, refreshArchivedThreads, From e846176ae640323fc15fb9be00c156d62f29bb99 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 19:49:04 -0400 Subject: [PATCH 78/79] Refresh archived drafts before project deletion --- apps/web/src/components/Sidebar.tsx | 50 ++++++++++++++--- apps/web/src/lib/archivedThreadsState.ts | 27 ++++++++++ apps/web/src/textAttachmentClaims.test.ts | 65 +++++++++++++++++++++-- 3 files changed, 132 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 514cfab9ae6..8f2e98c852e 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -76,7 +76,10 @@ import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstra import { isElectron } from "../env"; import { APP_STAGE_LABEL } from "../branding"; import { useOpenPrLink } from "../lib/openPullRequestLink"; -import { useArchivedThreadSnapshots } from "../lib/archivedThreadsState"; +import { + refreshAndReadArchivedThreadSnapshots, + useArchivedThreadSnapshots, +} from "../lib/archivedThreadsState"; import { isTerminalFocused } from "../lib/terminalFocus"; import { isMacPlatform } from "../lib/utils"; import { @@ -1223,6 +1226,21 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ), [archivedSnapshots], ); + const readLatestArchivedThreads = useCallback(async () => { + const result = await refreshAndReadArchivedThreadSnapshots(archivedEnvironmentIds); + if (result.error !== null) { + refreshArchivedThreads(); + toastManager.add({ + type: "warning", + title: "Could not refresh archived threads", + description: "Try removing the project again in a moment.", + }); + return null; + } + return result.snapshots.flatMap(({ environmentId, snapshot }) => + snapshot.threads.map((thread) => ({ ...thread, environmentId })), + ); + }, [archivedEnvironmentIds, refreshArchivedThreads]); const projectPreferenceKeys = useMemo(() => projectExpansionPreferenceKeys(project), [project]); const projectExpanded = useUiStateStore((state) => resolveProjectExpanded(state.projectExpandedById, projectPreferenceKeys), @@ -1468,14 +1486,20 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ); const removeProject = useCallback( - async (member: SidebarProjectGroupMember, options: { force?: boolean } = {}) => { + async ( + member: SidebarProjectGroupMember, + options: { + force?: boolean; + archivedThreads?: Parameters[0]["archivedThreads"]; + } = {}, + ) => { const memberProjectRef = scopeProjectRef(member.environmentId, member.id); const draftStore = useComposerDraftStore.getState(); const projectThreadRefs = getProjectRemovalThreadRefs({ environmentId: member.environmentId, projectId: member.id, liveThreads: projectThreads, - archivedThreads, + archivedThreads: options.archivedThreads ?? archivedThreads, }); const discardedTargets = composerDraftTargetsProject(memberProjectRef, projectThreadRefs); const discardedClaims = discardedTargets.flatMap((target) => @@ -1558,12 +1582,15 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec window.setTimeout(resolve, 180); }); + const archivedThreadsBeforeConfirmation = await readLatestArchivedThreads(); + if (archivedThreadsBeforeConfirmation === null) return; + const latestProjectThreads = Array.from(sidebarThreadByKeyRef.current.values()); const latestProjectThreadRefs = getProjectRemovalThreadRefs({ environmentId: memberProjectRef.environmentId, projectId: memberProjectRef.projectId, liveThreads: latestProjectThreads, - archivedThreads, + archivedThreads: archivedThreadsBeforeConfirmation, }); const confirmed = await api.dialogs.confirm( getProjectRemovalConfirmationMessage({ @@ -1577,7 +1604,12 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec return; } - const result = await removeProject(member, { force: true }); + const archivedThreadsBeforeRemoval = await readLatestArchivedThreads(); + if (archivedThreadsBeforeRemoval === null) return; + const result = await removeProject(member, { + force: true, + archivedThreads: archivedThreadsBeforeRemoval, + }); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); toastManager.add( @@ -1625,7 +1657,11 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec return; } - const result = await removeProject(member); + const archivedThreadsBeforeRemoval = await readLatestArchivedThreads(); + if (archivedThreadsBeforeRemoval === null) return; + const result = await removeProject(member, { + archivedThreads: archivedThreadsBeforeRemoval, + }); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); const message = error instanceof Error ? error.message : "Unknown error removing project."; @@ -1645,9 +1681,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }, [ archivedThreadsError, - archivedThreads, isLoadingArchivedThreads, memberThreadCountByPhysicalKey, + readLatestArchivedThreads, refreshArchivedThreads, removeProject, ], diff --git a/apps/web/src/lib/archivedThreadsState.ts b/apps/web/src/lib/archivedThreadsState.ts index 2d52383c02c..a98144876a3 100644 --- a/apps/web/src/lib/archivedThreadsState.ts +++ b/apps/web/src/lib/archivedThreadsState.ts @@ -4,6 +4,7 @@ import { createArchivedThreadSnapshotsAtomFamily, makeArchivedThreadsEnvironmentKey, } from "@t3tools/client-runtime/state/threads"; +import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime"; import type { EnvironmentId } from "@t3tools/contracts"; import { useCallback, useMemo } from "react"; @@ -26,6 +27,32 @@ export function refreshArchivedThreadsForEnvironment(environmentId: EnvironmentI appAtomRegistry.refresh(archivedSnapshotAtom(environmentId)); } +export async function refreshAndReadArchivedThreadSnapshots( + environmentIds: ReadonlyArray, +): Promise<{ + readonly snapshots: ReadonlyArray; + readonly error: string | null; +}> { + const results = await Promise.all( + environmentIds.map(async (environmentId) => { + const atom = archivedSnapshotAtom(environmentId); + appAtomRegistry.refresh(atom); + const result = await executeAtomQuery(appAtomRegistry, atom, { + reportDefect: false, + reportFailure: false, + }); + return { environmentId, result }; + }), + ); + const snapshots: ArchivedSnapshotEntry[] = []; + let error: string | null = null; + for (const { environmentId, result } of results) { + if (result._tag === "Success") snapshots.push({ environmentId, snapshot: result.value }); + else error = "Failed to load archived threads."; + } + return { snapshots, error }; +} + export function useArchivedThreadSnapshots(environmentIds: ReadonlyArray): { readonly snapshots: ReadonlyArray; readonly error: string | null; diff --git a/apps/web/src/textAttachmentClaims.test.ts b/apps/web/src/textAttachmentClaims.test.ts index b322edb5b88..ac985ea2b09 100644 --- a/apps/web/src/textAttachmentClaims.test.ts +++ b/apps/web/src/textAttachmentClaims.test.ts @@ -1,8 +1,9 @@ -import { scopeThreadRef } from "@t3tools/client-runtime/environment"; -import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; import { afterEach, describe, expect, it, vi } from "vite-plus/test"; -import { DraftId, useComposerDraftStore } from "./composerDraftStore"; +import { composerDraftTargetsProject, DraftId, useComposerDraftStore } from "./composerDraftStore"; +import { getProjectRemovalThreadRefs } from "./components/Sidebar.logic"; import { textAttachmentClaimChanges, textAttachmentClaims, @@ -413,6 +414,64 @@ describe("text attachment claims", () => { expect(pendingTextAttachmentClaimReleases(environmentId)).toEqual([]); }); + it("cleans a draft owner when its thread archives between click and confirmation", async () => { + const environmentId = EnvironmentId.make("project-removal-archive-race"); + const projectId = ProjectId.make("project-removal-archive-race"); + const threadId = ThreadId.make("thread-archived-before-confirm"); + const threadRef = scopeThreadRef(environmentId, threadId); + const ownerId = textAttachmentDraftOwnerId(threadRef); + const store = useComposerDraftStore.getState(); + store.setPrompt(threadRef, `[shared.txt](${PATH})`); + + // The thread was live when removal was clicked, then moved into the fresh + // archived snapshot while the final confirmation dialog was open. + const latestThreadRefs = getProjectRemovalThreadRefs({ + environmentId, + projectId, + liveThreads: [], + archivedThreads: [{ environmentId, projectId, id: threadId }], + }); + const targets = composerDraftTargetsProject( + scopeProjectRef(environmentId, projectId), + latestThreadRefs, + ); + const claims = targets.flatMap((target) => + textAttachmentClaims(target, store.getComposerDraft(target)?.prompt ?? ""), + ); + const release = vi.fn(async (_path: string, _draftOwnerId: string) => true); + + await Promise.all( + targets.map((target) => + fenceTextAttachmentUploadOwner(environmentId, textAttachmentDraftOwnerId(target)), + ), + ); + await releaseTextAttachmentClaimsInBackground({ + environmentId, + claims, + draftOwnerIds: targets.map(textAttachmentDraftOwnerId), + release: ({ path, draftOwnerId }) => release(path, draftOwnerId), + }); + for (const target of targets) { + store.clearDraftThread(target); + tombstoneTextAttachmentUploadOwner(environmentId, textAttachmentDraftOwnerId(target)); + } + + expect(targets).toEqual([threadRef]); + expect(release).toHaveBeenCalledWith(PATH, ownerId); + expect(store.getComposerDraft(threadRef)).toBeNull(); + const upload = vi.fn(async () => ({ path: PATH })); + await expect( + runTextAttachmentUpload({ + environmentId, + draftOwnerId: ownerId, + upload, + path: (result) => result.path, + release: vi.fn(async () => undefined), + }), + ).resolves.toBeNull(); + expect(upload).not.toHaveBeenCalled(); + }); + it("reconstructs the release outbox after a module restart and retries on reconnect", async () => { vi.useFakeTimers(); const environmentId = EnvironmentId.make("restarted-release-outbox"); From abf4dd871064e22a302f954032d95712195f51b7 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sun, 12 Jul 2026 19:53:44 -0400 Subject: [PATCH 79/79] Revalidate project threads after confirmation --- apps/web/src/components/Sidebar.logic.test.ts | 50 ++++++++ apps/web/src/components/Sidebar.logic.ts | 33 +++++ apps/web/src/components/Sidebar.tsx | 113 ++++++++++++------ 3 files changed, 157 insertions(+), 39 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 6077262f543..32eb5a94401 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -19,6 +19,7 @@ import { resolveSidebarStageBadgeLabel, resolveThreadRowClassName, resolveThreadStatusPill, + runStableProjectRemovalConfirmation, shouldClearThreadSelectionOnMouseDown, sortProjectsForSidebar, THREAD_JUMP_HINT_SHOW_DELAY_MS, @@ -81,6 +82,55 @@ describe("getProjectRemovalThreadRefs", () => { }); }); +describe("runStableProjectRemovalConfirmation", () => { + it("does not remove when a second snapshot adds an archived thread", async () => { + const firstThreadRef = scopeThreadRef(localEnvironmentId, ThreadId.make("thread-first")); + const archivedThreadRef = scopeThreadRef(localEnvironmentId, ThreadId.make("thread-archived")); + const readSnapshot = vi + .fn() + .mockResolvedValueOnce({ threadRefs: [firstThreadRef], value: "before" }) + .mockResolvedValueOnce({ + threadRefs: [firstThreadRef, archivedThreadRef], + value: "after", + }); + const remove = vi.fn(async () => undefined); + + const result = await runStableProjectRemovalConfirmation({ + readSnapshot, + confirm: vi.fn(async () => true), + remove, + }); + + expect(result).toEqual({ status: "changed" }); + expect(remove).not.toHaveBeenCalled(); + }); + + it("removes when the exact scoped thread set is unchanged", async () => { + const firstThreadRef = scopeThreadRef(localEnvironmentId, ThreadId.make("thread-first")); + const secondThreadRef = scopeThreadRef(localEnvironmentId, ThreadId.make("thread-second")); + const readSnapshot = vi + .fn() + .mockResolvedValueOnce({ + threadRefs: [firstThreadRef, secondThreadRef], + value: "before", + }) + .mockResolvedValueOnce({ + threadRefs: [secondThreadRef, firstThreadRef], + value: "after", + }); + const remove = vi.fn(async (snapshot: { value: string }) => snapshot.value); + + const result = await runStableProjectRemovalConfirmation({ + readSnapshot, + confirm: vi.fn(async () => true), + remove, + }); + + expect(result).toEqual({ status: "removed", result: "after" }); + expect(remove).toHaveBeenCalledOnce(); + }); +}); + describe("resolveSidebarStageBadgeLabel", () => { it("returns Nightly for nightly primary server versions", () => { expect( diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 9bdbcff24c4..4328a00b306 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -81,6 +81,39 @@ export function getProjectRemovalConfirmationMessage(input: { ].join("\n"); } +export async function runStableProjectRemovalConfirmation(input: { + readSnapshot: () => Promise<{ + readonly threadRefs: ReadonlyArray; + readonly value: TSnapshot; + } | null>; + confirm: (snapshot: { + readonly threadRefs: ReadonlyArray; + readonly value: TSnapshot; + }) => Promise; + remove: (snapshot: { + readonly threadRefs: ReadonlyArray; + readonly value: TSnapshot; + }) => Promise; +}): Promise< + | { readonly status: "unavailable" | "cancelled" | "changed" } + | { readonly status: "removed"; readonly result: TResult } +> { + const confirmedSnapshot = await input.readSnapshot(); + if (confirmedSnapshot === null) return { status: "unavailable" }; + if (!(await input.confirm(confirmedSnapshot))) return { status: "cancelled" }; + const latestSnapshot = await input.readSnapshot(); + if (latestSnapshot === null) return { status: "unavailable" }; + const confirmedKeys = new Set(confirmedSnapshot.threadRefs.map(scopedThreadKey)); + const latestKeys = new Set(latestSnapshot.threadRefs.map(scopedThreadKey)); + if ( + confirmedKeys.size !== latestKeys.size || + [...confirmedKeys].some((key) => !latestKeys.has(key)) + ) { + return { status: "changed" }; + } + return { status: "removed", result: await input.remove(latestSnapshot) }; +} + export interface ThreadStatusPill { label: | "Working" diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 8f2e98c852e..5b3ee19bb18 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -210,6 +210,7 @@ import { resolveSidebarStageBadgeLabel, resolveThreadRowClassName, resolveThreadStatusPill, + runStableProjectRemovalConfirmation, orderItemsByPreferredIds, shouldClearThreadSelectionOnMouseDown, sortProjectsForSidebar, @@ -1582,34 +1583,45 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec window.setTimeout(resolve, 180); }); - const archivedThreadsBeforeConfirmation = await readLatestArchivedThreads(); - if (archivedThreadsBeforeConfirmation === null) return; - - const latestProjectThreads = Array.from(sidebarThreadByKeyRef.current.values()); - const latestProjectThreadRefs = getProjectRemovalThreadRefs({ - environmentId: memberProjectRef.environmentId, - projectId: memberProjectRef.projectId, - liveThreads: latestProjectThreads, - archivedThreads: archivedThreadsBeforeConfirmation, + const outcome = await runStableProjectRemovalConfirmation({ + readSnapshot: async () => { + const latestArchivedThreads = await readLatestArchivedThreads(); + if (latestArchivedThreads === null) return null; + return { + threadRefs: getProjectRemovalThreadRefs({ + environmentId: memberProjectRef.environmentId, + projectId: memberProjectRef.projectId, + liveThreads: Array.from(sidebarThreadByKeyRef.current.values()), + archivedThreads: latestArchivedThreads, + }), + value: latestArchivedThreads, + }; + }, + confirm: ({ threadRefs }) => + api.dialogs.confirm( + getProjectRemovalConfirmationMessage({ + title: member.title, + workspaceRoot: member.workspaceRoot, + environmentLabel: member.environmentLabel, + threadCount: threadRefs.length, + }), + ), + remove: ({ value: latestArchivedThreads }) => + removeProject(member, { + force: true, + archivedThreads: latestArchivedThreads, + }), }); - const confirmed = await api.dialogs.confirm( - getProjectRemovalConfirmationMessage({ - title: member.title, - workspaceRoot: member.workspaceRoot, - environmentLabel: member.environmentLabel, - threadCount: latestProjectThreadRefs.length, - }), - ); - if (!confirmed) { + if (outcome.status === "changed") { + toastManager.add({ + type: "warning", + title: "Project threads changed", + description: "Review the updated thread count and confirm removal again.", + }); return; } - - const archivedThreadsBeforeRemoval = await readLatestArchivedThreads(); - if (archivedThreadsBeforeRemoval === null) return; - const result = await removeProject(member, { - force: true, - archivedThreads: archivedThreadsBeforeRemoval, - }); + if (outcome.status !== "removed") return; + const result = outcome.result; if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); toastManager.add( @@ -1646,22 +1658,45 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec return; } - const message = [ - `Remove project "${member.title}"?`, - `Path: ${member.workspaceRoot}`, - ...(member.environmentLabel ? [`Environment: ${member.environmentLabel}`] : []), - "This removes only this project entry.", - ].join("\n"); - const confirmed = await api.dialogs.confirm(message); - if (!confirmed) { + const outcome = await runStableProjectRemovalConfirmation({ + readSnapshot: async () => { + const latestArchivedThreads = await readLatestArchivedThreads(); + if (latestArchivedThreads === null) return null; + return { + threadRefs: getProjectRemovalThreadRefs({ + environmentId: memberProjectRef.environmentId, + projectId: memberProjectRef.projectId, + liveThreads: Array.from(sidebarThreadByKeyRef.current.values()), + archivedThreads: latestArchivedThreads, + }), + value: latestArchivedThreads, + }; + }, + confirm: ({ threadRefs }) => + api.dialogs.confirm( + getProjectRemovalConfirmationMessage({ + title: member.title, + workspaceRoot: member.workspaceRoot, + environmentLabel: member.environmentLabel, + threadCount: threadRefs.length, + }), + ), + remove: ({ threadRefs, value: latestArchivedThreads }) => + removeProject(member, { + ...(threadRefs.length > 0 ? { force: true } : {}), + archivedThreads: latestArchivedThreads, + }), + }); + if (outcome.status === "changed") { + toastManager.add({ + type: "warning", + title: "Project threads changed", + description: "Review the updated thread count and confirm removal again.", + }); return; } - - const archivedThreadsBeforeRemoval = await readLatestArchivedThreads(); - if (archivedThreadsBeforeRemoval === null) return; - const result = await removeProject(member, { - archivedThreads: archivedThreadsBeforeRemoval, - }); + if (outcome.status !== "removed") return; + const result = outcome.result; if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); const message = error instanceof Error ? error.message : "Unknown error removing project.";