From 56f7e88a706e2dcc23fdf57a7d828a1878553bdf Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Sat, 31 Oct 2026 17:41:43 +0100 Subject: [PATCH 01/12] fix(web): recover expired composer uploads --- .../src/components/chat/useComposerSend.ts | 35 ++++++++ .../components/chat/useMultipleModelSend.ts | 4 + .../web/src/lib/attachmentUploadQueue.test.ts | 86 +++++++++++++++++++ apps/web/src/lib/attachmentUploadQueue.ts | 41 +++++++++ 4 files changed, 166 insertions(+) diff --git a/apps/web/src/components/chat/useComposerSend.ts b/apps/web/src/components/chat/useComposerSend.ts index b22c313f6932b..389ee6a291589 100644 --- a/apps/web/src/components/chat/useComposerSend.ts +++ b/apps/web/src/components/chat/useComposerSend.ts @@ -103,6 +103,7 @@ import { type ComposerDispatchMode } from "@t3tools/client-runtime/state/compose import { awaitAttachmentUploads, getUploadedAttachments, + refreshExpiredAttachmentUploads, readAttachmentUpload, releaseDraftAttachments, retryAttachmentUpload, @@ -942,6 +943,23 @@ export function useComposerSend(input: ComposerSendInput) { multipleTargets.push(resolved); } + const draftSignature = () => { + const draft = useComposerDraftStore.getState().getComposerDraft(composerDraftTarget); + return JSON.stringify({ + prompt: draft?.prompt ?? "", + images: draft?.images.map((image) => image.id) ?? [], + files: draft?.files.map((file) => file.id) ?? [], + terminalContexts: draft?.terminalContexts ?? [], + previewAnnotations: draft?.previewAnnotations ?? [], + reviewComments: draft?.reviewComments ?? [], + threadContexts: draft?.threadContexts ?? [], + environmentContexts: draft?.environmentContexts ?? [], + }); + }; + const draftBeforePreparation = draftSignature(); + const draftChangedDuringPreparation = () => + draftSignature() !== draftBeforePreparation || + composerRef.current?.hasPendingAttachments() === true; sendInFlightRef.current = true; const sendGeneration = ++composerSendGenerationRef.current; const attachmentCapabilitiesBeforeUpload = readLiveAttachmentCapabilities(); @@ -967,6 +985,11 @@ export function useComposerSend(input: ComposerSendInput) { }); } await awaitAttachmentUploads(composerAttachmentsSnapshot.map((attachment) => attachment.id)); + await refreshExpiredAttachmentUploads({ + environmentId, + images: composerAttachmentsSnapshot, + draftTarget: composerDraftTarget, + }); const attachmentCapabilitiesAfterUpload = readLiveAttachmentCapabilities(); if (attachmentCapabilitiesAfterUpload.fileBlockReason !== null) { sendInFlightRef.current = false; @@ -1007,6 +1030,17 @@ export function useComposerSend(input: ComposerSendInput) { } const attachmentCapabilitiesBeforeDispatch = readLiveAttachmentCapabilities(); + if (draftChangedDuringPreparation()) { + sendInFlightRef.current = false; + setThreadError( + threadIdForSend, + "Draft changed while preparing this message. Review it and send again.", + ); + setDockedDraftHeroThreadKey((currentThreadKey) => + currentThreadKey === activeThreadKey ? null : currentThreadKey, + ); + return; + } if ( !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) || attachmentCapabilitiesBeforeDispatch.fileBlockReason !== null @@ -1182,6 +1216,7 @@ export function useComposerSend(input: ComposerSendInput) { readFileBlockReason: () => readLiveAttachmentCapabilities().fileBlockReason, buildOutgoingMessageContext, isCurrentSend: () => composerSendGenerationRef.current === sendGeneration, + draftChangedDuringPreparation, }); return; } diff --git a/apps/web/src/components/chat/useMultipleModelSend.ts b/apps/web/src/components/chat/useMultipleModelSend.ts index 8a2a1dd409643..9683593b4a79d 100644 --- a/apps/web/src/components/chat/useMultipleModelSend.ts +++ b/apps/web/src/components/chat/useMultipleModelSend.ts @@ -96,6 +96,7 @@ export function useMultipleModelSend(input: MultipleModelSendInput) { readFileBlockReason, buildOutgoingMessageContext, isCurrentSend, + draftChangedDuringPreparation, }: { readonly threadId: ThreadId; readonly project: EnvironmentProject; @@ -112,6 +113,7 @@ export function useMultipleModelSend(input: MultipleModelSendInput) { ) => ReturnType; /** False once a newer send from this composer started. */ readonly isCurrentSend: () => boolean; + readonly draftChangedDuringPreparation: () => boolean; }) => { const { attachments: attachmentsSnapshot, messageText } = sendSnapshot; const failedSelections: ModelSelection[] = []; @@ -123,6 +125,8 @@ export function useMultipleModelSend(input: MultipleModelSendInput) { // fails every selection; the draft restore below still runs. const sendResult = await settlePromise(async () => { const attachments = await attachmentsPromise; + if (draftChangedDuringPreparation()) + throw new Error("Draft changed while preparing this message. Review it and send again."); const fileBlockReason = readFileBlockReason(); if (fileBlockReason !== null) throw new Error(fileBlockReason); const context = buildOutgoingMessageContext( diff --git a/apps/web/src/lib/attachmentUploadQueue.test.ts b/apps/web/src/lib/attachmentUploadQueue.test.ts index c18f9e716378c..200720ccff02b 100644 --- a/apps/web/src/lib/attachmentUploadQueue.test.ts +++ b/apps/web/src/lib/attachmentUploadQueue.test.ts @@ -57,6 +57,7 @@ import { awaitAttachmentUploads, getUploadedAttachments, readAttachmentUpload, + refreshExpiredAttachmentUploads, releaseAttachmentUpload, releaseDraftAttachment, releaseDraftAttachments, @@ -210,6 +211,91 @@ describe("attachmentUploadQueue", () => { vi.unstubAllGlobals(); }); + it("reuploads an expired image while retaining other ready images", async () => { + const images = [makeImage("first"), makeImage("second")].map((image) => ({ + ...image, + name: "image.png", + })); + let minted = 0; + mocks.runAtomCommand.mockImplementation(async (_registry, command) => { + if (command !== mocks.createUploadUrl) return { _tag: "Success", value: undefined }; + const attachmentId = `pending-test-${++minted}`; + return { + _tag: "Success", + value: { + attachmentId, + relativeUrl: `/api/attachments/upload/${attachmentId}`, + expiresAt: 1, + }, + }; + }); + for (const image of images) startAttachmentUpload({ environmentId: firstEnvironment, image }); + await Promise.resolve(); + const initial = awaitAttachmentUploads(images.map((image) => image.id)); + TestXmlHttpRequest.requests.forEach((request) => request.complete()); + await initial; + mocks.executeAtomQuery.mockImplementation( + async (_registry, target: { input: { resource: { attachmentId: string } } }) => + target.input.resource.attachmentId === "pending-test-1" + ? { _tag: "Failure", error: { _tag: "AssetAttachmentNotFoundError" } } + : { _tag: "Success", value: {} }, + ); + const refreshing = refreshExpiredAttachmentUploads({ environmentId: firstEnvironment, images }); + await vi.waitFor(() => expect(TestXmlHttpRequest.requests).toHaveLength(3)); + TestXmlHttpRequest.requests[2]!.complete(); + await refreshing; + expect( + getUploadedAttachments({ environmentId: firstEnvironment, images })?.map((image) => image.id), + ).toEqual(["pending-test-3", "pending-test-2"]); + expect(TestXmlHttpRequest.requests).toHaveLength(3); + }); + + it("keeps an upload failed when verification cannot reach the server", async () => { + const image = makeImage("connection-failure"); + useAttachmentUploadStore.setState({ + uploadsByImageId: { + [image.id]: { + status: "ready", + environmentId: firstEnvironment, + attachmentId: "pending-original", + }, + }, + }); + mocks.executeAtomQuery.mockRejectedValueOnce(new Error("offline")); + await refreshExpiredAttachmentUploads({ environmentId: firstEnvironment, images: [image] }); + expect(readAttachmentUpload(image.id)).toMatchObject({ status: "failed" }); + expect(getUploadedAttachments({ environmentId: firstEnvironment, images: [image] })).toBeNull(); + expect(TestXmlHttpRequest.requests).toHaveLength(0); + }); + + it("does not resurrect an image removed while its upload is being checked", async () => { + const image = makeImage("removed-during-verification"); + useAttachmentUploadStore.setState({ + uploadsByImageId: { + [image.id]: { + status: "ready", + environmentId: firstEnvironment, + attachmentId: "pending-original", + }, + }, + }); + let finish!: (value: unknown) => void; + mocks.executeAtomQuery.mockReturnValueOnce( + new Promise((resolve) => { + finish = resolve; + }), + ); + const refresh = refreshExpiredAttachmentUploads({ + environmentId: firstEnvironment, + images: [image], + }); + releaseAttachmentUpload(image.id); + finish({ _tag: "Failure", error: { _tag: "AssetAttachmentNotFoundError" } }); + await refresh; + expect(readAttachmentUpload(image.id)).toBeUndefined(); + expect(TestXmlHttpRequest.requests).toHaveLength(0); + }); + it("keeps an attachment local until its own environment grants write access", async () => { const image = makeImage("permission-gain"); mocks.readEnvironmentScope.mockImplementation( diff --git a/apps/web/src/lib/attachmentUploadQueue.ts b/apps/web/src/lib/attachmentUploadQueue.ts index 6477028fe6bad..630736cd0c1fb 100644 --- a/apps/web/src/lib/attachmentUploadQueue.ts +++ b/apps/web/src/lib/attachmentUploadQueue.ts @@ -1,5 +1,6 @@ import { AuthOrchestrationOperateScope, + AuthOrchestrationReadScope, PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES, type ChatAttachment, type EnvironmentId, @@ -580,6 +581,46 @@ export async function awaitAttachmentUploads(imageIds: ReadonlyArray): P await Promise.all(imageIds.map((imageId) => jobsByImageId.get(imageId)?.settled)); } +/** Revalidate ready uploads at send time; a long-lived draft can outlive server cleanup. */ +export async function refreshExpiredAttachmentUploads(input: { + readonly environmentId: EnvironmentId; + readonly images: ReadonlyArray; + readonly draftTarget?: ComposerThreadTarget; +}): Promise { + if (!readEnvironmentScope(input.environmentId, AuthOrchestrationReadScope)) return; + await Promise.all( + input.images.map(async (image) => { + const upload = readAttachmentUpload(image.id); + if (upload?.status !== "ready" || upload.environmentId !== input.environmentId) return; + const verification = await verifyStashedAttachmentUpload({ + environmentId: input.environmentId, + attachmentId: upload.attachmentId, + }).catch((error: unknown): PersistedAttachmentVerification => ({ status: "failed", error })); + // The user may have removed or replaced the attachment while verification was in flight. + if (readAttachmentUpload(image.id) !== upload) return; + if (verification.status === "verified") return; + if (verification.status === "missing" && image.file) { + clearUploadState(image.id); + startAttachmentUpload({ + environmentId: input.environmentId, + image, + ...(input.draftTarget !== undefined ? { draftTarget: input.draftTarget } : {}), + }); + return; + } + setUploadState(image.id, { + status: "failed", + environmentId: input.environmentId, + reason: + verification.status === "missing" + ? "Uploaded attachment expired. Remove it and attach it again." + : "Uploaded attachment could not be verified. Retry when the server reconnects.", + }); + }), + ); + await awaitAttachmentUploads(input.images.map((image) => image.id)); +} + export function getUploadedAttachments(input: { readonly environmentId: EnvironmentId; readonly images: ReadonlyArray; From db0f77c44b1d326eb3484efcb16308430995556a Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Sat, 31 Oct 2026 17:41:36 +0100 Subject: [PATCH 02/12] fix(web): identify failed composer attachments --- .../orchestration-v2/AttachmentClaims.test.ts | 10 ++----- .../src/orchestration-v2/AttachmentClaims.ts | 6 ++++ apps/server/src/ws.ts | 6 ++++ .../chat/ComposerAttachmentStrip.tsx | 6 ++++ apps/web/src/components/chat/chatViewSend.ts | 2 ++ .../src/components/chat/useComposerSend.ts | 6 ++++ .../components/chat/useMultipleModelSend.ts | 11 ++++++- .../composerContextPresentation.tsx | 2 ++ .../web/src/lib/attachmentUploadQueue.test.ts | 24 +++++++++++++++ apps/web/src/lib/attachmentUploadQueue.ts | 30 +++++++++++++++++++ packages/contracts/src/orchestrationV2.ts | 2 ++ 11 files changed, 97 insertions(+), 8 deletions(-) diff --git a/apps/server/src/orchestration-v2/AttachmentClaims.test.ts b/apps/server/src/orchestration-v2/AttachmentClaims.test.ts index a67f7ee180db7..64d00d6949c84 100644 --- a/apps/server/src/orchestration-v2/AttachmentClaims.test.ts +++ b/apps/server/src/orchestration-v2/AttachmentClaims.test.ts @@ -149,18 +149,14 @@ describe("AttachmentClaims", () => { const mismatched = { ...staged, sizeBytes: 999 }; const config = yield* ServerConfig.ServerConfig; - const result = yield* Effect.exit( + const failure = yield* Effect.flip( claimPendingAttachments({ threadId: "thread-claims-4", attachments: [good, mismatched], }), ); - - expect(result._tag).toBe("Failure"); - if (result._tag === "Failure") { - const error = result.cause; - expect(String(error)).toContain("stored size does not match"); - } + expect(failure.message).toContain("stored size does not match"); + expect(failure.attachmentId).toBe(mismatched.id); const claimedFiles = NodeFS.readdirSync(config.attachmentsDir).filter((entry) => entry.startsWith("thread-claims-4-"), ); diff --git a/apps/server/src/orchestration-v2/AttachmentClaims.ts b/apps/server/src/orchestration-v2/AttachmentClaims.ts index be48ef68b5559..2bc018ea07bbe 100644 --- a/apps/server/src/orchestration-v2/AttachmentClaims.ts +++ b/apps/server/src/orchestration-v2/AttachmentClaims.ts @@ -21,6 +21,7 @@ export class AttachmentClaimError extends Schema.TaggedError new AttachmentClaimError({ + attachmentId: attachment.id, message: `Attachment '${attachment.name}' cannot be sent: attachment not found.`, cause, }), @@ -113,6 +116,7 @@ export const claimPendingAttachments = Effect.fn("AttachmentClaims.claimPendingA ); if (Number(info.size) !== attachment.sizeBytes) { return yield* new AttachmentClaimError({ + attachmentId: attachment.id, message: `Attachment '${attachment.name}' cannot be sent: stored size does not match.`, }); } @@ -133,6 +137,7 @@ export const claimPendingAttachments = Effect.fn("AttachmentClaims.claimPendingA }); if (expectedPath !== claim.finalPath) { return yield* new AttachmentClaimError({ + attachmentId: attachment.id, message: `Attachment '${attachment.name}' cannot be sent: attachment type does not match the upload.`, }); } @@ -142,6 +147,7 @@ export const claimPendingAttachments = Effect.fn("AttachmentClaims.claimPendingA Effect.mapError( (cause) => new AttachmentClaimError({ + attachmentId: attachment.id, message: `Failed to claim attachment '${attachment.name}' for this thread.`, cause, }), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 4781fcf78dde6..74c3e1309accd 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1911,6 +1911,9 @@ export const layerWsRpc = ( commandType: command.type, message: detail ?? "Failed to dispatch orchestration V2 command", ...(detail === undefined ? {} : { detail }), + ...(cause._tag === "AttachmentClaimError" && cause.attachmentId !== undefined + ? { attachmentId: cause.attachmentId } + : {}), cause, }); }), @@ -2101,6 +2104,9 @@ export const layerWsRpc = ( commandId: input.commandId, projectId: input.projectId, message: cause.message, + ...(cause.attachmentId !== undefined + ? { attachmentId: cause.attachmentId } + : {}), cause, }), ThreadLaunchError: (cause) => diff --git a/apps/web/src/components/chat/ComposerAttachmentStrip.tsx b/apps/web/src/components/chat/ComposerAttachmentStrip.tsx index e3560312925e5..fddef5d3db2fa 100644 --- a/apps/web/src/components/chat/ComposerAttachmentStrip.tsx +++ b/apps/web/src/components/chat/ComposerAttachmentStrip.tsx @@ -230,6 +230,12 @@ export function ComposerAttachmentMediaRow({ {image.name} )} + {upload?.status === "failed" && ( +