Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 3 additions & 7 deletions apps/server/src/orchestration-v2/AttachmentClaims.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-"),
);
Expand Down
6 changes: 6 additions & 0 deletions apps/server/src/orchestration-v2/AttachmentClaims.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export class AttachmentClaimError extends Schema.TaggedError<AttachmentClaimErro
"AttachmentClaimError",
{
message: Schema.String,
attachmentId: Schema.optional(Schema.String),
cause: Schema.optional(Schema.Defect()),
},
) {}
Expand Down Expand Up @@ -99,20 +100,23 @@ export const claimPendingAttachments = Effect.fn("AttachmentClaims.claimPendingA
});
if (!claim.ok) {
return yield* new AttachmentClaimError({
attachmentId: attachment.id,
message: `Attachment '${attachment.name}' cannot be sent: ${claim.reason}.`,
});
}
const info = yield* fileSystem.stat(claim.currentPath).pipe(
Effect.mapError(
(cause) =>
new AttachmentClaimError({
attachmentId: attachment.id,
message: `Attachment '${attachment.name}' cannot be sent: attachment not found.`,
cause,
}),
),
);
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.`,
});
}
Expand All @@ -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.`,
});
}
Expand All @@ -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,
}),
Expand Down
6 changes: 6 additions & 0 deletions apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1998,6 +1998,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,
});
}),
Expand Down Expand Up @@ -2221,6 +2224,9 @@ export const layerWsRpc = (
commandId: input.commandId,
projectId: input.projectId,
message: cause.message,
...(cause.attachmentId !== undefined
? { attachmentId: cause.attachmentId }
: {}),
cause,
}),
ThreadLaunchError: (cause) =>
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/chat/chatViewSend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export async function startFanoutThread(input: {
readonly uncertainSubmissions: Map<string, ThreadId>;
readonly start: (threadId: ThreadId) => Promise<AtomCommandResult<unknown, unknown>>;
readonly openThread: (threadId: ThreadId) => void;
readonly onFailure?: (error: unknown) => void;
}): Promise<boolean> {
const { retryKey, uncertainSubmissions } = input;
const uncertainThreadId = uncertainSubmissions.get(retryKey);
Expand All @@ -103,6 +104,7 @@ export async function startFanoutThread(input: {
}
return true;
} catch (error) {
input.onFailure?.(error);
if (requestMayHaveStarted && !uncertainSubmissions.has(retryKey)) {
uncertainSubmissions.set(retryKey, targetThreadId);
}
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/components/chat/useComposerSend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ import { useMultipleModelSend } from "./useMultipleModelSend";
import { type ComposerDispatchMode } from "@t3tools/client-runtime/state/composer-dispatch";
import {
getUploadedAttachments,
markAttachmentClaimFailure,
refreshExpiredAttachmentUploads,
readAttachmentUpload,
releaseDraftAttachments,
Expand Down Expand Up @@ -1494,6 +1495,11 @@ export function useComposerSend(input: ComposerSendInput) {
}
if (!isAtomCommandInterrupted(failure)) {
const error = squashAtomCommandFailure(failure);
markAttachmentClaimFailure({
environmentId,
attachments: composerAttachmentsSnapshot,
error,
});
setThreadError(
threadIdForSend,
error instanceof Error ? error.message : "Failed to send message.",
Expand Down
11 changes: 10 additions & 1 deletion apps/web/src/components/chat/useMultipleModelSend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ import {
useComposerDraftStore,
} from "../../composerDraftStore";
import type { ComposerHandleRef } from "../../composerHandleContext";
import { releaseDraftAttachments } from "../../lib/attachmentUploadQueue";
import {
markAttachmentClaimFailure,
releaseDraftAttachments,
} from "../../lib/attachmentUploadQueue";
import type { buildMessageContext } from "../../lib/composerContextRecords";
import { appAtomRegistry } from "../../rpc/atomRegistry";
import { threadEnvironment } from "../../state/threads";
Expand Down Expand Up @@ -162,6 +165,12 @@ export function useMultipleModelSend(input: MultipleModelSendInput) {
]),
model: target.selection.model,
uncertainSubmissions: uncertainSubmissionsRef.current,
onFailure: (error) =>
markAttachmentClaimFailure({
environmentId,
attachments: attachmentsSnapshot,
error,
}),
start: (targetThreadId) => {
return startThreadTurn({
environmentId,
Expand Down
91 changes: 91 additions & 0 deletions apps/web/src/lib/attachmentUploadQueue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ vi.mock("../state/session", () => ({
import {
awaitAttachmentUploads,
getUploadedAttachments,
markAttachmentClaimFailure,
readAttachmentUpload,
refreshExpiredAttachmentUploads,
releaseAttachmentUpload,
Expand Down Expand Up @@ -211,6 +212,96 @@ describe("attachmentUploadQueue", () => {
vi.unstubAllGlobals();
});

it("marks only the matching upload when same-named images fail server validation", () => {
const images = [makeImage("first"), makeImage("second")].map((image) => ({
...image,
name: "image.png",
}));
useAttachmentUploadStore.setState({
uploadsByImageId: {
first: { status: "ready", environmentId: firstEnvironment, attachmentId: "pending-1" },
second: { status: "ready", environmentId: firstEnvironment, attachmentId: "pending-2" },
},
});
markAttachmentClaimFailure({
environmentId: firstEnvironment,
attachments: images,
error: { attachmentId: "pending-2" },
});
expect(readAttachmentUpload("first")?.status).toBe("ready");
expect(readAttachmentUpload("second")).toMatchObject({
status: "failed",
attachmentId: "pending-2",
});
});

it("rechecks a server-rejected restored document when local bytes are unavailable", async () => {
const file = {
...makeFile("restored"),
file: null,
uploadedAttachmentId: "pending-original",
uploadEnvironmentId: firstEnvironment,
};
useAttachmentUploadStore.setState({
uploadsByImageId: {
[file.id]: {
status: "ready",
environmentId: firstEnvironment,
attachmentId: "pending-original",
},
},
});
mocks.executeAtomQuery.mockResolvedValue({ _tag: "Success", value: {} });
markAttachmentClaimFailure({
environmentId: firstEnvironment,
attachments: [file],
error: { attachmentId: "pending-original" },
});
retryAttachmentUpload({ environmentId: firstEnvironment, image: file });
await awaitAttachmentUploads([file.id]);
expect(
getUploadedAttachments({ environmentId: firstEnvironment, images: [file] })?.[0]?.id,
).toBe("pending-original");
expect(TestXmlHttpRequest.requests).toHaveLength(0);
});

it("reuploads a server-rejected persisted document instead of reusing its ID", async () => {
const file = {
...makeFile("rejected"),
uploadedAttachmentId: "pending-original",
uploadEnvironmentId: firstEnvironment,
};
useAttachmentUploadStore.setState({
uploadsByImageId: {
[file.id]: {
status: "ready",
environmentId: firstEnvironment,
attachmentId: "pending-original",
},
},
});
markAttachmentClaimFailure({
environmentId: firstEnvironment,
attachments: [file],
error: { attachmentId: "pending-original" },
});
retryAttachmentUpload({ environmentId: firstEnvironment, image: file });
await vi.waitFor(() => expect(TestXmlHttpRequest.requests).toHaveLength(1));
const failed = awaitAttachmentUploads([file.id]);
TestXmlHttpRequest.requests[0]!.complete(500);
await failed;
expect(readAttachmentUpload(file.id)).toMatchObject({ status: "failed", forceReupload: true });
retryAttachmentUpload({ environmentId: firstEnvironment, image: file });
await vi.waitFor(() => expect(TestXmlHttpRequest.requests).toHaveLength(2));
const settled = awaitAttachmentUploads([file.id]);
TestXmlHttpRequest.requests[1]!.complete();
await settled;
expect(
getUploadedAttachments({ environmentId: firstEnvironment, images: [file] })?.[0]?.id,
).not.toBe("pending-original");
expect(mocks.executeAtomQuery).not.toHaveBeenCalled();
});

it("reuploads an expired image while retaining other ready images", async () => {
const images = [makeImage("first"), makeImage("second")].map((image) => ({
...image,
Expand Down
47 changes: 46 additions & 1 deletion apps/web/src/lib/attachmentUploadQueue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ interface UploadJob {
/** Original owner; completion resolves the current owner because uploads can outlive draft moves. */
readonly draftTarget?: ComposerThreadTarget;
readonly previous?: ReadyAttachmentUpload;
readonly forceReupload?: boolean;
/** Draft-owned; delete only through releasePersistedAttachmentUpload, never queue cancel/retry. */
readonly persistedAttachmentId?: string;
readonly settled: Promise<void>;
Expand Down Expand Up @@ -85,6 +86,39 @@ export function readAttachmentUpload(imageId: string): AttachmentUploadState | u
return useAttachmentUploadStore.getState().uploadsByImageId[imageId];
}

/** Associate a server claim failure with the local draft attachment, regardless of its name. */
export function markAttachmentClaimFailure(input: {
readonly environmentId: EnvironmentId;
readonly attachments: ReadonlyArray<ComposerImageAttachment | ComposerFileAttachment>;
readonly error: unknown;
}): void {
if (
typeof input.error !== "object" ||
input.error === null ||
!("attachmentId" in input.error) ||
typeof input.error.attachmentId !== "string"
)
return;
for (const attachment of input.attachments) {
const upload = readAttachmentUpload(attachment.id);
if (
upload?.status !== "ready" ||
upload.environmentId !== input.environmentId ||
upload.attachmentId !== input.error.attachmentId
)
continue;
setUploadState(attachment.id, {
status: "failed",
environmentId: input.environmentId,
forceReupload: attachment.file !== null,
...(attachment.type !== "file" || attachment.uploadedAttachmentId !== upload.attachmentId
? { attachmentId: upload.attachmentId }
: {}),
reason: input.error instanceof Error ? input.error.message : "Attachment could not be sent.",
});
}
}

function resolveCurrentFileDraftTarget(job: DraftFileOwner): ComposerThreadTarget | undefined {
if (job.draftTarget === undefined || job.image.type !== "file") {
return undefined;
Expand Down Expand Up @@ -170,6 +204,7 @@ async function runUpload(job: UploadJob): Promise<void> {
setUploadState(job.image.id, {
status: "failed",
environmentId: job.environmentId,
...(job.forceReupload ? { forceReupload: true } : {}),
reason: "This connection cannot upload attachments.",
...(job.previous ? { previous: job.previous } : {}),
});
Expand Down Expand Up @@ -216,6 +251,7 @@ async function runUpload(job: UploadJob): Promise<void> {
setUploadState(job.image.id, {
status: "failed",
environmentId: job.environmentId,
...(job.forceReupload ? { forceReupload: true } : {}),
reason:
verification.status === "missing"
? "Uploaded file expired. Remove it and attach it again."
Expand All @@ -236,6 +272,7 @@ async function runUpload(job: UploadJob): Promise<void> {
setUploadState(job.image.id, {
status: "failed",
environmentId: job.environmentId,
...(job.forceReupload ? { forceReupload: true } : {}),
reason: "Unsupported image type",
...(job.previous ? { previous: job.previous } : {}),
});
Expand All @@ -246,6 +283,7 @@ async function runUpload(job: UploadJob): Promise<void> {
setUploadState(job.image.id, {
status: "failed",
environmentId: job.environmentId,
...(job.forceReupload ? { forceReupload: true } : {}),
reason: "Original file is no longer available",
...(job.previous ? { previous: job.previous } : {}),
});
Expand All @@ -257,6 +295,7 @@ async function runUpload(job: UploadJob): Promise<void> {
setUploadState(job.image.id, {
status: "failed",
environmentId: job.environmentId,
...(job.forceReupload ? { forceReupload: true } : {}),
reason: "This connection cannot upload attachments.",
...(job.previous ? { previous: job.previous } : {}),
});
Expand Down Expand Up @@ -332,6 +371,7 @@ async function runUpload(job: UploadJob): Promise<void> {
setUploadState(job.image.id, {
status: "failed",
environmentId: job.environmentId,
...(job.forceReupload ? { forceReupload: true } : {}),
reason:
result.step === "mint"
? "Upload could not start"
Expand Down Expand Up @@ -367,6 +407,7 @@ function pumpUploads(): void {
setUploadState(job.image.id, {
status: "failed",
environmentId: job.environmentId,
...(job.forceReupload ? { forceReupload: true } : {}),
reason: "Upload failed",
...(job.previous ? { previous: job.previous } : {}),
});
Expand Down Expand Up @@ -445,6 +486,7 @@ export function startAttachmentUpload(input: {
image: input.image,
environmentId: input.environmentId,
...(input.draftTarget !== undefined ? { draftTarget: input.draftTarget } : {}),
...(input.force ? { forceReupload: true } : {}),
...(previous ? { previous } : {}),
...(!input.force &&
input.image.type === "file" &&
Expand Down Expand Up @@ -569,7 +611,10 @@ export function retryAttachmentUpload(input: {
} else {
clearUploadState(input.image.id);
}
startAttachmentUpload(input);
startAttachmentUpload({
...input,
force: previous?.status === "failed" && previous.forceReupload === true,
});
}

/** Verifies uploads before stash restore; pending uploads expire after 24 hours. */
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/lib/attachmentUploadState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export type AttachmentUploadState =
readonly status: "failed";
readonly environmentId: EnvironmentId;
readonly reason: string;
/** A server claim rejected this upload; retry must not reuse its persisted ID. */
readonly forceReupload?: boolean;
/** Retry/release deletes this id; never use a persisted draft attachment id. */
readonly attachmentId?: string;
readonly previous?: ReadyAttachmentUpload;
Expand Down
Loading
Loading