| author | |
| committer | |
| log | e9f50c3efcb02a199042364ead292e164274e716 |
| tree | bcff92529017a600605fce875f00f58a9ab1c426 |
| parent | 7c6163c67aa122fa7c13466536c012888db102c6 |
| signature | Signed by PGP key B5690EEEBB952194 |
37 files changed, 2475 insertions(+), 123 deletions(-)
apps/server/src/assets/AttachmentUpload.test.ts created+128| ... | @@ -0,0 +1,128 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off | ||
| 2 | import * as NodeFS from "node:fs"; | ||
| 3 | import * as NodePath from "node:path"; | ||
| 4 | |||
| 5 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 6 | import { describe, expect, it } from "@effect/vitest"; | ||
| 7 | import * as Effect from "effect/Effect"; | ||
| 8 | import * as Layer from "effect/Layer"; | ||
| 9 | import * as TestClock from "effect/testing/TestClock"; | ||
| 10 | |||
| 11 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | ||
| 12 | import * as ServerConfig from "../config.ts"; | ||
| 13 | import { parseThreadSegmentFromAttachmentId } from "../attachmentStore.ts"; | ||
| 14 | import { | ||
| 15 | ATTACHMENT_UPLOAD_ROUTE_PREFIX, | ||
| 16 | deletePendingAttachment, | ||
| 17 | issueAttachmentUploadUrl, | ||
| 18 | storeAttachmentUpload, | ||
| 19 | validateAttachmentUploadToken, | ||
| 20 | } from "./AttachmentUpload.ts"; | ||
| 21 | |||
| 22 | const testLayer = ServerSecretStore.layer.pipe( | ||
| 23 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-attachment-upload-" })), | ||
| 24 | Layer.provideMerge(NodeServices.layer), | ||
| 25 | ); | ||
| 26 | |||
| 27 | const uploadInput = { | ||
| 28 | name: "screenshot.png", | ||
| 29 | mimeType: "image/png", | ||
| 30 | sizeBytes: 6, | ||
| 31 | } as const; | ||
| 32 | |||
| 33 | describe("AttachmentUpload", () => { | ||
| 34 | it.effect("signs the attachment metadata and validates the upload token", () => | ||
| 35 | Effect.gen(function* () { | ||
| 36 | const issued = yield* issueAttachmentUploadUrl(uploadInput); | ||
| 37 | expect(parseThreadSegmentFromAttachmentId(issued.attachmentId)).toBe("pending"); | ||
| 38 | |||
| 39 | const token = issued.relativeUrl.slice(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`.length); | ||
| 40 | expect(yield* validateAttachmentUploadToken(token)).toMatchObject({ | ||
| 41 | kind: "attachment-upload", | ||
| 42 | attachmentId: issued.attachmentId, | ||
| 43 | name: "screenshot.png", | ||
| 44 | mimeType: "image/png", | ||
| 45 | sizeBytes: 6, | ||
| 46 | }); | ||
| 47 | }).pipe(Effect.provide(testLayer)), | ||
| 48 | ); | ||
| 49 | |||
| 50 | it.effect("rejects tampered and malformed upload tokens", () => | ||
| 51 | Effect.gen(function* () { | ||
| 52 | const issued = yield* issueAttachmentUploadUrl(uploadInput); | ||
| 53 | const token = issued.relativeUrl.slice(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`.length); | ||
| 54 | const [payload, signature] = token.split("."); | ||
| 55 | |||
| 56 | expect(yield* validateAttachmentUploadToken(`${payload}x.${signature}`)).toBeNull(); | ||
| 57 | expect(yield* validateAttachmentUploadToken(`${token}.extra`)).toBeNull(); | ||
| 58 | expect(yield* validateAttachmentUploadToken("garbage")).toBeNull(); | ||
| 59 | }).pipe(Effect.provide(testLayer)), | ||
| 60 | ); | ||
| 61 | |||
| 62 | it.effect("rejects expired upload tokens", () => | ||
| 63 | Effect.gen(function* () { | ||
| 64 | const issued = yield* issueAttachmentUploadUrl(uploadInput); | ||
| 65 | const token = issued.relativeUrl.slice(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`.length); | ||
| 66 | |||
| 67 | yield* TestClock.adjust("11 minutes"); | ||
| 68 | expect(yield* validateAttachmentUploadToken(token)).toBeNull(); | ||
| 69 | }).pipe(Effect.provide(testLayer)), | ||
| 70 | ); | ||
| 71 | |||
| 72 | it.effect("removes expired pending uploads while issuing a new upload URL", () => | ||
| 73 | Effect.gen(function* () { | ||
| 74 | const config = yield* ServerConfig.ServerConfig; | ||
| 75 | const staleId = "pending-00000000-0000-4000-8000-0000000000cc"; | ||
| 76 | const stalePath = NodePath.join(config.attachmentsDir, `${staleId}.png`); | ||
| 77 | NodeFS.writeFileSync(stalePath, Buffer.from("pixels")); | ||
| 78 | NodeFS.utimesSync(stalePath, 0, 0); | ||
| 79 | |||
| 80 | yield* TestClock.adjust("25 hours"); | ||
| 81 | yield* issueAttachmentUploadUrl(uploadInput); | ||
| 82 | |||
| 83 | expect(NodeFS.existsSync(stalePath)).toBe(false); | ||
| 84 | }).pipe(Effect.provide(testLayer)), | ||
| 85 | ); | ||
| 86 | |||
| 87 | it.effect("stores the expected bytes without leaving temporary files", () => | ||
| 88 | Effect.gen(function* () { | ||
| 89 | const config = yield* ServerConfig.ServerConfig; | ||
| 90 | const issued = yield* issueAttachmentUploadUrl(uploadInput); | ||
| 91 | const token = issued.relativeUrl.slice(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`.length); | ||
| 92 | const claims = yield* validateAttachmentUploadToken(token); | ||
| 93 | if (!claims) { | ||
| 94 | throw new Error("Expected valid upload claims."); | ||
| 95 | } | ||
| 96 | |||
| 97 | expect(yield* storeAttachmentUpload(claims, new Uint8Array([1, 2, 3]))).toMatchObject({ | ||
| 98 | ok: false, | ||
| 99 | status: 400, | ||
| 100 | }); | ||
| 101 | expect(yield* storeAttachmentUpload(claims, new Uint8Array(6))).toEqual({ ok: true }); | ||
| 102 | expect( | ||
| 103 | NodeFS.existsSync(NodePath.join(config.attachmentsDir, `${issued.attachmentId}.png`)), | ||
| 104 | ).toBe(true); | ||
| 105 | expect( | ||
| 106 | NodeFS.readdirSync(config.attachmentsDir).filter((entry) => entry.endsWith(".part")), | ||
| 107 | ).toEqual([]); | ||
| 108 | }).pipe(Effect.provide(testLayer)), | ||
| 109 | ); | ||
| 110 | |||
| 111 | it.effect("deletes pending uploads without deleting thread-owned copies", () => | ||
| 112 | Effect.gen(function* () { | ||
| 113 | const config = yield* ServerConfig.ServerConfig; | ||
| 114 | const uuid = "00000000-0000-4000-8000-0000000000dd"; | ||
| 115 | const pendingPath = NodePath.join(config.attachmentsDir, `pending-${uuid}.png`); | ||
| 116 | const claimedPath = NodePath.join(config.attachmentsDir, `thread-1-${uuid}.png`); | ||
| 117 | NodeFS.writeFileSync(pendingPath, Buffer.from("pixels")); | ||
| 118 | NodeFS.writeFileSync(claimedPath, Buffer.from("pixels")); | ||
| 119 | |||
| 120 | yield* deletePendingAttachment(`pending-${uuid}`); | ||
| 121 | yield* deletePendingAttachment(`pending-${uuid}`); | ||
| 122 | yield* deletePendingAttachment(`thread-1-${uuid}`); | ||
| 123 | |||
| 124 | expect(NodeFS.existsSync(pendingPath)).toBe(false); | ||
| 125 | expect(NodeFS.existsSync(claimedPath)).toBe(true); | ||
| 126 | }).pipe(Effect.provide(testLayer)), | ||
| 127 | ); | ||
| 128 | }); | ||
apps/server/src/assets/AttachmentUpload.ts created+214| ... | @@ -0,0 +1,214 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off | ||
| 2 | import * as NodeCrypto from "node:crypto"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | ATTACHMENT_UPLOAD_URL_TTL_MS, | ||
| 6 | type AttachmentCreateUploadUrlInput, | ||
| 7 | AttachmentUploadSigningKeyError, | ||
| 8 | } from "@t3tools/contracts"; | ||
| 9 | import * as Clock from "effect/Clock"; | ||
| 10 | import * as Effect from "effect/Effect"; | ||
| 11 | import * as FileSystem from "effect/FileSystem"; | ||
| 12 | import * as Option from "effect/Option"; | ||
| 13 | import * as Path from "effect/Path"; | ||
| 14 | import * as Schema from "effect/Schema"; | ||
| 15 | |||
| 16 | import { | ||
| 17 | createPendingAttachmentId, | ||
| 18 | parseThreadSegmentFromAttachmentId, | ||
| 19 | PENDING_ATTACHMENT_THREAD_SEGMENT, | ||
| 20 | resolveAttachmentPathById, | ||
| 21 | sweepStalePendingAttachments, | ||
| 22 | } from "../attachmentStore.ts"; | ||
| 23 | import { resolveAttachmentRelativePath } from "../attachmentPaths.ts"; | ||
| 24 | import { | ||
| 25 | base64UrlDecodeUtf8, | ||
| 26 | base64UrlEncode, | ||
| 27 | signPayload, | ||
| 28 | timingSafeEqualBase64Url, | ||
| 29 | } from "../auth/utils.ts"; | ||
| 30 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | ||
| 31 | import * as ServerConfig from "../config.ts"; | ||
| 32 | import { inferImageExtension } from "../imageMime.ts"; | ||
| 33 | |||
| 34 | export const ATTACHMENT_UPLOAD_ROUTE_PREFIX = "/api/attachments/upload"; | ||
| 35 | |||
| 36 | // Asset download tokens share this key, but their signed claim kind is different. | ||
| 37 | const SIGNING_SECRET_NAME = "asset-access-signing-key"; | ||
| 38 | const PENDING_ATTACHMENT_SWEEP_INTERVAL_MS = 15 * 60_000; | ||
| 39 | const lastPendingSweepByDirectory = new Map<string, number>(); | ||
| 40 | |||
| 41 | const AttachmentUploadClaims = Schema.Struct({ | ||
| 42 | version: Schema.Literal(1), | ||
| 43 | kind: Schema.Literal("attachment-upload"), | ||
| 44 | attachmentId: Schema.String, | ||
| 45 | name: Schema.String, | ||
| 46 | mimeType: Schema.String, | ||
| 47 | sizeBytes: Schema.Number, | ||
| 48 | expiresAt: Schema.Number, | ||
| 49 | }); | ||
| 50 | export type AttachmentUploadClaims = typeof AttachmentUploadClaims.Type; | ||
| 51 | |||
| 52 | const attachmentUploadClaimsJson = Schema.fromJsonString(AttachmentUploadClaims); | ||
| 53 | const decodeAttachmentUploadClaims = Schema.decodeUnknownOption(attachmentUploadClaimsJson); | ||
| 54 | const encodeAttachmentUploadClaims = Schema.encodeSync(attachmentUploadClaimsJson); | ||
| 55 | |||
| 56 | function decodeClaims(encodedPayload: string): AttachmentUploadClaims | null { | ||
| 57 | try { | ||
| 58 | return Option.getOrNull(decodeAttachmentUploadClaims(base64UrlDecodeUtf8(encodedPayload))); | ||
| 59 | } catch { | ||
| 60 | return null; | ||
| 61 | } | ||
| 62 | } | ||
| 63 | |||
| 64 | const loadSigningSecret = Effect.gen(function* () { | ||
| 65 | const secretStore = yield* ServerSecretStore.ServerSecretStore; | ||
| 66 | return yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32); | ||
| 67 | }); | ||
| 68 | |||
| 69 | export const issueAttachmentUploadUrl = Effect.fn("AttachmentUpload.issueUrl")(function* ( | ||
| 70 | input: AttachmentCreateUploadUrlInput, | ||
| 71 | ) { | ||
| 72 | const secret = yield* loadSigningSecret.pipe( | ||
| 73 | Effect.mapError((cause) => new AttachmentUploadSigningKeyError({ cause })), | ||
| 74 | ); | ||
| 75 | const config = yield* ServerConfig.ServerConfig; | ||
| 76 | const nowMs = yield* Clock.currentTimeMillis; | ||
| 77 | const previousSweep = lastPendingSweepByDirectory.get(config.attachmentsDir); | ||
| 78 | if ( | ||
| 79 | previousSweep === undefined || | ||
| 80 | nowMs - previousSweep >= PENDING_ATTACHMENT_SWEEP_INTERVAL_MS | ||
| 81 | ) { | ||
| 82 | lastPendingSweepByDirectory.set(config.attachmentsDir, nowMs); | ||
| 83 | const swept = sweepStalePendingAttachments({ | ||
| 84 | attachmentsDir: config.attachmentsDir, | ||
| 85 | nowMs, | ||
| 86 | }); | ||
| 87 | if (swept.deleted > 0) { | ||
| 88 | yield* Effect.logInfo("Removed expired attachment uploads.", { deleted: swept.deleted }); | ||
| 89 | } | ||
| 90 | } | ||
| 91 | |||
| 92 | const attachmentId = createPendingAttachmentId(); | ||
| 93 | const expiresAt = nowMs + ATTACHMENT_UPLOAD_URL_TTL_MS; | ||
| 94 | const encodedPayload = base64UrlEncode( | ||
| 95 | encodeAttachmentUploadClaims({ | ||
| 96 | version: 1, | ||
| 97 | kind: "attachment-upload", | ||
| 98 | attachmentId, | ||
| 99 | name: input.name, | ||
| 100 | mimeType: input.mimeType, | ||
| 101 | sizeBytes: input.sizeBytes, | ||
| 102 | expiresAt, | ||
| 103 | }), | ||
| 104 | ); | ||
| 105 | |||
| 106 | return { | ||
| 107 | attachmentId, | ||
| 108 | relativeUrl: `${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/${encodedPayload}.${signPayload(encodedPayload, secret)}`, | ||
| 109 | expiresAt, | ||
| 110 | }; | ||
| 111 | }); | ||
| 112 | |||
| 113 | export const validateAttachmentUploadToken = Effect.fn("AttachmentUpload.validateToken")(function* ( | ||
| 114 | token: string, | ||
| 115 | ) { | ||
| 116 | const [encodedPayload, signature, unexpectedSegment] = token.split("."); | ||
| 117 | if (!encodedPayload || !signature || unexpectedSegment) { | ||
| 118 | return null; | ||
| 119 | } | ||
| 120 | |||
| 121 | const secret = yield* loadSigningSecret.pipe( | ||
| 122 | Effect.tapError((cause) => | ||
| 123 | Effect.logError("Failed to load the attachment upload signing key.", { cause }), | ||
| 124 | ), | ||
| 125 | Effect.orElseSucceed(() => null), | ||
| 126 | ); | ||
| 127 | if (!secret || !timingSafeEqualBase64Url(signature, signPayload(encodedPayload, secret))) { | ||
| 128 | return null; | ||
| 129 | } | ||
| 130 | |||
| 131 | const claims = decodeClaims(encodedPayload); | ||
| 132 | if (!claims || claims.expiresAt <= (yield* Clock.currentTimeMillis)) { | ||
| 133 | return null; | ||
| 134 | } | ||
| 135 | return claims; | ||
| 136 | }); | ||
| 137 | |||
| 138 | export type StoreAttachmentUploadResult = | ||
| 139 | | { readonly ok: true } | ||
| 140 | | { readonly ok: false; readonly status: number; readonly detail: string }; | ||
| 141 | |||
| 142 | export const storeAttachmentUpload = Effect.fn("AttachmentUpload.store")(function* ( | ||
| 143 | claims: AttachmentUploadClaims, | ||
| 144 | bytes: Uint8Array, | ||
| 145 | ) { | ||
| 146 | if (bytes.byteLength !== claims.sizeBytes) { | ||
| 147 | return { | ||
| 148 | ok: false, | ||
| 149 | status: 400, | ||
| 150 | detail: `Body was ${bytes.byteLength} bytes, expected ${claims.sizeBytes}.`, | ||
| 151 | } satisfies StoreAttachmentUploadResult; | ||
| 152 | } | ||
| 153 | |||
| 154 | const config = yield* ServerConfig.ServerConfig; | ||
| 155 | const extension = inferImageExtension({ mimeType: claims.mimeType, fileName: claims.name }); | ||
| 156 | const relativePath = `${claims.attachmentId}${extension}`; | ||
| 157 | const finalPath = resolveAttachmentRelativePath({ | ||
| 158 | attachmentsDir: config.attachmentsDir, | ||
| 159 | relativePath, | ||
| 160 | }); | ||
| 161 | const partPath = resolveAttachmentRelativePath({ | ||
| 162 | attachmentsDir: config.attachmentsDir, | ||
| 163 | relativePath: `${relativePath}.${NodeCrypto.randomUUID()}.part`, | ||
| 164 | }); | ||
| 165 | if (!finalPath || !partPath) { | ||
| 166 | return { ok: false, status: 500, detail: "Failed to resolve attachment path." }; | ||
| 167 | } | ||
| 168 | |||
| 169 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 170 | const path = yield* Path.Path; | ||
| 171 | return yield* Effect.gen(function* () { | ||
| 172 | yield* fileSystem.makeDirectory(path.dirname(finalPath), { recursive: true }); | ||
| 173 | yield* fileSystem.writeFile(partPath, bytes); | ||
| 174 | yield* fileSystem.rename(partPath, finalPath); | ||
| 175 | return { ok: true } satisfies StoreAttachmentUploadResult; | ||
| 176 | }).pipe( | ||
| 177 | Effect.catch((cause) => | ||
| 178 | fileSystem.remove(partPath, { force: true }).pipe( | ||
| 179 | Effect.orElseSucceed(() => undefined), | ||
| 180 | Effect.andThen( | ||
| 181 | Effect.logError("Failed to persist attachment upload.", { | ||
| 182 | attachmentId: claims.attachmentId, | ||
| 183 | cause, | ||
| 184 | }), | ||
| 185 | ), | ||
| 186 | Effect.as({ | ||
| 187 | ok: false, | ||
| 188 | status: 500, | ||
| 189 | detail: "Failed to persist upload.", | ||
| 190 | } satisfies StoreAttachmentUploadResult), | ||
| 191 | ), | ||
| 192 | ), | ||
| 193 | ); | ||
| 194 | }); | ||
| 195 | |||
| 196 | export const deletePendingAttachment = Effect.fn("AttachmentUpload.deletePending")(function* ( | ||
| 197 | attachmentId: string, | ||
| 198 | ) { | ||
| 199 | if (parseThreadSegmentFromAttachmentId(attachmentId) !== PENDING_ATTACHMENT_THREAD_SEGMENT) { | ||
| 200 | return; | ||
| 201 | } | ||
| 202 | |||
| 203 | const config = yield* ServerConfig.ServerConfig; | ||
| 204 | const attachmentPath = resolveAttachmentPathById({ | ||
| 205 | attachmentsDir: config.attachmentsDir, | ||
| 206 | attachmentId, | ||
| 207 | }); | ||
| 208 | if (!attachmentPath) { | ||
| 209 | return; | ||
| 210 | } | ||
| 211 | |||
| 212 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 213 | yield* fileSystem.remove(attachmentPath, { force: true }).pipe(Effect.orElseSucceed(() => {})); | ||
| 214 | }); | ||
apps/server/src/attachmentStore.test.ts+85| ... | @@ -7,8 +7,12 @@ import { describe, expect, it } from "vite-plus/test"; | ... | @@ -7,8 +7,12 @@ import { describe, expect, it } from "vite-plus/test"; |
| 7 | 7 | ||
| 8 | import { | 8 | import { |
| 9 | createAttachmentId, | 9 | createAttachmentId, |
| 10 | createPendingAttachmentId, | ||
| 11 | parseAttachmentUuid, | ||
| 12 | planAttachmentClaim, | ||
| 10 | parseThreadSegmentFromAttachmentId, | 13 | parseThreadSegmentFromAttachmentId, |
| 11 | resolveAttachmentPathById, | 14 | resolveAttachmentPathById, |
| 15 | sweepStalePendingAttachments, | ||
| 12 | } from "./attachmentStore.ts"; | 16 | } from "./attachmentStore.ts"; |
| 13 | 17 | ||
| 14 | describe("attachmentStore", () => { | 18 | describe("attachmentStore", () => { |
| ... | @@ -44,6 +48,16 @@ describe("attachmentStore", () => { | ... | @@ -44,6 +48,16 @@ describe("attachmentStore", () => { |
| 44 | expect(parseThreadSegmentFromAttachmentId(attachmentId)).toBe("thread-foo"); | 48 | expect(parseThreadSegmentFromAttachmentId(attachmentId)).toBe("thread-foo"); |
| 45 | }); | 49 | }); |
| 46 | 50 | ||
| 51 | it("reserves the pending attachment segment", () => { | ||
| 52 | const pendingId = createPendingAttachmentId(); | ||
| 53 | expect(parseThreadSegmentFromAttachmentId(pendingId)).toBe("pending"); | ||
| 54 | expect(parseAttachmentUuid(pendingId)).toMatch(/^[a-f0-9-]{36}$/); | ||
| 55 | expect(parseThreadSegmentFromAttachmentId(createAttachmentId("pending")!)).toBe("_pending"); | ||
| 56 | expect(parseThreadSegmentFromAttachmentId(createAttachmentId("pending_thread")!)).toBe( | ||
| 57 | "pending_thread", | ||
| 58 | ); | ||
| 59 | }); | ||
| 60 | |||
| 47 | it("resolves attachment path by id using the extension that exists on disk", () => { | 61 | it("resolves attachment path by id using the extension that exists on disk", () => { |
| 48 | const attachmentsDir = NodeFS.mkdtempSync( | 62 | const attachmentsDir = NodeFS.mkdtempSync( |
| 49 | NodePath.join(NodeOS.tmpdir(), "t3code-attachment-store-"), | 63 | NodePath.join(NodeOS.tmpdir(), "t3code-attachment-store-"), |
| ... | @@ -77,4 +91,75 @@ describe("attachmentStore", () => { | ... | @@ -77,4 +91,75 @@ describe("attachmentStore", () => { |
| 77 | NodeFS.rmSync(attachmentsDir, { recursive: true, force: true }); | 91 | NodeFS.rmSync(attachmentsDir, { recursive: true, force: true }); |
| 78 | } | 92 | } |
| 79 | }); | 93 | }); |
| 94 | |||
| 95 | it("plans pending attachment claims with direct filename lookups", () => { | ||
| 96 | const attachmentsDir = NodeFS.mkdtempSync( | ||
| 97 | NodePath.join(NodeOS.tmpdir(), "t3code-attachment-claim-"), | ||
| 98 | ); | ||
| 99 | try { | ||
| 100 | const uuid = "00000000-0000-4000-8000-000000000001"; | ||
| 101 | const pendingPath = NodePath.join(attachmentsDir, `pending-${uuid}.png`); | ||
| 102 | NodeFS.writeFileSync(pendingPath, Buffer.from("pixels")); | ||
| 103 | |||
| 104 | const claim = planAttachmentClaim({ | ||
| 105 | attachmentsDir, | ||
| 106 | threadId: "thread-1", | ||
| 107 | attachmentId: `pending-${uuid}`, | ||
| 108 | }); | ||
| 109 | expect(claim).toMatchObject({ | ||
| 110 | ok: true, | ||
| 111 | currentPath: pendingPath, | ||
| 112 | }); | ||
| 113 | if (!claim.ok) { | ||
| 114 | return; | ||
| 115 | } | ||
| 116 | expect(parseThreadSegmentFromAttachmentId(claim.finalId)).toBe("thread-1"); | ||
| 117 | expect(parseAttachmentUuid(claim.finalId)).not.toBe(uuid); | ||
| 118 | expect(claim.finalPath).toBe(NodePath.join(attachmentsDir, `${claim.finalId}.png`)); | ||
| 119 | } finally { | ||
| 120 | NodeFS.rmSync(attachmentsDir, { recursive: true, force: true }); | ||
| 121 | } | ||
| 122 | }); | ||
| 123 | |||
| 124 | it("rejects thread-owned attachments even when thread segments collide", () => { | ||
| 125 | const attachmentsDir = NodeFS.mkdtempSync( | ||
| 126 | NodePath.join(NodeOS.tmpdir(), "t3code-attachment-ownership-"), | ||
| 127 | ); | ||
| 128 | try { | ||
| 129 | const attachmentId = "a-b-00000000-0000-4000-8000-000000000003"; | ||
| 130 | NodeFS.writeFileSync(NodePath.join(attachmentsDir, `${attachmentId}.png`), "pixels"); | ||
| 131 | |||
| 132 | expect(planAttachmentClaim({ attachmentsDir, threadId: "a b", attachmentId })).toEqual({ | ||
| 133 | ok: false, | ||
| 134 | reason: "attachment must be a pending upload", | ||
| 135 | }); | ||
| 136 | } finally { | ||
| 137 | NodeFS.rmSync(attachmentsDir, { recursive: true, force: true }); | ||
| 138 | } | ||
| 139 | }); | ||
| 140 | |||
| 141 | it("removes expired pending and partial files without touching thread attachments", () => { | ||
| 142 | const attachmentsDir = NodeFS.mkdtempSync( | ||
| 143 | NodePath.join(NodeOS.tmpdir(), "t3code-attachment-sweep-"), | ||
| 144 | ); | ||
| 145 | try { | ||
| 146 | const now = 1_800_000_000_000; | ||
| 147 | const oldTimeSeconds = (now - 2 * 24 * 60 * 60 * 1000) / 1000; | ||
| 148 | const uuid = "00000000-0000-4000-8000-000000000002"; | ||
| 149 | const pendingPath = NodePath.join(attachmentsDir, `pending-${uuid}.png`); | ||
| 150 | const threadPath = NodePath.join(attachmentsDir, `thread-1-${uuid}.png`); | ||
| 151 | const partialPath = NodePath.join(attachmentsDir, `${uuid}.part`); | ||
| 152 | for (const filePath of [pendingPath, threadPath, partialPath]) { | ||
| 153 | NodeFS.writeFileSync(filePath, Buffer.from("pixels")); | ||
| 154 | NodeFS.utimesSync(filePath, oldTimeSeconds, oldTimeSeconds); | ||
| 155 | } | ||
| 156 | |||
| 157 | expect(sweepStalePendingAttachments({ attachmentsDir, nowMs: now })).toEqual({ deleted: 2 }); | ||
| 158 | expect(NodeFS.existsSync(pendingPath)).toBe(false); | ||
| 159 | expect(NodeFS.existsSync(partialPath)).toBe(false); | ||
| 160 | expect(NodeFS.existsSync(threadPath)).toBe(true); | ||
| 161 | } finally { | ||
| 162 | NodeFS.rmSync(attachmentsDir, { recursive: true, force: true }); | ||
| 163 | } | ||
| 164 | }); | ||
| 80 | }); | 165 | }); |
apps/server/src/attachmentStore.ts+117-1| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off | 1 | // @effect-diagnostics nodeBuiltinImport:off |
| 2 | import * as NodeCrypto from "node:crypto"; | 2 | import * as NodeCrypto from "node:crypto"; |
| 3 | import * as NodeFS from "node:fs"; | 3 | import * as NodeFS from "node:fs"; |
| 4 | import * as NodePath from "node:path"; | ||
| 4 | 5 | ||
| 5 | import type { ChatAttachment } from "@t3tools/contracts"; | 6 | import type { ChatAttachment } from "@t3tools/contracts"; |
| 6 | 7 | ||
| ... | @@ -19,6 +20,10 @@ const ATTACHMENT_ID_PATTERN = new RegExp( | ... | @@ -19,6 +20,10 @@ const ATTACHMENT_ID_PATTERN = new RegExp( |
| 19 | "i", | 20 | "i", |
| 20 | ); | 21 | ); |
| 21 | 22 | ||
| 23 | export const PENDING_ATTACHMENT_THREAD_SEGMENT = "pending"; | ||
| 24 | export const PENDING_ATTACHMENT_MAX_AGE_MS = 24 * 60 * 60 * 1000; | ||
| 25 | const PARTIAL_UPLOAD_MAX_AGE_MS = 60 * 60 * 1000; | ||
| 26 | |||
| 22 | export function toSafeThreadAttachmentSegment(threadId: string): string | null { | 27 | export function toSafeThreadAttachmentSegment(threadId: string): string | null { |
| 23 | const segment = threadId | 28 | const segment = threadId |
| 24 | .trim() | 29 | .trim() |
| ... | @@ -31,7 +36,19 @@ export function toSafeThreadAttachmentSegment(threadId: string): string | null { | ... | @@ -31,7 +36,19 @@ export function toSafeThreadAttachmentSegment(threadId: string): string | null { |
| 31 | if (segment.length === 0) { | 36 | if (segment.length === 0) { |
| 32 | return null; | 37 | return null; |
| 33 | } | 38 | } |
| 34 | return segment; | 39 | return segment === PENDING_ATTACHMENT_THREAD_SEGMENT ? "_pending" : segment; |
| 40 | } | ||
| 41 | |||
| 42 | export function createPendingAttachmentId(): string { | ||
| 43 | return `${PENDING_ATTACHMENT_THREAD_SEGMENT}-${NodeCrypto.randomUUID()}`; | ||
| 44 | } | ||
| 45 | |||
| 46 | export function parseAttachmentUuid(attachmentId: string): string | null { | ||
| 47 | const normalizedId = normalizeAttachmentRelativePath(attachmentId); | ||
| 48 | if (!normalizedId || normalizedId.includes("/") || normalizedId.includes(".")) { | ||
| 49 | return null; | ||
| 50 | } | ||
| 51 | return normalizedId.match(ATTACHMENT_ID_PATTERN)?.[2]?.toLowerCase() ?? null; | ||
| 35 | } | 52 | } |
| 36 | 53 | ||
| 37 | export function createAttachmentId(threadId: string): string | null { | 54 | export function createAttachmentId(threadId: string): string | null { |
| ... | @@ -96,6 +113,105 @@ export function resolveAttachmentPathById(input: { | ... | @@ -96,6 +113,105 @@ export function resolveAttachmentPathById(input: { |
| 96 | return null; | 113 | return null; |
| 97 | } | 114 | } |
| 98 | 115 | ||
| 116 | export type AttachmentClaimPlan = | ||
| 117 | | { | ||
| 118 | readonly ok: true; | ||
| 119 | readonly finalId: string; | ||
| 120 | readonly currentPath: string; | ||
| 121 | readonly finalPath: string; | ||
| 122 | } | ||
| 123 | | { readonly ok: false; readonly reason: string }; | ||
| 124 | |||
| 125 | export function planAttachmentClaim(input: { | ||
| 126 | readonly attachmentsDir: string; | ||
| 127 | readonly threadId: string; | ||
| 128 | readonly attachmentId: string; | ||
| 129 | }): AttachmentClaimPlan { | ||
| 130 | const uuid = parseAttachmentUuid(input.attachmentId); | ||
| 131 | const requestedSegment = parseThreadSegmentFromAttachmentId(input.attachmentId); | ||
| 132 | if (!uuid || !requestedSegment) { | ||
| 133 | return { ok: false, reason: "invalid attachment id" }; | ||
| 134 | } | ||
| 135 | |||
| 136 | if (!toSafeThreadAttachmentSegment(input.threadId)) { | ||
| 137 | return { ok: false, reason: "invalid thread id" }; | ||
| 138 | } | ||
| 139 | if (requestedSegment !== PENDING_ATTACHMENT_THREAD_SEGMENT) { | ||
| 140 | return { ok: false, reason: "attachment must be a pending upload" }; | ||
| 141 | } | ||
| 142 | |||
| 143 | const currentPath = resolveAttachmentPathById({ | ||
| 144 | attachmentsDir: input.attachmentsDir, | ||
| 145 | attachmentId: input.attachmentId, | ||
| 146 | }); | ||
| 147 | if (!currentPath) { | ||
| 148 | return { ok: false, reason: "attachment not found (removed or expired)" }; | ||
| 149 | } | ||
| 150 | const finalId = createAttachmentId(input.threadId); | ||
| 151 | if (!finalId) { | ||
| 152 | return { ok: false, reason: "failed to create attachment id" }; | ||
| 153 | } | ||
| 154 | |||
| 155 | const expectedFinalPath = resolveAttachmentRelativePath({ | ||
| 156 | attachmentsDir: input.attachmentsDir, | ||
| 157 | relativePath: `${finalId}${NodePath.extname(currentPath)}`, | ||
| 158 | }); | ||
| 159 | if (!expectedFinalPath) { | ||
| 160 | return { ok: false, reason: "failed to resolve attachment path" }; | ||
| 161 | } | ||
| 162 | return { | ||
| 163 | ok: true, | ||
| 164 | finalId, | ||
| 165 | currentPath, | ||
| 166 | finalPath: expectedFinalPath, | ||
| 167 | }; | ||
| 168 | } | ||
| 169 | |||
| 170 | export function sweepStalePendingAttachments(input: { | ||
| 171 | readonly attachmentsDir: string; | ||
| 172 | readonly nowMs: number; | ||
| 173 | }): { readonly deleted: number } { | ||
| 174 | let entries: string[]; | ||
| 175 | try { | ||
| 176 | entries = NodeFS.readdirSync(input.attachmentsDir); | ||
| 177 | } catch { | ||
| 178 | return { deleted: 0 }; | ||
| 179 | } | ||
| 180 | |||
| 181 | let deleted = 0; | ||
| 182 | for (const entry of entries) { | ||
| 183 | const isPartial = entry.endsWith(".part"); | ||
| 184 | if (!isPartial) { | ||
| 185 | const attachmentId = parseAttachmentIdFromRelativePath(entry); | ||
| 186 | if ( | ||
| 187 | !attachmentId || | ||
| 188 | parseThreadSegmentFromAttachmentId(attachmentId) !== PENDING_ATTACHMENT_THREAD_SEGMENT | ||
| 189 | ) { | ||
| 190 | continue; | ||
| 191 | } | ||
| 192 | } | ||
| 193 | |||
| 194 | const resolved = resolveAttachmentRelativePath({ | ||
| 195 | attachmentsDir: input.attachmentsDir, | ||
| 196 | relativePath: entry, | ||
| 197 | }); | ||
| 198 | if (!resolved) { | ||
| 199 | continue; | ||
| 200 | } | ||
| 201 | try { | ||
| 202 | const maxAgeMs = isPartial ? PARTIAL_UPLOAD_MAX_AGE_MS : PENDING_ATTACHMENT_MAX_AGE_MS; | ||
| 203 | if (input.nowMs - NodeFS.statSync(resolved).mtimeMs > maxAgeMs) { | ||
| 204 | NodeFS.unlinkSync(resolved); | ||
| 205 | deleted += 1; | ||
| 206 | } | ||
| 207 | } catch { | ||
| 208 | continue; | ||
| 209 | } | ||
| 210 | } | ||
| 211 | |||
| 212 | return { deleted }; | ||
| 213 | } | ||
| 214 | |||
| 99 | export function parseAttachmentIdFromRelativePath(relativePath: string): string | null { | 215 | export function parseAttachmentIdFromRelativePath(relativePath: string): string | null { |
| 100 | const normalized = normalizeAttachmentRelativePath(relativePath); | 216 | const normalized = normalizeAttachmentRelativePath(relativePath); |
| 101 | if (!normalized || normalized.includes("/")) { | 217 | if (!normalized || normalized.includes("/")) { |
apps/server/src/auth/RpcAuthorization.ts+2| ... | @@ -84,6 +84,8 @@ export const RPC_REQUIRED_SCOPES = { | ... | @@ -84,6 +84,8 @@ export const RPC_REQUIRED_SCOPES = { |
| 84 | [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope, | 84 | [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope, |
| 85 | [WS_METHODS.filesystemBrowse]: AuthOrchestrationReadScope, | 85 | [WS_METHODS.filesystemBrowse]: AuthOrchestrationReadScope, |
| 86 | [WS_METHODS.assetsCreateUrl]: AuthOrchestrationReadScope, | 86 | [WS_METHODS.assetsCreateUrl]: AuthOrchestrationReadScope, |
| 87 | [WS_METHODS.attachmentsCreateUploadUrl]: AuthOrchestrationOperateScope, | ||
| 88 | [WS_METHODS.attachmentsDelete]: AuthOrchestrationOperateScope, | ||
| 87 | [WS_METHODS.providerUploadFeedback]: AuthOrchestrationOperateScope, | 89 | [WS_METHODS.providerUploadFeedback]: AuthOrchestrationOperateScope, |
| 88 | [WS_METHODS.subscribeVcsStatus]: AuthOrchestrationReadScope, | 90 | [WS_METHODS.subscribeVcsStatus]: AuthOrchestrationReadScope, |
| 89 | [WS_METHODS.subscribeResourceTelemetry]: AuthOrchestrationReadScope, | 91 | [WS_METHODS.subscribeResourceTelemetry]: AuthOrchestrationReadScope, |
apps/server/src/config.ts+11| ... | @@ -7,6 +7,7 @@ | ... | @@ -7,6 +7,7 @@ |
| 7 | * @module ServerConfig | 7 | * @module ServerConfig |
| 8 | */ | 8 | */ |
| 9 | import * as Context from "effect/Context"; | 9 | import * as Context from "effect/Context"; |
| 10 | import * as Clock from "effect/Clock"; | ||
| 10 | import * as Effect from "effect/Effect"; | 11 | import * as Effect from "effect/Effect"; |
| 11 | import * as FileSystem from "effect/FileSystem"; | 12 | import * as FileSystem from "effect/FileSystem"; |
| 12 | import * as Layer from "effect/Layer"; | 13 | import * as Layer from "effect/Layer"; |
| ... | @@ -14,6 +15,8 @@ import * as LogLevel from "effect/LogLevel"; | ... | @@ -14,6 +15,8 @@ import * as LogLevel from "effect/LogLevel"; |
| 14 | import * as Path from "effect/Path"; | 15 | import * as Path from "effect/Path"; |
| 15 | import * as Schema from "effect/Schema"; | 16 | import * as Schema from "effect/Schema"; |
| 16 | 17 | ||
| 18 | import { sweepStalePendingAttachments } from "./attachmentStore.ts"; | ||
| 19 | |||
| 17 | export const DEFAULT_PORT = 3773; | 20 | export const DEFAULT_PORT = 3773; |
| 18 | 21 | ||
| 19 | export const RuntimeMode = Schema.Literals(["web", "desktop"]); | 22 | export const RuntimeMode = Schema.Literals(["web", "desktop"]); |
| ... | @@ -152,6 +155,14 @@ export const ensureServerDirectories = Effect.fn(function* (derivedPaths: Server | ... | @@ -152,6 +155,14 @@ export const ensureServerDirectories = Effect.fn(function* (derivedPaths: Server |
| 152 | ], | 155 | ], |
| 153 | { concurrency: "unbounded" }, | 156 | { concurrency: "unbounded" }, |
| 154 | ); | 157 | ); |
| 158 | |||
| 159 | const swept = sweepStalePendingAttachments({ | ||
| 160 | attachmentsDir: derivedPaths.attachmentsDir, | ||
| 161 | nowMs: yield* Clock.currentTimeMillis, | ||
| 162 | }); | ||
| 163 | if (swept.deleted > 0) { | ||
| 164 | yield* Effect.logInfo("Removed expired attachment uploads.", { deleted: swept.deleted }); | ||
| 165 | } | ||
| 155 | }); | 166 | }); |
| 156 | 167 | ||
| 157 | const makeTest = Effect.fn("ServerConfig.makeTest")(function* ( | 168 | const makeTest = Effect.fn("ServerConfig.makeTest")(function* ( |
apps/server/src/environment/ServerEnvironment.test.ts+1| ... | @@ -90,6 +90,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { | ... | @@ -90,6 +90,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { |
| 90 | expect(first.environmentId).toBe(second.environmentId); | 90 | expect(first.environmentId).toBe(second.environmentId); |
| 91 | expect(second.capabilities.repositoryIdentity).toBe(true); | 91 | expect(second.capabilities.repositoryIdentity).toBe(true); |
| 92 | expect(second.capabilities.connectionProbe).toBe(true); | 92 | expect(second.capabilities.connectionProbe).toBe(true); |
| 93 | expect(second.capabilities.attachmentUploads).toBe(true); | ||
| 93 | expect(second.capabilities.pullRequests).toBe(true); | 94 | expect(second.capabilities.pullRequests).toBe(true); |
| 94 | expect(second.capabilities.threadTitleRegeneration).toBe(true); | 95 | expect(second.capabilities.threadTitleRegeneration).toBe(true); |
| 95 | expect(second.capabilities.agentActivityPublishing).toBe(false); | 96 | expect(second.capabilities.agentActivityPublishing).toBe(false); |
apps/server/src/environment/ServerEnvironment.ts+1| ... | @@ -146,6 +146,7 @@ export const make = Effect.gen(function* () { | ... | @@ -146,6 +146,7 @@ export const make = Effect.gen(function* () { |
| 146 | capabilities: { | 146 | capabilities: { |
| 147 | repositoryIdentity: true, | 147 | repositoryIdentity: true, |
| 148 | connectionProbe: true, | 148 | connectionProbe: true, |
| 149 | attachmentUploads: true, | ||
| 149 | pullRequests: true, | 150 | pullRequests: true, |
| 150 | threadSettlement: true, | 151 | threadSettlement: true, |
| 151 | threadSnooze: true, | 152 | threadSnooze: true, |
apps/server/src/http.ts+50| ... | @@ -28,6 +28,11 @@ import { OtlpTracer } from "effect/unstable/observability"; | ... | @@ -28,6 +28,11 @@ import { OtlpTracer } from "effect/unstable/observability"; |
| 28 | 28 | ||
| 29 | import * as ServerConfig from "./config.ts"; | 29 | import * as ServerConfig from "./config.ts"; |
| 30 | import { ASSET_ROUTE_PREFIX, resolveAsset } from "./assets/AssetAccess.ts"; | 30 | import { ASSET_ROUTE_PREFIX, resolveAsset } from "./assets/AssetAccess.ts"; |
| 31 | import { | ||
| 32 | ATTACHMENT_UPLOAD_ROUTE_PREFIX, | ||
| 33 | storeAttachmentUpload, | ||
| 34 | validateAttachmentUploadToken, | ||
| 35 | } from "./assets/AttachmentUpload.ts"; | ||
| 31 | import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts"; | 36 | import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts"; |
| 32 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; | 37 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; |
| 33 | import { traceRelayRequest } from "./cloud/traceRelayRequest.ts"; | 38 | import { traceRelayRequest } from "./cloud/traceRelayRequest.ts"; |
| ... | @@ -230,6 +235,51 @@ export const assetRouteLayer = HttpRouter.add( | ... | @@ -230,6 +235,51 @@ export const assetRouteLayer = HttpRouter.add( |
| 230 | }), | 235 | }), |
| 231 | ); | 236 | ); |
| 232 | 237 | ||
| 238 | export const attachmentUploadRouteLayer = HttpRouter.add( | ||
| 239 | "POST", | ||
| 240 | `${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/*`, | ||
| 241 | Effect.gen(function* () { | ||
| 242 | const request = yield* HttpServerRequest.HttpServerRequest; | ||
| 243 | const url = HttpServerRequest.toURL(request); | ||
| 244 | if (Option.isNone(url)) { | ||
| 245 | return HttpServerResponse.text("Bad Request", { status: 400 }); | ||
| 246 | } | ||
| 247 | |||
| 248 | const token = url.value.pathname.slice(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`.length); | ||
| 249 | if (!token) { | ||
| 250 | return HttpServerResponse.text("Not Found", { status: 404 }); | ||
| 251 | } | ||
| 252 | const claims = yield* validateAttachmentUploadToken(token); | ||
| 253 | if (!claims) { | ||
| 254 | return HttpServerResponse.text("Not Found", { status: 404 }); | ||
| 255 | } | ||
| 256 | |||
| 257 | const contentLengthHeader = request.headers["content-length"]; | ||
| 258 | if ( | ||
| 259 | contentLengthHeader !== undefined && | ||
| 260 | (!Number.isInteger(Number(contentLengthHeader)) || | ||
| 261 | Number(contentLengthHeader) !== claims.sizeBytes) | ||
| 262 | ) { | ||
| 263 | return HttpServerResponse.text("Content-Length must match the upload size.", { | ||
| 264 | status: 400, | ||
| 265 | }); | ||
| 266 | } | ||
| 267 | |||
| 268 | const body = yield* request.arrayBuffer.pipe( | ||
| 269 | Effect.provideService(HttpServerRequest.MaxBodySize, FileSystem.Size(claims.sizeBytes)), | ||
| 270 | Effect.orElseSucceed(() => null), | ||
| 271 | ); | ||
| 272 | if (body === null) { | ||
| 273 | return HttpServerResponse.text("Failed to read the upload body.", { status: 400 }); | ||
| 274 | } | ||
| 275 | |||
| 276 | const stored = yield* storeAttachmentUpload(claims, new Uint8Array(body)); | ||
| 277 | return stored.ok | ||
| 278 | ? HttpServerResponse.empty({ status: 204 }) | ||
| 279 | : HttpServerResponse.text(stored.detail, { status: stored.status }); | ||
| 280 | }), | ||
| 281 | ); | ||
| 282 | |||
| 233 | export const staticAndDevRouteLayer = HttpRouter.add( | 283 | export const staticAndDevRouteLayer = HttpRouter.add( |
| 234 | "GET", | 284 | "GET", |
| 235 | "*", | 285 | "*", |
apps/server/src/orchestration/Normalizer.attachments.test.ts created+318| ... | @@ -0,0 +1,318 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off | ||
| 2 | import * as NodeFS from "node:fs"; | ||
| 3 | import * as NodePath from "node:path"; | ||
| 4 | |||
| 5 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 6 | import { describe, expect, it } from "@effect/vitest"; | ||
| 7 | import { | ||
| 8 | type ClientOrchestrationCommand, | ||
| 9 | CommandId, | ||
| 10 | MessageId, | ||
| 11 | ThreadId, | ||
| 12 | } from "@t3tools/contracts"; | ||
| 13 | import * as Effect from "effect/Effect"; | ||
| 14 | import * as Layer from "effect/Layer"; | ||
| 15 | |||
| 16 | import * as ServerConfig from "../config.ts"; | ||
| 17 | import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; | ||
| 18 | import { cleanupFailedUploadedAttachments, normalizeDispatchCommand } from "./Normalizer.ts"; | ||
| 19 | |||
| 20 | const testLayer = Layer.mergeAll( | ||
| 21 | WorkspacePaths.layer, | ||
| 22 | ServerConfig.layerTest(process.cwd(), { prefix: "t3-normalizer-attachments-" }), | ||
| 23 | ).pipe(Layer.provideMerge(NodeServices.layer)); | ||
| 24 | |||
| 25 | const attachmentUuid = "00000000-0000-4000-8000-0000000000aa"; | ||
| 26 | |||
| 27 | function turnStartCommand(input: { | ||
| 28 | readonly threadId?: string; | ||
| 29 | readonly attachments: ReadonlyArray< | ||
| 30 | | { readonly id: string; readonly sizeBytes: number } | ||
| 31 | | { readonly dataUrl: string; readonly sizeBytes: number } | ||
| 32 | >; | ||
| 33 | }): ClientOrchestrationCommand { | ||
| 34 | return { | ||
| 35 | type: "thread.turn.start", | ||
| 36 | commandId: CommandId.make("command-1"), | ||
| 37 | threadId: ThreadId.make(input.threadId ?? "thread-1"), | ||
| 38 | message: { | ||
| 39 | messageId: MessageId.make("message-1"), | ||
| 40 | role: "user", | ||
| 41 | text: "look at this", | ||
| 42 | attachments: input.attachments.map((attachment) => ({ | ||
| 43 | type: "image" as const, | ||
| 44 | name: "screenshot.png", | ||
| 45 | mimeType: "image/png", | ||
| 46 | ...attachment, | ||
| 47 | })), | ||
| 48 | }, | ||
| 49 | runtimeMode: "full-access", | ||
| 50 | interactionMode: "default", | ||
| 51 | createdAt: "2026-08-01T00:00:00.000Z", | ||
| 52 | }; | ||
| 53 | } | ||
| 54 | |||
| 55 | describe("normalizeDispatchCommand attachments", () => { | ||
| 56 | it.effect("preserves inline image attachments from existing mobile clients", () => | ||
| 57 | Effect.gen(function* () { | ||
| 58 | const config = yield* ServerConfig.ServerConfig; | ||
| 59 | const normalized = yield* normalizeDispatchCommand( | ||
| 60 | turnStartCommand({ | ||
| 61 | attachments: [{ dataUrl: "data:image/png;base64,cGl4ZWxz", sizeBytes: 6 }], | ||
| 62 | }), | ||
| 63 | ); | ||
| 64 | if (normalized.type !== "thread.turn.start") { | ||
| 65 | throw new Error("Expected a thread.turn.start command."); | ||
| 66 | } | ||
| 67 | |||
| 68 | const attachment = normalized.message.attachments[0]!; | ||
| 69 | expect(attachment.id.startsWith("thread-1-")).toBe(true); | ||
| 70 | expect( | ||
| 71 | NodeFS.readFileSync(NodePath.join(config.attachmentsDir, `${attachment.id}.png`)), | ||
| 72 | ).toEqual(Buffer.from("pixels")); | ||
| 73 | }).pipe(Effect.provide(testLayer)), | ||
| 74 | ); | ||
| 75 | |||
| 76 | it.effect("claims uploaded attachments while retaining a retryable pending copy", () => | ||
| 77 | Effect.gen(function* () { | ||
| 78 | const config = yield* ServerConfig.ServerConfig; | ||
| 79 | const bytes = Buffer.from("pixels"); | ||
| 80 | const pendingPath = NodePath.join(config.attachmentsDir, `pending-${attachmentUuid}.png`); | ||
| 81 | NodeFS.writeFileSync(pendingPath, bytes); | ||
| 82 | |||
| 83 | const normalized = yield* normalizeDispatchCommand( | ||
| 84 | turnStartCommand({ | ||
| 85 | attachments: [{ id: `pending-${attachmentUuid}`, sizeBytes: bytes.byteLength }], | ||
| 86 | }), | ||
| 87 | ); | ||
| 88 | if (normalized.type !== "thread.turn.start") { | ||
| 89 | throw new Error("Expected a thread.turn.start command."); | ||
| 90 | } | ||
| 91 | |||
| 92 | const attachmentId = normalized.message.attachments[0]!.id; | ||
| 93 | expect(attachmentId.startsWith("thread-1-")).toBe(true); | ||
| 94 | expect(attachmentId).not.toBe(`thread-1-${attachmentUuid}`); | ||
| 95 | expect(NodeFS.existsSync(pendingPath)).toBe(true); | ||
| 96 | expect(NodeFS.existsSync(NodePath.join(config.attachmentsDir, `${attachmentId}.png`))).toBe( | ||
| 97 | true, | ||
| 98 | ); | ||
| 99 | }).pipe(Effect.provide(testLayer)), | ||
| 100 | ); | ||
| 101 | |||
| 102 | it.effect("normalizes inline and uploaded attachments in the same turn", () => | ||
| 103 | Effect.gen(function* () { | ||
| 104 | const config = yield* ServerConfig.ServerConfig; | ||
| 105 | NodeFS.writeFileSync( | ||
| 106 | NodePath.join(config.attachmentsDir, `pending-${attachmentUuid}.png`), | ||
| 107 | Buffer.from("pixels"), | ||
| 108 | ); | ||
| 109 | |||
| 110 | const normalized = yield* normalizeDispatchCommand( | ||
| 111 | turnStartCommand({ | ||
| 112 | attachments: [ | ||
| 113 | { dataUrl: "data:image/png;base64,cGl4ZWxz", sizeBytes: 6 }, | ||
| 114 | { id: `pending-${attachmentUuid}`, sizeBytes: 6 }, | ||
| 115 | ], | ||
| 116 | }), | ||
| 117 | ); | ||
| 118 | if (normalized.type !== "thread.turn.start") { | ||
| 119 | throw new Error("Expected a thread.turn.start command."); | ||
| 120 | } | ||
| 121 | |||
| 122 | expect(normalized.message.attachments).toHaveLength(2); | ||
| 123 | expect(normalized.message.attachments[1]?.id.startsWith("thread-1-")).toBe(true); | ||
| 124 | }).pipe(Effect.provide(testLayer)), | ||
| 125 | ); | ||
| 126 | |||
| 127 | it.effect("retries a failed bootstrap with a fresh thread id", () => | ||
| 128 | Effect.gen(function* () { | ||
| 129 | const config = yield* ServerConfig.ServerConfig; | ||
| 130 | const bytes = Buffer.from("pixels"); | ||
| 131 | NodeFS.writeFileSync( | ||
| 132 | NodePath.join(config.attachmentsDir, `pending-${attachmentUuid}.png`), | ||
| 133 | bytes, | ||
| 134 | ); | ||
| 135 | |||
| 136 | const first = yield* normalizeDispatchCommand( | ||
| 137 | turnStartCommand({ | ||
| 138 | attachments: [{ id: `pending-${attachmentUuid}`, sizeBytes: bytes.byteLength }], | ||
| 139 | }), | ||
| 140 | ); | ||
| 141 | if (first.type !== "thread.turn.start") { | ||
| 142 | throw new Error("Expected a thread.turn.start command."); | ||
| 143 | } | ||
| 144 | NodeFS.rmSync( | ||
| 145 | NodePath.join(config.attachmentsDir, `${first.message.attachments[0]!.id}.png`), | ||
| 146 | ); | ||
| 147 | |||
| 148 | const retried = yield* normalizeDispatchCommand( | ||
| 149 | turnStartCommand({ | ||
| 150 | threadId: "thread-retry", | ||
| 151 | attachments: [{ id: `pending-${attachmentUuid}`, sizeBytes: bytes.byteLength }], | ||
| 152 | }), | ||
| 153 | ); | ||
| 154 | if (retried.type !== "thread.turn.start") { | ||
| 155 | throw new Error("Expected a thread.turn.start command."); | ||
| 156 | } | ||
| 157 | expect(retried.message.attachments[0]?.id.startsWith("thread-retry-")).toBe(true); | ||
| 158 | }).pipe(Effect.provide(testLayer)), | ||
| 159 | ); | ||
| 160 | |||
| 161 | it.effect("removes failed attachment claims without deleting their pending uploads", () => | ||
| 162 | Effect.gen(function* () { | ||
| 163 | const config = yield* ServerConfig.ServerConfig; | ||
| 164 | const pendingPath = NodePath.join(config.attachmentsDir, `pending-${attachmentUuid}.png`); | ||
| 165 | NodeFS.writeFileSync(pendingPath, Buffer.from("pixels")); | ||
| 166 | const command = turnStartCommand({ | ||
| 167 | attachments: [ | ||
| 168 | { dataUrl: "data:image/png;base64,cGl4ZWxz", sizeBytes: 6 }, | ||
| 169 | { id: `pending-${attachmentUuid}`, sizeBytes: 6 }, | ||
| 170 | ], | ||
| 171 | }); | ||
| 172 | const normalized = yield* normalizeDispatchCommand(command); | ||
| 173 | if (normalized.type !== "thread.turn.start") { | ||
| 174 | throw new Error("Expected a thread.turn.start command."); | ||
| 175 | } | ||
| 176 | |||
| 177 | const inlinePath = NodePath.join( | ||
| 178 | config.attachmentsDir, | ||
| 179 | `${normalized.message.attachments[0]!.id}.png`, | ||
| 180 | ); | ||
| 181 | const claimedPath = NodePath.join( | ||
| 182 | config.attachmentsDir, | ||
| 183 | `${normalized.message.attachments[1]!.id}.png`, | ||
| 184 | ); | ||
| 185 | yield* cleanupFailedUploadedAttachments(command, normalized); | ||
| 186 | |||
| 187 | expect(NodeFS.existsSync(pendingPath)).toBe(true); | ||
| 188 | expect(NodeFS.existsSync(claimedPath)).toBe(false); | ||
| 189 | expect(NodeFS.existsSync(inlinePath)).toBe(true); | ||
| 190 | }).pipe(Effect.provide(testLayer)), | ||
| 191 | ); | ||
| 192 | |||
| 193 | it.effect("removes a failed claimed copy after its pending original was removed", () => | ||
| 194 | Effect.gen(function* () { | ||
| 195 | const config = yield* ServerConfig.ServerConfig; | ||
| 196 | const pendingPath = NodePath.join(config.attachmentsDir, `pending-${attachmentUuid}.png`); | ||
| 197 | NodeFS.writeFileSync(pendingPath, Buffer.from("pixels")); | ||
| 198 | const command = turnStartCommand({ | ||
| 199 | attachments: [{ id: `pending-${attachmentUuid}`, sizeBytes: 6 }], | ||
| 200 | }); | ||
| 201 | const normalized = yield* normalizeDispatchCommand(command); | ||
| 202 | if (normalized.type !== "thread.turn.start") { | ||
| 203 | throw new Error("Expected a thread.turn.start command."); | ||
| 204 | } | ||
| 205 | |||
| 206 | const claimedPath = NodePath.join( | ||
| 207 | config.attachmentsDir, | ||
| 208 | `${normalized.message.attachments[0]!.id}.png`, | ||
| 209 | ); | ||
| 210 | NodeFS.rmSync(pendingPath); | ||
| 211 | |||
| 212 | yield* cleanupFailedUploadedAttachments(command, normalized); | ||
| 213 | |||
| 214 | expect(NodeFS.existsSync(claimedPath)).toBe(false); | ||
| 215 | }).pipe(Effect.provide(testLayer)), | ||
| 216 | ); | ||
| 217 | |||
| 218 | it.effect("keeps concurrent claims independent when one dispatch fails", () => | ||
| 219 | Effect.gen(function* () { | ||
| 220 | const config = yield* ServerConfig.ServerConfig; | ||
| 221 | const pendingPath = NodePath.join(config.attachmentsDir, `pending-${attachmentUuid}.png`); | ||
| 222 | NodeFS.writeFileSync(pendingPath, Buffer.from("pixels")); | ||
| 223 | const command = turnStartCommand({ | ||
| 224 | attachments: [{ id: `pending-${attachmentUuid}`, sizeBytes: 6 }], | ||
| 225 | }); | ||
| 226 | |||
| 227 | const [failed, succeeded] = yield* Effect.all( | ||
| 228 | [normalizeDispatchCommand(command), normalizeDispatchCommand(command)], | ||
| 229 | { concurrency: 2 }, | ||
| 230 | ); | ||
| 231 | if (failed.type !== "thread.turn.start" || succeeded.type !== "thread.turn.start") { | ||
| 232 | throw new Error("Expected thread.turn.start commands."); | ||
| 233 | } | ||
| 234 | |||
| 235 | const failedPath = NodePath.join( | ||
| 236 | config.attachmentsDir, | ||
| 237 | `${failed.message.attachments[0]!.id}.png`, | ||
| 238 | ); | ||
| 239 | const succeededPath = NodePath.join( | ||
| 240 | config.attachmentsDir, | ||
| 241 | `${succeeded.message.attachments[0]!.id}.png`, | ||
| 242 | ); | ||
| 243 | expect(failedPath).not.toBe(succeededPath); | ||
| 244 | |||
| 245 | yield* cleanupFailedUploadedAttachments(command, failed); | ||
| 246 | |||
| 247 | expect(NodeFS.existsSync(pendingPath)).toBe(true); | ||
| 248 | expect(NodeFS.existsSync(failedPath)).toBe(false); | ||
| 249 | expect(NodeFS.existsSync(succeededPath)).toBe(true); | ||
| 250 | }).pipe(Effect.provide(testLayer)), | ||
| 251 | ); | ||
| 252 | |||
| 253 | it.effect("removes earlier claimed copies when a later attachment cannot be normalized", () => | ||
| 254 | Effect.gen(function* () { | ||
| 255 | const config = yield* ServerConfig.ServerConfig; | ||
| 256 | const pendingId = `pending-${attachmentUuid}`; | ||
| 257 | const pendingPath = NodePath.join(config.attachmentsDir, `${pendingId}.png`); | ||
| 258 | NodeFS.writeFileSync(pendingPath, Buffer.from("pixels")); | ||
| 259 | |||
| 260 | const failure = yield* normalizeDispatchCommand( | ||
| 261 | turnStartCommand({ | ||
| 262 | attachments: [ | ||
| 263 | { id: pendingId, sizeBytes: 6 }, | ||
| 264 | { | ||
| 265 | id: "pending-00000000-0000-4000-8000-0000000000ff", | ||
| 266 | sizeBytes: 6, | ||
| 267 | }, | ||
| 268 | ], | ||
| 269 | }), | ||
| 270 | ).pipe(Effect.flip); | ||
| 271 | |||
| 272 | expect(failure.message).toContain("not found"); | ||
| 273 | expect(NodeFS.readdirSync(config.attachmentsDir)).toEqual([`${pendingId}.png`]); | ||
| 274 | }).pipe(Effect.provide(testLayer)), | ||
| 275 | ); | ||
| 276 | |||
| 277 | it.effect("rejects uploaded attachments with the wrong size or thread", () => | ||
| 278 | Effect.gen(function* () { | ||
| 279 | const config = yield* ServerConfig.ServerConfig; | ||
| 280 | NodeFS.writeFileSync( | ||
| 281 | NodePath.join(config.attachmentsDir, `pending-${attachmentUuid}.png`), | ||
| 282 | Buffer.from("pixels"), | ||
| 283 | ); | ||
| 284 | |||
| 285 | const wrongSize = yield* normalizeDispatchCommand( | ||
| 286 | turnStartCommand({ | ||
| 287 | attachments: [{ id: `pending-${attachmentUuid}`, sizeBytes: 999 }], | ||
| 288 | }), | ||
| 289 | ).pipe(Effect.flip); | ||
| 290 | expect(wrongSize.message).toContain("size"); | ||
| 291 | |||
| 292 | const wrongThread = yield* normalizeDispatchCommand( | ||
| 293 | turnStartCommand({ | ||
| 294 | attachments: [{ id: `another-thread-${attachmentUuid}`, sizeBytes: 6 }], | ||
| 295 | }), | ||
| 296 | ).pipe(Effect.flip); | ||
| 297 | expect(wrongThread.message).toContain("pending upload"); | ||
| 298 | |||
| 299 | const mismatchedTypeCommand = turnStartCommand({ | ||
| 300 | attachments: [{ id: `pending-${attachmentUuid}`, sizeBytes: 6 }], | ||
| 301 | }); | ||
| 302 | if (mismatchedTypeCommand.type !== "thread.turn.start") { | ||
| 303 | throw new Error("Expected a thread.turn.start command."); | ||
| 304 | } | ||
| 305 | const mismatchedType = yield* normalizeDispatchCommand({ | ||
| 306 | ...mismatchedTypeCommand, | ||
| 307 | message: { | ||
| 308 | ...mismatchedTypeCommand.message, | ||
| 309 | attachments: mismatchedTypeCommand.message.attachments.map((attachment) => ({ | ||
| 310 | ...attachment, | ||
| 311 | mimeType: "image/jpeg", | ||
| 312 | })), | ||
| 313 | }, | ||
| 314 | }).pipe(Effect.flip); | ||
| 315 | expect(mismatchedType.message).toContain("image type"); | ||
| 316 | }).pipe(Effect.provide(testLayer)), | ||
| 317 | ); | ||
| 318 | }); | ||
apps/server/src/orchestration/Normalizer.ts+120-2| ... | @@ -10,7 +10,13 @@ import { | ... | @@ -10,7 +10,13 @@ import { |
| 10 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, | 10 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, |
| 11 | } from "@t3tools/contracts"; | 11 | } from "@t3tools/contracts"; |
| 12 | 12 | ||
| 13 | import { createAttachmentId, resolveAttachmentPath } from "../attachmentStore.ts"; | 13 | import { |
| 14 | createAttachmentId, | ||
| 15 | planAttachmentClaim, | ||
| 16 | PENDING_ATTACHMENT_THREAD_SEGMENT, | ||
| 17 | parseThreadSegmentFromAttachmentId, | ||
| 18 | resolveAttachmentPath, | ||
| 19 | } from "../attachmentStore.ts"; | ||
| 14 | import { ServerConfig } from "../config.ts"; | 20 | import { ServerConfig } from "../config.ts"; |
| 15 | import { parseBase64DataUrl } from "../imageMime.ts"; | 21 | import { parseBase64DataUrl } from "../imageMime.ts"; |
| 16 | import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; | 22 | import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; |
| ... | @@ -43,6 +49,29 @@ export const canonicalizeClientCommandTimestamps = ( | ... | @@ -43,6 +49,29 @@ export const canonicalizeClientCommandTimestamps = ( |
| 43 | }; | 49 | }; |
| 44 | }; | 50 | }; |
| 45 | 51 | ||
| 52 | const removeClaimedAttachmentPaths = Effect.fn("Normalizer.removeClaimedAttachmentPaths")( | ||
| 53 | function* (attachmentPaths: ReadonlyArray<string>) { | ||
| 54 | if (attachmentPaths.length === 0) { | ||
| 55 | return; | ||
| 56 | } | ||
| 57 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 58 | yield* Effect.forEach( | ||
| 59 | attachmentPaths, | ||
| 60 | (attachmentPath) => | ||
| 61 | fileSystem.remove(attachmentPath, { force: true }).pipe( | ||
| 62 | Effect.tapError((cause) => | ||
| 63 | Effect.logWarning("Failed to remove an unclaimed attachment copy.", { | ||
| 64 | attachmentPath, | ||
| 65 | cause, | ||
| 66 | }), | ||
| 67 | ), | ||
| 68 | Effect.orElseSucceed(() => undefined), | ||
| 69 | ), | ||
| 70 | { concurrency: 1 }, | ||
| 71 | ); | ||
| 72 | }, | ||
| 73 | ); | ||
| 74 | |||
| 46 | export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => | 75 | export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => |
| 47 | Effect.gen(function* () { | 76 | Effect.gen(function* () { |
| 48 | const receivedAt = DateTime.formatIso(yield* DateTime.now); | 77 | const receivedAt = DateTime.formatIso(yield* DateTime.now); |
| ... | @@ -104,10 +133,69 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => | ... | @@ -104,10 +133,69 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => |
| 104 | return canonicalCommand as OrchestrationCommand; | 133 | return canonicalCommand as OrchestrationCommand; |
| 105 | } | 134 | } |
| 106 | 135 | ||
| 136 | const claimedAttachmentPaths: string[] = []; | ||
| 107 | const normalizedAttachments = yield* Effect.forEach( | 137 | const normalizedAttachments = yield* Effect.forEach( |
| 108 | canonicalCommand.message.attachments, | 138 | canonicalCommand.message.attachments, |
| 109 | (attachment) => | 139 | (attachment) => |
| 110 | Effect.gen(function* () { | 140 | Effect.gen(function* () { |
| 141 | if (!("dataUrl" in attachment)) { | ||
| 142 | const claim = planAttachmentClaim({ | ||
| 143 | attachmentsDir: serverConfig.attachmentsDir, | ||
| 144 | threadId: canonicalCommand.threadId, | ||
| 145 | attachmentId: attachment.id, | ||
| 146 | }); | ||
| 147 | if (!claim.ok) { | ||
| 148 | return yield* new OrchestrationDispatchCommandError({ | ||
| 149 | message: `Attachment '${attachment.name}' cannot be sent: ${claim.reason}.`, | ||
| 150 | }); | ||
| 151 | } | ||
| 152 | |||
| 153 | const info = yield* fileSystem.stat(claim.currentPath).pipe( | ||
| 154 | Effect.mapError( | ||
| 155 | (cause) => | ||
| 156 | new OrchestrationDispatchCommandError({ | ||
| 157 | message: `Attachment '${attachment.name}' cannot be sent: attachment not found.`, | ||
| 158 | cause, | ||
| 159 | }), | ||
| 160 | ), | ||
| 161 | ); | ||
| 162 | if (Number(info.size) !== attachment.sizeBytes) { | ||
| 163 | return yield* new OrchestrationDispatchCommandError({ | ||
| 164 | message: `Attachment '${attachment.name}' cannot be sent: stored size does not match.`, | ||
| 165 | }); | ||
| 166 | } | ||
| 167 | |||
| 168 | const normalizedAttachment = { | ||
| 169 | ...attachment, | ||
| 170 | id: claim.finalId, | ||
| 171 | mimeType: attachment.mimeType.toLowerCase(), | ||
| 172 | }; | ||
| 173 | const expectedPath = resolveAttachmentPath({ | ||
| 174 | attachmentsDir: serverConfig.attachmentsDir, | ||
| 175 | attachment: normalizedAttachment, | ||
| 176 | }); | ||
| 177 | if (expectedPath !== claim.finalPath) { | ||
| 178 | return yield* new OrchestrationDispatchCommandError({ | ||
| 179 | message: `Attachment '${attachment.name}' cannot be sent: image type does not match the upload.`, | ||
| 180 | }); | ||
| 181 | } | ||
| 182 | |||
| 183 | // Keep the pending copy until the turn succeeds. A failed thread | ||
| 184 | // bootstrap can then retry with a fresh thread id. | ||
| 185 | yield* fileSystem.copyFile(claim.currentPath, claim.finalPath).pipe( | ||
| 186 | Effect.mapError( | ||
| 187 | (cause) => | ||
| 188 | new OrchestrationDispatchCommandError({ | ||
| 189 | message: `Failed to claim attachment '${attachment.name}' for this thread.`, | ||
| 190 | cause, | ||
| 191 | }), | ||
| 192 | ), | ||
| 193 | ); | ||
| 194 | claimedAttachmentPaths.push(claim.finalPath); | ||
| 195 | |||
| 196 | return normalizedAttachment; | ||
| 197 | } | ||
| 198 | |||
| 111 | const parsed = parseBase64DataUrl(attachment.dataUrl); | 199 | const parsed = parseBase64DataUrl(attachment.dataUrl); |
| 112 | if (!parsed || !parsed.mimeType.startsWith("image/")) { | 200 | if (!parsed || !parsed.mimeType.startsWith("image/")) { |
| 113 | return yield* new OrchestrationDispatchCommandError({ | 201 | return yield* new OrchestrationDispatchCommandError({ |
| ... | @@ -167,7 +255,7 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => | ... | @@ -167,7 +255,7 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => |
| 167 | return persistedAttachment; | 255 | return persistedAttachment; |
| 168 | }), | 256 | }), |
| 169 | { concurrency: 1 }, | 257 | { concurrency: 1 }, |
| 170 | ); | 258 | ).pipe(Effect.tapError(() => removeClaimedAttachmentPaths(claimedAttachmentPaths))); |
| 171 | 259 | ||
| 172 | return { | 260 | return { |
| 173 | ...canonicalCommand, | 261 | ...canonicalCommand, |
| ... | @@ -177,3 +265,33 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => | ... | @@ -177,3 +265,33 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => |
| 177 | }, | 265 | }, |
| 178 | } satisfies OrchestrationCommand; | 266 | } satisfies OrchestrationCommand; |
| 179 | }); | 267 | }); |
| 268 | |||
| 269 | export const cleanupFailedUploadedAttachments = Effect.fn( | ||
| 270 | "Normalizer.cleanupFailedUploadedAttachments", | ||
| 271 | )(function* (command: ClientOrchestrationCommand, normalizedCommand: OrchestrationCommand) { | ||
| 272 | if (command.type !== "thread.turn.start" || normalizedCommand.type !== "thread.turn.start") { | ||
| 273 | return; | ||
| 274 | } | ||
| 275 | |||
| 276 | const serverConfig = yield* ServerConfig; | ||
| 277 | const claimedPaths: string[] = []; | ||
| 278 | for (const [index, attachment] of normalizedCommand.message.attachments.entries()) { | ||
| 279 | const original = command.message.attachments[index]; | ||
| 280 | if ( | ||
| 281 | !original || | ||
| 282 | "dataUrl" in original || | ||
| 283 | parseThreadSegmentFromAttachmentId(original.id) !== PENDING_ATTACHMENT_THREAD_SEGMENT | ||
| 284 | ) { | ||
| 285 | continue; | ||
| 286 | } | ||
| 287 | |||
| 288 | const claimedPath = resolveAttachmentPath({ | ||
| 289 | attachmentsDir: serverConfig.attachmentsDir, | ||
| 290 | attachment, | ||
| 291 | }); | ||
| 292 | if (claimedPath) { | ||
| 293 | claimedPaths.push(claimedPath); | ||
| 294 | } | ||
| 295 | } | ||
| 296 | yield* removeClaimedAttachmentPaths(claimedPaths); | ||
| 297 | }); |
apps/server/src/orchestration/http.ts+9-8| ... | @@ -8,7 +8,7 @@ import * as Option from "effect/Option"; | ... | @@ -8,7 +8,7 @@ import * as Option from "effect/Option"; |
| 8 | import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; | 8 | import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; |
| 9 | 9 | ||
| 10 | import { projectThreadDetailSnapshot } from "./ActivityPayloadProjection.ts"; | 10 | import { projectThreadDetailSnapshot } from "./ActivityPayloadProjection.ts"; |
| 11 | import { normalizeDispatchCommand } from "./Normalizer.ts"; | 11 | import { cleanupFailedUploadedAttachments, normalizeDispatchCommand } from "./Normalizer.ts"; |
| 12 | import { | 12 | import { |
| 13 | annotateEnvironmentRequest, | 13 | annotateEnvironmentRequest, |
| 14 | failEnvironmentInternal, | 14 | failEnvironmentInternal, |
| ... | @@ -96,13 +96,14 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group( | ... | @@ -96,13 +96,14 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group( |
| 96 | const normalizedCommand = yield* normalizeDispatchCommand(args.payload).pipe( | 96 | const normalizedCommand = yield* normalizeDispatchCommand(args.payload).pipe( |
| 97 | Effect.catch(() => failEnvironmentInvalidRequest("invalid_command")), | 97 | Effect.catch(() => failEnvironmentInvalidRequest("invalid_command")), |
| 98 | ); | 98 | ); |
| 99 | return yield* orchestrationEngine | 99 | return yield* orchestrationEngine.dispatch(normalizedCommand).pipe( |
| 100 | .dispatch(normalizedCommand) | 100 | Effect.tapError(() => |
| 101 | .pipe( | 101 | cleanupFailedUploadedAttachments(args.payload, normalizedCommand), |
| 102 | Effect.catch((cause) => | 102 | ), |
| 103 | failEnvironmentInternal("orchestration_dispatch_failed", cause), | 103 | Effect.catch((cause) => |
| 104 | ), | 104 | failEnvironmentInternal("orchestration_dispatch_failed", cause), |
| 105 | ); | 105 | ), |
| 106 | ); | ||
| 106 | }), | 107 | }), |
| 107 | ); | 108 | ); |
| 108 | }), | 109 | }), |
apps/server/src/server.test.ts+111-31| ... | @@ -4486,6 +4486,55 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -4486,6 +4486,55 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 4486 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 4486 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 4487 | ); | 4487 | ); |
| 4488 | 4488 | ||
| 4489 | it.effect("uploads image bytes through a signed URL issued by websocket rpc", () => | ||
| 4490 | Effect.gen(function* () { | ||
| 4491 | const config = yield* buildAppUnderTest(); | ||
| 4492 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 4493 | const path = yield* Path.Path; | ||
| 4494 | const wsUrl = yield* getWsServerUrl("/ws"); | ||
| 4495 | |||
| 4496 | yield* Effect.scoped( | ||
| 4497 | withWsRpcClient(wsUrl, (client) => | ||
| 4498 | Effect.gen(function* () { | ||
| 4499 | const issued = yield* client[WS_METHODS.attachmentsCreateUploadUrl]({ | ||
| 4500 | name: "screenshot.png", | ||
| 4501 | mimeType: "image/png", | ||
| 4502 | sizeBytes: 6, | ||
| 4503 | }); | ||
| 4504 | const rejected = yield* HttpClient.post(issued.relativeUrl, { | ||
| 4505 | body: HttpBody.uint8Array(new Uint8Array([1, 2, 3]), "image/png"), | ||
| 4506 | }); | ||
| 4507 | assert.equal(rejected.status, 400); | ||
| 4508 | |||
| 4509 | const response = yield* HttpClient.post(issued.relativeUrl, { | ||
| 4510 | headers: { origin: crossOriginClientOrigin }, | ||
| 4511 | body: HttpBody.uint8Array(new Uint8Array([1, 2, 3, 4, 5, 6]), "image/png"), | ||
| 4512 | }); | ||
| 4513 | assert.equal(response.status, 204); | ||
| 4514 | assertBrowserApiCorsResponseHeaders(response.headers); | ||
| 4515 | |||
| 4516 | const attachmentPath = path.join(config.attachmentsDir, `${issued.attachmentId}.png`); | ||
| 4517 | assert.isTrue(yield* fileSystem.exists(attachmentPath)); | ||
| 4518 | |||
| 4519 | yield* client[WS_METHODS.attachmentsDelete]({ attachmentId: issued.attachmentId }); | ||
| 4520 | assert.isFalse(yield* fileSystem.exists(attachmentPath)); | ||
| 4521 | |||
| 4522 | const streamed = yield* client[WS_METHODS.attachmentsCreateUploadUrl]({ | ||
| 4523 | name: "streamed.png", | ||
| 4524 | mimeType: "image/png", | ||
| 4525 | sizeBytes: 6, | ||
| 4526 | }); | ||
| 4527 | const streamedResponse = yield* HttpClient.post(streamed.relativeUrl, { | ||
| 4528 | body: HttpBody.stream(Stream.make(new Uint8Array([1, 2, 3, 4, 5, 6])), "image/png"), | ||
| 4529 | }); | ||
| 4530 | assert.equal(streamedResponse.status, 204); | ||
| 4531 | yield* client[WS_METHODS.attachmentsDelete]({ attachmentId: streamed.attachmentId }); | ||
| 4532 | }), | ||
| 4533 | ), | ||
| 4534 | ); | ||
| 4535 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 4536 | ); | ||
| 4537 | |||
| 4489 | it.effect("keeps feedback errors structured across websocket rpc", () => | 4538 | it.effect("keeps feedback errors structured across websocket rpc", () => |
| 4490 | Effect.gen(function* () { | 4539 | Effect.gen(function* () { |
| 4491 | const threadId = ThreadId.make("thread-feedback-failure"); | 4540 | const threadId = ThreadId.make("thread-feedback-failure"); |
| ... | @@ -7993,12 +8042,14 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -7993,12 +8042,14 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 7993 | it.effect("cleans up created bootstrap threads when worktree creation defects", () => | 8042 | it.effect("cleans up created bootstrap threads when worktree creation defects", () => |
| 7994 | Effect.gen(function* () { | 8043 | Effect.gen(function* () { |
| 7995 | const dispatchedCommands: Array<OrchestrationCommand> = []; | 8044 | const dispatchedCommands: Array<OrchestrationCommand> = []; |
| 8045 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 8046 | const path = yield* Path.Path; | ||
| 7996 | const createWorktree = vi.fn( | 8047 | const createWorktree = vi.fn( |
| 7997 | (_: Parameters<GitVcsDriver.GitVcsDriver["Service"]["createWorktree"]>[0]) => | 8048 | (_: Parameters<GitVcsDriver.GitVcsDriver["Service"]["createWorktree"]>[0]) => |
| 7998 | Effect.die(new Error("worktree exploded")), | 8049 | Effect.die(new Error("worktree exploded")), |
| 7999 | ); | 8050 | ); |
| 8000 | 8051 | ||
| 8001 | yield* buildAppUnderTest({ | 8052 | const config = yield* buildAppUnderTest({ |
| 8002 | layers: { | 8053 | layers: { |
| 8003 | gitVcsDriver: { | 8054 | gitVcsDriver: { |
| 8004 | createWorktree, | 8055 | createWorktree, |
| ... | @@ -8016,40 +8067,62 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -8016,40 +8067,62 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 8016 | 8067 | ||
| 8017 | const createdAt = "2026-01-01T00:00:00.000Z"; | 8068 | const createdAt = "2026-01-01T00:00:00.000Z"; |
| 8018 | const wsUrl = yield* getWsServerUrl("/ws"); | 8069 | const wsUrl = yield* getWsServerUrl("/ws"); |
| 8070 | let pendingAttachmentId: string | undefined; | ||
| 8019 | const result = yield* Effect.scoped( | 8071 | const result = yield* Effect.scoped( |
| 8020 | withWsRpcClient(wsUrl, (client) => | 8072 | withWsRpcClient(wsUrl, (client) => |
| 8021 | client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ | 8073 | Effect.gen(function* () { |
| 8022 | type: "thread.turn.start", | 8074 | const upload = yield* client[WS_METHODS.attachmentsCreateUploadUrl]({ |
| 8023 | commandId: CommandId.make("cmd-bootstrap-turn-start-defect"), | 8075 | name: "screenshot.png", |
| 8024 | threadId: ThreadId.make("thread-bootstrap-defect"), | 8076 | mimeType: "image/png", |
| 8025 | message: { | 8077 | sizeBytes: 6, |
| 8026 | messageId: MessageId.make("msg-bootstrap-defect"), | 8078 | }); |
| 8027 | role: "user", | 8079 | pendingAttachmentId = upload.attachmentId; |
| 8028 | text: "hello", | 8080 | const uploadResponse = yield* HttpClient.post(upload.relativeUrl, { |
| 8029 | attachments: [], | 8081 | body: HttpBody.uint8Array(new Uint8Array([1, 2, 3, 4, 5, 6]), "image/png"), |
| 8030 | }, | 8082 | }); |
| 8031 | modelSelection: defaultModelSelection, | 8083 | assert.equal(uploadResponse.status, 204); |
| 8032 | runtimeMode: "full-access", | 8084 | |
| 8033 | interactionMode: "default", | 8085 | return yield* client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ |
| 8034 | bootstrap: { | 8086 | type: "thread.turn.start", |
| 8035 | createThread: { | 8087 | commandId: CommandId.make("cmd-bootstrap-turn-start-defect"), |
| 8036 | projectId: defaultProjectId, | 8088 | threadId: ThreadId.make("thread-bootstrap-defect"), |
| 8037 | title: "Bootstrap Thread", | 8089 | message: { |
| 8038 | modelSelection: defaultModelSelection, | 8090 | messageId: MessageId.make("msg-bootstrap-defect"), |
| 8039 | runtimeMode: "full-access", | 8091 | role: "user", |
| 8040 | interactionMode: "default", | 8092 | text: "hello", |
| 8041 | branch: "main", | 8093 | attachments: [ |
| 8042 | worktreePath: null, | 8094 | { |
| 8043 | createdAt, | 8095 | type: "image", |
| 8096 | id: upload.attachmentId, | ||
| 8097 | name: "screenshot.png", | ||
| 8098 | mimeType: "image/png", | ||
| 8099 | sizeBytes: 6, | ||
| 8100 | }, | ||
| 8101 | ], | ||
| 8044 | }, | 8102 | }, |
| 8045 | prepareWorktree: { | 8103 | modelSelection: defaultModelSelection, |
| 8046 | projectCwd: "/tmp/project", | 8104 | runtimeMode: "full-access", |
| 8047 | baseBranch: "main", | 8105 | interactionMode: "default", |
| 8048 | branch: "t3code/bootstrap-refName", | 8106 | bootstrap: { |
| 8107 | createThread: { | ||
| 8108 | projectId: defaultProjectId, | ||
| 8109 | title: "Bootstrap Thread", | ||
| 8110 | modelSelection: defaultModelSelection, | ||
| 8111 | runtimeMode: "full-access", | ||
| 8112 | interactionMode: "default", | ||
| 8113 | branch: "main", | ||
| 8114 | worktreePath: null, | ||
| 8115 | createdAt, | ||
| 8116 | }, | ||
| 8117 | prepareWorktree: { | ||
| 8118 | projectCwd: "/tmp/project", | ||
| 8119 | baseBranch: "main", | ||
| 8120 | branch: "t3code/bootstrap-refName", | ||
| 8121 | }, | ||
| 8122 | runSetupScript: false, | ||
| 8049 | }, | 8123 | }, |
| 8050 | runSetupScript: false, | 8124 | createdAt, |
| 8051 | }, | 8125 | }); |
| 8052 | createdAt, | ||
| 8053 | }), | 8126 | }), |
| 8054 | ).pipe(Effect.result), | 8127 | ).pipe(Effect.result), |
| 8055 | ); | 8128 | ); |
| ... | @@ -8062,6 +8135,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -8062,6 +8135,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 8062 | dispatchedCommands.map((command) => command.type), | 8135 | dispatchedCommands.map((command) => command.type), |
| 8063 | ["thread.create", "thread.delete"], | 8136 | ["thread.create", "thread.delete"], |
| 8064 | ); | 8137 | ); |
| 8138 | assert.isDefined(pendingAttachmentId); | ||
| 8139 | assert.isTrue( | ||
| 8140 | yield* fileSystem.exists(path.join(config.attachmentsDir, `${pendingAttachmentId}.png`)), | ||
| 8141 | ); | ||
| 8142 | assert.deepEqual(yield* fileSystem.readDirectory(config.attachmentsDir), [ | ||
| 8143 | `${pendingAttachmentId}.png`, | ||
| 8144 | ]); | ||
| 8065 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 8145 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 8066 | ); | 8146 | ); |
| 8067 | 8147 |
apps/server/src/server.ts+2| ... | @@ -13,6 +13,7 @@ import * as ServerConfig from "./config.ts"; | ... | @@ -13,6 +13,7 @@ import * as ServerConfig from "./config.ts"; |
| 13 | import { | 13 | import { |
| 14 | otlpTracesProxyRouteLayer, | 14 | otlpTracesProxyRouteLayer, |
| 15 | assetRouteLayer, | 15 | assetRouteLayer, |
| 16 | attachmentUploadRouteLayer, | ||
| 16 | serverEnvironmentHttpApiLayer, | 17 | serverEnvironmentHttpApiLayer, |
| 17 | staticAndDevRouteLayer, | 18 | staticAndDevRouteLayer, |
| 18 | browserApiCorsLayer, | 19 | browserApiCorsLayer, |
| ... | @@ -456,6 +457,7 @@ export const makeRoutesLayer = Layer.mergeAll( | ... | @@ -456,6 +457,7 @@ export const makeRoutesLayer = Layer.mergeAll( |
| 456 | ), | 457 | ), |
| 457 | otlpTracesProxyRouteLayer, | 458 | otlpTracesProxyRouteLayer, |
| 458 | assetRouteLayer, | 459 | assetRouteLayer, |
| 460 | attachmentUploadRouteLayer, | ||
| 459 | staticAndDevRouteLayer, | 461 | staticAndDevRouteLayer, |
| 460 | websocketRpcRouteLayer, | 462 | websocketRpcRouteLayer, |
| 461 | ), | 463 | ), |
apps/server/src/ws.ts+18-2| ... | @@ -73,7 +73,10 @@ import { | ... | @@ -73,7 +73,10 @@ import { |
| 73 | projectActivityEvent, | 73 | projectActivityEvent, |
| 74 | projectThreadDetailSnapshot, | 74 | projectThreadDetailSnapshot, |
| 75 | } from "./orchestration/ActivityPayloadProjection.ts"; | 75 | } from "./orchestration/ActivityPayloadProjection.ts"; |
| 76 | import { normalizeDispatchCommand } from "./orchestration/Normalizer.ts"; | 76 | import { |
| 77 | cleanupFailedUploadedAttachments, | ||
| 78 | normalizeDispatchCommand, | ||
| 79 | } from "./orchestration/Normalizer.ts"; | ||
| 77 | import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; | 80 | import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; |
| 78 | import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; | 81 | import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; |
| 79 | import { | 82 | import { |
| ... | @@ -92,6 +95,7 @@ import * as TerminalManager from "./terminal/Manager.ts"; | ... | @@ -92,6 +95,7 @@ import * as TerminalManager from "./terminal/Manager.ts"; |
| 92 | import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; | 95 | import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; |
| 93 | import * as PreviewManager from "./preview/Manager.ts"; | 96 | import * as PreviewManager from "./preview/Manager.ts"; |
| 94 | import { issueAssetUrl } from "./assets/AssetAccess.ts"; | 97 | import { issueAssetUrl } from "./assets/AssetAccess.ts"; |
| 98 | import { deletePendingAttachment, issueAttachmentUploadUrl } from "./assets/AttachmentUpload.ts"; | ||
| 95 | import * as PortScanner from "./preview/PortScanner.ts"; | 99 | import * as PortScanner from "./preview/PortScanner.ts"; |
| 96 | import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; | 100 | import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; |
| 97 | import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; | 101 | import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; |
| ... | @@ -1159,7 +1163,9 @@ const makeWsRpcLayer = ( | ... | @@ -1159,7 +1163,9 @@ const makeWsRpcLayer = ( |
| 1159 | ), | 1163 | ), |
| 1160 | ) | 1164 | ) |
| 1161 | : false; | 1165 | : false; |
| 1162 | const result = yield* dispatchNormalizedCommand(normalizedCommand); | 1166 | const result = yield* dispatchNormalizedCommand(normalizedCommand).pipe( |
| 1167 | Effect.tapError(() => cleanupFailedUploadedAttachments(command, normalizedCommand)), | ||
| 1168 | ); | ||
| 1163 | yield* recordClientCommandAnalytics(normalizedCommand); | 1169 | yield* recordClientCommandAnalytics(normalizedCommand); |
| 1164 | if (parkingCommand) { | 1170 | if (parkingCommand) { |
| 1165 | const parkingKind = parkingCommand.type === "thread.archive" ? "archive" : "settle"; | 1171 | const parkingKind = parkingCommand.type === "thread.archive" ? "archive" : "settle"; |
| ... | @@ -1951,6 +1957,16 @@ const makeWsRpcLayer = ( | ... | @@ -1951,6 +1957,16 @@ const makeWsRpcLayer = ( |
| 1951 | ), | 1957 | ), |
| 1952 | { "rpc.aggregate": "workspace" }, | 1958 | { "rpc.aggregate": "workspace" }, |
| 1953 | ), | 1959 | ), |
| 1960 | [WS_METHODS.attachmentsCreateUploadUrl]: (input) => | ||
| 1961 | observeRpcEffect(WS_METHODS.attachmentsCreateUploadUrl, issueAttachmentUploadUrl(input), { | ||
| 1962 | "rpc.aggregate": "workspace", | ||
| 1963 | }), | ||
| 1964 | [WS_METHODS.attachmentsDelete]: (input) => | ||
| 1965 | observeRpcEffect( | ||
| 1966 | WS_METHODS.attachmentsDelete, | ||
| 1967 | deletePendingAttachment(input.attachmentId), | ||
| 1968 | { "rpc.aggregate": "workspace" }, | ||
| 1969 | ), | ||
| 1954 | [WS_METHODS.assetsCreateUrl]: (input) => | 1970 | [WS_METHODS.assetsCreateUrl]: (input) => |
| 1955 | observeRpcEffect( | 1971 | observeRpcEffect( |
| 1956 | WS_METHODS.assetsCreateUrl, | 1972 | WS_METHODS.assetsCreateUrl, |
apps/web/src/components/ChatView.tsx+44-8| ... | @@ -352,6 +352,12 @@ import { | ... | @@ -352,6 +352,12 @@ import { |
| 352 | import type { ThreadSyncPhase } from "../threadSync"; | 352 | import type { ThreadSyncPhase } from "../threadSync"; |
| 353 | import { useLocalStorage } from "~/hooks/useLocalStorage"; | 353 | import { useLocalStorage } from "~/hooks/useLocalStorage"; |
| 354 | import { useComposerHandleContext } from "../composerHandleContext"; | 354 | import { useComposerHandleContext } from "../composerHandleContext"; |
| 355 | import { | ||
| 356 | awaitAttachmentUploads, | ||
| 357 | getUploadedAttachments, | ||
| 358 | releaseAttachmentUploads, | ||
| 359 | startAttachmentUpload, | ||
| 360 | } from "../lib/attachmentUploadQueue"; | ||
| 355 | import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; | 361 | import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; |
| 356 | import { RightPanelSheet } from "./RightPanelSheet"; | 362 | import { RightPanelSheet } from "./RightPanelSheet"; |
| 357 | import { previewEnvironment } from "../state/preview"; | 363 | import { previewEnvironment } from "../state/preview"; |
| ... | @@ -2094,6 +2100,10 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -2094,6 +2100,10 @@ function ChatViewContent(props: ChatViewProps) { |
| 2094 | : (primaryEnvironment?.serverConfig ?? null); | 2100 | : (primaryEnvironment?.serverConfig ?? null); |
| 2095 | const pullRequestsCapabilityKnown = serverConfig !== null; | 2101 | const pullRequestsCapabilityKnown = serverConfig !== null; |
| 2096 | const supportsPullRequests = serverConfig?.environment.capabilities.pullRequests === true; | 2102 | const supportsPullRequests = serverConfig?.environment.capabilities.pullRequests === true; |
| 2103 | const attachmentEnvironmentConfig = environmentById.get(environmentId)?.serverConfig ?? null; | ||
| 2104 | const attachmentUploadsCapabilityKnown = attachmentEnvironmentConfig !== null; | ||
| 2105 | const supportsAttachmentUploads = | ||
| 2106 | attachmentEnvironmentConfig?.environment.capabilities.attachmentUploads === true; | ||
| 2097 | const versionMismatch = resolveServerConfigVersionMismatch(serverConfig); | 2107 | const versionMismatch = resolveServerConfigVersionMismatch(serverConfig); |
| 2098 | const versionMismatchDismissKey = | 2108 | const versionMismatchDismissKey = |
| 2099 | versionMismatch && activeThread | 2109 | versionMismatch && activeThread |
| ... | @@ -5406,9 +5416,21 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -5406,9 +5416,21 @@ function ChatViewContent(props: ChatViewProps) { |
| 5406 | return; | 5416 | return; |
| 5407 | } | 5417 | } |
| 5408 | 5418 | ||
| 5419 | sendInFlightRef.current = true; | ||
| 5420 | if (supportsAttachmentUploads && composerImagesSnapshot.length > 0) { | ||
| 5421 | for (const image of composerImagesSnapshot) { | ||
| 5422 | startAttachmentUpload({ environmentId, image }); | ||
| 5423 | } | ||
| 5424 | await awaitAttachmentUploads(composerImagesSnapshot.map((image) => image.id)); | ||
| 5425 | if (getUploadedAttachments({ environmentId, images: composerImagesSnapshot }) === null) { | ||
| 5426 | sendInFlightRef.current = false; | ||
| 5427 | setThreadError(threadIdForSend, "Retry or remove failed image uploads before sending."); | ||
| 5428 | return; | ||
| 5429 | } | ||
| 5430 | } | ||
| 5431 | |||
| 5409 | const resolvedSubmissionIntent = | 5432 | const resolvedSubmissionIntent = |
| 5410 | submissionIntent === "background" && isLocalDraftThread ? "background" : "foreground"; | 5433 | submissionIntent === "background" && isLocalDraftThread ? "background" : "foreground"; |
| 5411 | sendInFlightRef.current = true; | ||
| 5412 | if ( | 5434 | if ( |
| 5413 | shouldDockDraftHeroForSubmission({ | 5435 | shouldDockDraftHeroForSubmission({ |
| 5414 | isDraftHeroState, | 5436 | isDraftHeroState, |
| ... | @@ -5439,13 +5461,22 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -5439,13 +5461,22 @@ function ChatViewContent(props: ChatViewProps) { |
| 5439 | const messageIdForSend = newMessageId(); | 5461 | const messageIdForSend = newMessageId(); |
| 5440 | const messageCreatedAt = new Date().toISOString(); | 5462 | const messageCreatedAt = new Date().toISOString(); |
| 5441 | const turnAttachmentsPromise = Promise.all( | 5463 | const turnAttachmentsPromise = Promise.all( |
| 5442 | composerImagesSnapshot.map(async (image) => ({ | 5464 | composerImagesSnapshot.map(async (image) => { |
| 5443 | type: "image" as const, | 5465 | if (supportsAttachmentUploads) { |
| 5444 | name: image.name, | 5466 | const uploaded = getUploadedAttachments({ environmentId, images: [image] })?.[0]; |
| 5445 | mimeType: image.mimeType, | 5467 | if (!uploaded) { |
| 5446 | sizeBytes: image.sizeBytes, | 5468 | throw new Error(`Image '${image.name}' did not finish uploading.`); |
| 5447 | dataUrl: await readFileAsDataUrl(image.file), | 5469 | } |
| 5448 | })), | 5470 | return uploaded; |
| 5471 | } | ||
| 5472 | return { | ||
| 5473 | type: "image" as const, | ||
| 5474 | name: image.name, | ||
| 5475 | mimeType: image.mimeType, | ||
| 5476 | sizeBytes: image.sizeBytes, | ||
| 5477 | dataUrl: await readFileAsDataUrl(image.file), | ||
| 5478 | }; | ||
| 5479 | }), | ||
| 5449 | ); | 5480 | ); |
| 5450 | const optimisticAttachments = composerImagesSnapshot.map((image) => ({ | 5481 | const optimisticAttachments = composerImagesSnapshot.map((image) => ({ |
| 5451 | type: "image" as const, | 5482 | type: "image" as const, |
| ... | @@ -5632,6 +5663,9 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -5632,6 +5663,9 @@ function ChatViewContent(props: ChatViewProps) { |
| 5632 | failure = startResult; | 5663 | failure = startResult; |
| 5633 | } else { | 5664 | } else { |
| 5634 | turnStartSucceeded = true; | 5665 | turnStartSucceeded = true; |
| 5666 | if (supportsAttachmentUploads) { | ||
| 5667 | releaseAttachmentUploads(composerImagesSnapshot); | ||
| 5668 | } | ||
| 5635 | acknowledgeActiveThreadWoke(); | 5669 | acknowledgeActiveThreadWoke(); |
| 5636 | if (backgroundThreadRef) { | 5670 | if (backgroundThreadRef) { |
| 5637 | markPromotedDraftThreadByRef(backgroundThreadRef); | 5671 | markPromotedDraftThreadByRef(backgroundThreadRef); |
| ... | @@ -6774,6 +6808,8 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -6774,6 +6808,8 @@ function ChatViewContent(props: ChatViewProps) { |
| 6774 | composerRef={composerRef} | 6808 | composerRef={composerRef} |
| 6775 | composerDraftTarget={composerDraftTarget} | 6809 | composerDraftTarget={composerDraftTarget} |
| 6776 | environmentId={environmentId} | 6810 | environmentId={environmentId} |
| 6811 | attachmentUploadsCapabilityKnown={attachmentUploadsCapabilityKnown} | ||
| 6812 | supportsAttachmentUploads={supportsAttachmentUploads} | ||
| 6777 | routeKind={routeKind} | 6813 | routeKind={routeKind} |
| 6778 | routeThreadRef={routeThreadRef} | 6814 | routeThreadRef={routeThreadRef} |
| 6779 | draftId={draftId} | 6815 | draftId={draftId} |
apps/web/src/components/LegacySidebar.tsx+2| ... | @@ -75,6 +75,7 @@ import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstra | ... | @@ -75,6 +75,7 @@ import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstra |
| 75 | import { isElectron } from "../env"; | 75 | import { isElectron } from "../env"; |
| 76 | import { useTerminalFocus } from "../hooks/useTerminalFocus"; | 76 | import { useTerminalFocus } from "../hooks/useTerminalFocus"; |
| 77 | import { useOpenPrLink } from "../lib/openPullRequestLink"; | 77 | import { useOpenPrLink } from "../lib/openPullRequestLink"; |
| 78 | import { releaseProjectDraftUploads } from "../lib/composerDraftUploads"; | ||
| 78 | import { isTerminalFocused } from "../lib/terminalFocus"; | 79 | import { isTerminalFocused } from "../lib/terminalFocus"; |
| 79 | import { isMacPlatform } from "../lib/utils"; | 80 | import { isMacPlatform } from "../lib/utils"; |
| 80 | import { | 81 | import { |
| ... | @@ -1462,6 +1463,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec | ... | @@ -1462,6 +1463,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1462 | return result; | 1463 | return result; |
| 1463 | } | 1464 | } |
| 1464 | const draftStore = useComposerDraftStore.getState(); | 1465 | const draftStore = useComposerDraftStore.getState(); |
| 1466 | releaseProjectDraftUploads(memberProjectRef); | ||
| 1465 | const projectDraftThread = draftStore.getDraftThreadByProjectRef(memberProjectRef); | 1467 | const projectDraftThread = draftStore.getDraftThreadByProjectRef(memberProjectRef); |
| 1466 | if (projectDraftThread) { | 1468 | if (projectDraftThread) { |
| 1467 | draftStore.clearDraftThread(projectDraftThread.draftId); | 1469 | draftStore.clearDraftThread(projectDraftThread.draftId); |
apps/web/src/components/Sidebar.tsx+2| ... | @@ -89,6 +89,7 @@ import { isModelPickerOpen } from "../modelPickerVisibility"; | ... | @@ -89,6 +89,7 @@ import { isModelPickerOpen } from "../modelPickerVisibility"; |
| 89 | import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; | 89 | import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; |
| 90 | import { isMacPlatform } from "~/lib/utils"; | 90 | import { isMacPlatform } from "~/lib/utils"; |
| 91 | import { useOpenPrLink } from "../lib/openPullRequestLink"; | 91 | import { useOpenPrLink } from "../lib/openPullRequestLink"; |
| 92 | import { releaseComposerDraftUploads } from "../lib/composerDraftUploads"; | ||
| 92 | import { readLocalApi } from "../localApi"; | 93 | import { readLocalApi } from "../localApi"; |
| 93 | import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject"; | 94 | import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject"; |
| 94 | import { | 95 | import { |
| ... | @@ -654,6 +655,7 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { | ... | @@ -654,6 +655,7 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { |
| 654 | // The /draft/$draftId route redirects home on its own when the draft | 655 | // The /draft/$draftId route redirects home on its own when the draft |
| 655 | // it renders disappears, so discarding the open draft needs no | 656 | // it renders disappears, so discarding the open draft needs no |
| 656 | // special-casing here. | 657 | // special-casing here. |
| 658 | releaseComposerDraftUploads(draftId); | ||
| 657 | clearDraftThread(draftId); | 659 | clearDraftThread(draftId); |
| 658 | }, | 660 | }, |
| 659 | [clearDraftThread], | 661 | [clearDraftThread], |
apps/web/src/components/chat/ChatComposer.tsx+154-65| ... | @@ -74,6 +74,16 @@ import { | ... | @@ -74,6 +74,16 @@ import { |
| 74 | type ComposerTasksProgress, | 74 | type ComposerTasksProgress, |
| 75 | } from "./ComposerTasksBadge"; | 75 | } from "./ComposerTasksBadge"; |
| 76 | import { compressImageForStash, compressImageToByteLimit } from "../../lib/imageCompression"; | 76 | import { compressImageForStash, compressImageToByteLimit } from "../../lib/imageCompression"; |
| 77 | import { | ||
| 78 | releaseAttachmentUpload, | ||
| 79 | retryAttachmentUpload, | ||
| 80 | startAttachmentUpload, | ||
| 81 | useAttachmentUploadStore, | ||
| 82 | } from "../../lib/attachmentUploadQueue"; | ||
| 83 | import { | ||
| 84 | attachmentUploadBlockReason, | ||
| 85 | formatAttachmentUploadProgress, | ||
| 86 | } from "../../lib/attachmentUploadState"; | ||
| 77 | import { isCommandPaletteOpen } from "../../commandPaletteBus"; | 87 | import { isCommandPaletteOpen } from "../../commandPaletteBus"; |
| 78 | import { getTerminalFocusOwner } from "../../lib/terminalFocus"; | 88 | import { getTerminalFocusOwner } from "../../lib/terminalFocus"; |
| 79 | import { resolveShortcutCommand } from "../../keybindings"; | 89 | import { resolveShortcutCommand } from "../../keybindings"; |
| ... | @@ -232,6 +242,7 @@ import { | ... | @@ -232,6 +242,7 @@ import { |
| 232 | LockIcon, | 242 | LockIcon, |
| 233 | LockOpenIcon, | 243 | LockOpenIcon, |
| 234 | PenLineIcon, | 244 | PenLineIcon, |
| 245 | RotateCcwIcon, | ||
| 235 | SparklesIcon, | 246 | SparklesIcon, |
| 236 | XIcon, | 247 | XIcon, |
| 237 | } from "lucide-react"; | 248 | } from "lucide-react"; |
| ... | @@ -533,6 +544,8 @@ export interface ChatComposerHandle { | ... | @@ -533,6 +544,8 @@ export interface ChatComposerHandle { |
| 533 | export interface ChatComposerProps { | 544 | export interface ChatComposerProps { |
| 534 | composerDraftTarget: ScopedThreadRef | DraftId; | 545 | composerDraftTarget: ScopedThreadRef | DraftId; |
| 535 | environmentId: EnvironmentId; | 546 | environmentId: EnvironmentId; |
| 547 | attachmentUploadsCapabilityKnown: boolean; | ||
| 548 | supportsAttachmentUploads: boolean; | ||
| 536 | routeKind: "server" | "draft"; | 549 | routeKind: "server" | "draft"; |
| 537 | routeThreadRef: ScopedThreadRef; | 550 | routeThreadRef: ScopedThreadRef; |
| 538 | draftId: DraftId | null; | 551 | draftId: DraftId | null; |
| ... | @@ -647,6 +660,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -647,6 +660,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 647 | const { | 660 | const { |
| 648 | composerDraftTarget, | 661 | composerDraftTarget, |
| 649 | environmentId, | 662 | environmentId, |
| 663 | attachmentUploadsCapabilityKnown, | ||
| 664 | supportsAttachmentUploads, | ||
| 650 | routeKind, | 665 | routeKind, |
| 651 | routeThreadRef, | 666 | routeThreadRef, |
| 652 | draftId, | 667 | draftId, |
| ... | @@ -660,7 +675,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -660,7 +675,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 660 | phase, | 675 | phase, |
| 661 | isConnecting, | 676 | isConnecting, |
| 662 | isSendBusy, | 677 | isSendBusy, |
| 663 | sendDisabledReason, | 678 | sendDisabledReason: externalSendDisabledReason, |
| 664 | isPreparingWorktree, | 679 | isPreparingWorktree, |
| 665 | environmentUnavailable, | 680 | environmentUnavailable, |
| 666 | activePendingApproval, | 681 | activePendingApproval, |
| ... | @@ -711,8 +726,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -711,8 +726,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 711 | setThreadError, | 726 | setThreadError, |
| 712 | onExpandImage, | 727 | onExpandImage, |
| 713 | } = props; | 728 | } = props; |
| 714 | const isSendDisabled = sendDisabledReason !== null; | ||
| 715 | |||
| 716 | // ------------------------------------------------------------------ | 729 | // ------------------------------------------------------------------ |
| 717 | // Store subscriptions (prompt / images / terminal contexts) | 730 | // Store subscriptions (prompt / images / terminal contexts) |
| 718 | // ------------------------------------------------------------------ | 731 | // ------------------------------------------------------------------ |
| ... | @@ -724,6 +737,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -724,6 +737,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 724 | const composerPreviewAnnotations = composerDraft.previewAnnotations; | 737 | const composerPreviewAnnotations = composerDraft.previewAnnotations; |
| 725 | const composerReviewComments = composerDraft.reviewComments; | 738 | const composerReviewComments = composerDraft.reviewComments; |
| 726 | const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds; | 739 | const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds; |
| 740 | const uploadsByImageId = useAttachmentUploadStore((state) => state.uploadsByImageId); | ||
| 741 | const attachmentBlockReason = supportsAttachmentUploads | ||
| 742 | ? attachmentUploadBlockReason({ | ||
| 743 | imageIds: composerImages.map((image) => image.id), | ||
| 744 | uploadsByImageId, | ||
| 745 | environmentId, | ||
| 746 | }) | ||
| 747 | : null; | ||
| 748 | const sendDisabledReason = | ||
| 749 | externalSendDisabledReason ?? (activePendingProgress ? null : attachmentBlockReason); | ||
| 750 | const isSendDisabled = sendDisabledReason !== null; | ||
| 727 | 751 | ||
| 728 | const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); | 752 | const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); |
| 729 | const addComposerDraftImage = useComposerDraftStore((store) => store.addImage); | 753 | const addComposerDraftImage = useComposerDraftStore((store) => store.addImage); |
| ... | @@ -758,6 +782,21 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -758,6 +782,21 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 758 | ); | 782 | ); |
| 759 | const getComposerDraft = useComposerDraftStore((store) => store.getComposerDraft); | 783 | const getComposerDraft = useComposerDraftStore((store) => store.getComposerDraft); |
| 760 | 784 | ||
| 785 | useEffect(() => { | ||
| 786 | if (!attachmentUploadsCapabilityKnown) { | ||
| 787 | return; | ||
| 788 | } | ||
| 789 | if (!supportsAttachmentUploads) { | ||
| 790 | for (const image of composerImages) { | ||
| 791 | releaseAttachmentUpload(image.id); | ||
| 792 | } | ||
| 793 | return; | ||
| 794 | } | ||
| 795 | for (const image of composerImages) { | ||
| 796 | startAttachmentUpload({ environmentId, image }); | ||
| 797 | } | ||
| 798 | }, [attachmentUploadsCapabilityKnown, composerImages, environmentId, supportsAttachmentUploads]); | ||
| 799 | |||
| 761 | // ------------------------------------------------------------------ | 800 | // ------------------------------------------------------------------ |
| 762 | // Model state | 801 | // Model state |
| 763 | // ------------------------------------------------------------------ | 802 | // ------------------------------------------------------------------ |
| ... | @@ -1337,6 +1376,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -1337,6 +1376,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1337 | 1376 | ||
| 1338 | const removeComposerImageFromDraft = useCallback( | 1377 | const removeComposerImageFromDraft = useCallback( |
| 1339 | (imageId: string) => { | 1378 | (imageId: string) => { |
| 1379 | releaseAttachmentUpload(imageId); | ||
| 1340 | removeComposerDraftImage(composerDraftTarget, imageId); | 1380 | removeComposerDraftImage(composerDraftTarget, imageId); |
| 1341 | }, | 1381 | }, |
| 1342 | [composerDraftTarget, removeComposerDraftImage], | 1382 | [composerDraftTarget, removeComposerDraftImage], |
| ... | @@ -2244,6 +2284,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -2244,6 +2284,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2244 | // destroying them here would be unrecoverable. | 2284 | // destroying them here would be unrecoverable. |
| 2245 | promptRef.current = ""; | 2285 | promptRef.current = ""; |
| 2246 | clearComposerDraftPromptAndImages(stashTarget); | 2286 | clearComposerDraftPromptAndImages(stashTarget); |
| 2287 | for (const image of images) { | ||
| 2288 | releaseAttachmentUpload(image.id); | ||
| 2289 | } | ||
| 2247 | setComposerCursor(0); | 2290 | setComposerCursor(0); |
| 2248 | setComposerTrigger(null); | 2291 | setComposerTrigger(null); |
| 2249 | pulseStashBadge(); | 2292 | pulseStashBadge(); |
| ... | @@ -3106,9 +3149,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -3106,9 +3149,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3106 | <ComposerPreviewAnnotationCards | 3149 | <ComposerPreviewAnnotationCards |
| 3107 | annotations={composerPreviewAnnotations} | 3150 | annotations={composerPreviewAnnotations} |
| 3108 | images={composerImages} | 3151 | images={composerImages} |
| 3109 | onRemove={(annotationId) => | 3152 | {...(supportsAttachmentUploads |
| 3110 | removeComposerDraftPreviewAnnotation(composerDraftTarget, annotationId) | 3153 | ? { |
| 3111 | } | 3154 | uploadsByImageId, |
| 3155 | onRetryUpload: (image: ComposerImageAttachment) => | ||
| 3156 | retryAttachmentUpload({ environmentId, image }), | ||
| 3157 | } | ||
| 3158 | : {})} | ||
| 3159 | onRemove={(annotationId) => { | ||
| 3160 | releaseAttachmentUpload(annotationId); | ||
| 3161 | removeComposerDraftPreviewAnnotation(composerDraftTarget, annotationId); | ||
| 3162 | }} | ||
| 3112 | onExpandImage={(imageId) => { | 3163 | onExpandImage={(imageId) => { |
| 3113 | const preview = buildExpandedImagePreview(composerImages, imageId); | 3164 | const preview = buildExpandedImagePreview(composerImages, imageId); |
| 3114 | if (preview) onExpandImage(preview); | 3165 | if (preview) onExpandImage(preview); |
| ... | @@ -3158,66 +3209,104 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -3158,66 +3209,104 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3158 | (annotation) => annotation.id === image.id, | 3209 | (annotation) => annotation.id === image.id, |
| 3159 | ), | 3210 | ), |
| 3160 | ) | 3211 | ) |
| 3161 | .map((image) => ( | 3212 | .map((image) => { |
| 3162 | <div | 3213 | const upload = supportsAttachmentUploads |
| 3163 | key={image.id} | 3214 | ? uploadsByImageId[image.id] |
| 3164 | className="relative h-16 w-16 overflow-hidden rounded-lg border border-border/80 bg-background" | 3215 | : undefined; |
| 3165 | > | 3216 | return ( |
| 3166 | {image.previewUrl ? ( | 3217 | <div |
| 3167 | <button | 3218 | key={image.id} |
| 3168 | type="button" | 3219 | className="relative h-16 w-16 overflow-hidden rounded-lg border border-border/80 bg-background" |
| 3169 | className="h-full w-full cursor-zoom-in" | ||
| 3170 | aria-label={`Preview ${image.name}`} | ||
| 3171 | onClick={() => { | ||
| 3172 | const preview = buildExpandedImagePreview(composerImages, image.id); | ||
| 3173 | if (!preview) return; | ||
| 3174 | onExpandImage(preview); | ||
| 3175 | }} | ||
| 3176 | > | ||
| 3177 | <img | ||
| 3178 | src={image.previewUrl} | ||
| 3179 | alt={image.name} | ||
| 3180 | className="h-full w-full object-cover" | ||
| 3181 | /> | ||
| 3182 | </button> | ||
| 3183 | ) : ( | ||
| 3184 | <div className="flex h-full w-full items-center justify-center px-1 text-center text-[10px] text-secondary-label"> | ||
| 3185 | {image.name} | ||
| 3186 | </div> | ||
| 3187 | )} | ||
| 3188 | {nonPersistedComposerImageIdSet.has(image.id) && ( | ||
| 3189 | <Tooltip> | ||
| 3190 | <TooltipTrigger | ||
| 3191 | render={ | ||
| 3192 | <span | ||
| 3193 | role="img" | ||
| 3194 | aria-label="Draft attachment may not persist" | ||
| 3195 | className="absolute left-1 top-1 inline-flex items-center justify-center rounded bg-background/85 p-0.5 text-amber-600" | ||
| 3196 | > | ||
| 3197 | <CircleAlertIcon className="size-3" /> | ||
| 3198 | </span> | ||
| 3199 | } | ||
| 3200 | /> | ||
| 3201 | <TooltipPopup | ||
| 3202 | side="top" | ||
| 3203 | className="max-w-64 whitespace-normal leading-tight" | ||
| 3204 | > | ||
| 3205 | Draft attachment could not be saved locally and may be lost on | ||
| 3206 | navigation. | ||
| 3207 | </TooltipPopup> | ||
| 3208 | </Tooltip> | ||
| 3209 | )} | ||
| 3210 | <Button | ||
| 3211 | variant="ghost" | ||
| 3212 | size="icon-xs" | ||
| 3213 | className="absolute right-1 top-1 bg-background/80 hover:bg-background/90" | ||
| 3214 | onClick={() => removeComposerImage(image.id)} | ||
| 3215 | aria-label={`Remove ${image.name}`} | ||
| 3216 | > | 3220 | > |
| 3217 | <XIcon /> | 3221 | {image.previewUrl ? ( |
| 3218 | </Button> | 3222 | <button |
| 3219 | </div> | 3223 | type="button" |
| 3220 | ))} | 3224 | className="h-full w-full cursor-zoom-in" |
| 3225 | aria-label={`Preview ${image.name}`} | ||
| 3226 | onClick={() => { | ||
| 3227 | const preview = buildExpandedImagePreview( | ||
| 3228 | composerImages, | ||
| 3229 | image.id, | ||
| 3230 | ); | ||
| 3231 | if (!preview) return; | ||
| 3232 | onExpandImage(preview); | ||
| 3233 | }} | ||
| 3234 | > | ||
| 3235 | <img | ||
| 3236 | src={image.previewUrl} | ||
| 3237 | alt={image.name} | ||
| 3238 | className="h-full w-full object-cover" | ||
| 3239 | /> | ||
| 3240 | </button> | ||
| 3241 | ) : ( | ||
| 3242 | <div className="flex h-full w-full items-center justify-center px-1 text-center text-[10px] text-secondary-label"> | ||
| 3243 | {image.name} | ||
| 3244 | </div> | ||
| 3245 | )} | ||
| 3246 | {nonPersistedComposerImageIdSet.has(image.id) && ( | ||
| 3247 | <Tooltip> | ||
| 3248 | <TooltipTrigger | ||
| 3249 | render={ | ||
| 3250 | <span | ||
| 3251 | role="img" | ||
| 3252 | aria-label="Draft attachment may not persist" | ||
| 3253 | className="absolute left-1 top-1 inline-flex items-center justify-center rounded bg-background/85 p-0.5 text-amber-600" | ||
| 3254 | > | ||
| 3255 | <CircleAlertIcon className="size-3" /> | ||
| 3256 | </span> | ||
| 3257 | } | ||
| 3258 | /> | ||
| 3259 | <TooltipPopup | ||
| 3260 | side="top" | ||
| 3261 | className="max-w-64 whitespace-normal leading-tight" | ||
| 3262 | > | ||
| 3263 | Draft attachment could not be saved locally and may be lost on | ||
| 3264 | navigation. | ||
| 3265 | </TooltipPopup> | ||
| 3266 | </Tooltip> | ||
| 3267 | )} | ||
| 3268 | {upload?.status === "uploading" && ( | ||
| 3269 | <span className="pointer-events-none absolute inset-x-0 bottom-0 bg-background/85 px-1 text-center text-[10px] text-foreground"> | ||
| 3270 | {formatAttachmentUploadProgress(upload.progress)} | ||
| 3271 | </span> | ||
| 3272 | )} | ||
| 3273 | {upload?.status === "failed" && ( | ||
| 3274 | <Tooltip> | ||
| 3275 | <TooltipTrigger | ||
| 3276 | render={ | ||
| 3277 | <Button | ||
| 3278 | variant="ghost" | ||
| 3279 | size="icon-xs" | ||
| 3280 | className="absolute bottom-1 left-1 bg-background/85 hover:bg-background/95" | ||
| 3281 | onClick={() => | ||
| 3282 | retryAttachmentUpload({ environmentId, image }) | ||
| 3283 | } | ||
| 3284 | aria-label={`Retry upload for ${image.name}`} | ||
| 3285 | /> | ||
| 3286 | } | ||
| 3287 | > | ||
| 3288 | <RotateCcwIcon /> | ||
| 3289 | </TooltipTrigger> | ||
| 3290 | <TooltipPopup | ||
| 3291 | side="top" | ||
| 3292 | className="max-w-64 whitespace-normal leading-tight" | ||
| 3293 | > | ||
| 3294 | {upload.reason} | ||
| 3295 | </TooltipPopup> | ||
| 3296 | </Tooltip> | ||
| 3297 | )} | ||
| 3298 | <Button | ||
| 3299 | variant="ghost" | ||
| 3300 | size="icon-xs" | ||
| 3301 | className="absolute right-1 top-1 bg-background/80 hover:bg-background/90" | ||
| 3302 | onClick={() => removeComposerImage(image.id)} | ||
| 3303 | aria-label={`Remove ${image.name}`} | ||
| 3304 | > | ||
| 3305 | <XIcon /> | ||
| 3306 | </Button> | ||
| 3307 | </div> | ||
| 3308 | ); | ||
| 3309 | })} | ||
| 3221 | </div> | 3310 | </div> |
| 3222 | )} | 3311 | )} |
| 3223 | 3312 |
apps/web/src/components/chat/ComposerPreviewAnnotationCards.test.tsx+31-1| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import type { PreviewAnnotationPayload } from "@t3tools/contracts"; | 1 | import { EnvironmentId, type PreviewAnnotationPayload } from "@t3tools/contracts"; |
| 2 | import { renderToStaticMarkup } from "react-dom/server"; | 2 | import { renderToStaticMarkup } from "react-dom/server"; |
| 3 | import { describe, expect, it, vi } from "vite-plus/test"; | 3 | import { describe, expect, it, vi } from "vite-plus/test"; |
| 4 | 4 | ||
| ... | @@ -58,4 +58,34 @@ describe("ComposerPreviewAnnotationCards", () => { | ... | @@ -58,4 +58,34 @@ describe("ComposerPreviewAnnotationCards", () => { |
| 58 | expect(markup).toContain('aria-label="Remove preview annotation"'); | 58 | expect(markup).toContain('aria-label="Remove preview annotation"'); |
| 59 | expect(markup).toContain('data-slot="button"'); | 59 | expect(markup).toContain('data-slot="button"'); |
| 60 | }); | 60 | }); |
| 61 | |||
| 62 | it("shows a retry action for a failed screenshot upload", () => { | ||
| 63 | const image = { | ||
| 64 | type: "image" as const, | ||
| 65 | id: annotation.id, | ||
| 66 | name: "annotation.png", | ||
| 67 | mimeType: "image/png", | ||
| 68 | sizeBytes: 3, | ||
| 69 | previewUrl: "blob:annotation", | ||
| 70 | file: new File([new Uint8Array([1, 2, 3])], "annotation.png", { type: "image/png" }), | ||
| 71 | }; | ||
| 72 | const markup = renderToStaticMarkup( | ||
| 73 | <ComposerPreviewAnnotationCards | ||
| 74 | annotations={[annotation]} | ||
| 75 | images={[image]} | ||
| 76 | uploadsByImageId={{ | ||
| 77 | [image.id]: { | ||
| 78 | status: "failed", | ||
| 79 | environmentId: EnvironmentId.make("environment-1"), | ||
| 80 | reason: "Upload rejected", | ||
| 81 | }, | ||
| 82 | }} | ||
| 83 | onRetryUpload={vi.fn()} | ||
| 84 | onRemove={vi.fn()} | ||
| 85 | onExpandImage={vi.fn()} | ||
| 86 | />, | ||
| 87 | ); | ||
| 88 | |||
| 89 | expect(markup).toContain('aria-label="Retry upload for annotation.png"'); | ||
| 90 | }); | ||
| 61 | }); | 91 | }); |
apps/web/src/components/chat/ComposerPreviewAnnotationCards.tsx+32-1| ... | @@ -1,9 +1,13 @@ | ... | @@ -1,9 +1,13 @@ |
| 1 | import type { PreviewAnnotationPayload } from "@t3tools/contracts"; | 1 | import type { PreviewAnnotationPayload } from "@t3tools/contracts"; |
| 2 | import { Frame, MousePointerClick, Paintbrush, PenLine, X } from "lucide-react"; | 2 | import { Frame, MousePointerClick, Paintbrush, PenLine, RotateCcw, X } from "lucide-react"; |
| 3 | import type { ReactNode } from "react"; | 3 | import type { ReactNode } from "react"; |
| 4 | 4 | ||
| 5 | import type { ComposerImageAttachment } from "~/composerDraftStore"; | 5 | import type { ComposerImageAttachment } from "~/composerDraftStore"; |
| 6 | import { formatElementContextLabel, normalizeElementContextSelection } from "~/lib/elementContext"; | 6 | import { formatElementContextLabel, normalizeElementContextSelection } from "~/lib/elementContext"; |
| 7 | import { | ||
| 8 | formatAttachmentUploadProgress, | ||
| 9 | type AttachmentUploadState, | ||
| 10 | } from "~/lib/attachmentUploadState"; | ||
| 7 | import { cn } from "~/lib/utils"; | 11 | import { cn } from "~/lib/utils"; |
| 8 | import { Button } from "../ui/button"; | 12 | import { Button } from "../ui/button"; |
| 9 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 13 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| ... | @@ -13,6 +17,8 @@ interface ComposerPreviewAnnotationCardsProps { | ... | @@ -13,6 +17,8 @@ interface ComposerPreviewAnnotationCardsProps { |
| 13 | images: ReadonlyArray<ComposerImageAttachment>; | 17 | images: ReadonlyArray<ComposerImageAttachment>; |
| 14 | onRemove: (annotationId: string) => void; | 18 | onRemove: (annotationId: string) => void; |
| 15 | onExpandImage: (imageId: string) => void; | 19 | onExpandImage: (imageId: string) => void; |
| 20 | uploadsByImageId?: Readonly<Record<string, AttachmentUploadState>>; | ||
| 21 | onRetryUpload?: (image: ComposerImageAttachment) => void; | ||
| 16 | className?: string; | 22 | className?: string; |
| 17 | } | 23 | } |
| 18 | 24 | ||
| ... | @@ -38,6 +44,8 @@ export function ComposerPreviewAnnotationCards({ | ... | @@ -38,6 +44,8 @@ export function ComposerPreviewAnnotationCards({ |
| 38 | images, | 44 | images, |
| 39 | onRemove, | 45 | onRemove, |
| 40 | onExpandImage, | 46 | onExpandImage, |
| 47 | uploadsByImageId, | ||
| 48 | onRetryUpload, | ||
| 41 | className, | 49 | className, |
| 42 | }: ComposerPreviewAnnotationCardsProps) { | 50 | }: ComposerPreviewAnnotationCardsProps) { |
| 43 | if (annotations.length === 0) return null; | 51 | if (annotations.length === 0) return null; |
| ... | @@ -47,6 +55,7 @@ export function ComposerPreviewAnnotationCards({ | ... | @@ -47,6 +55,7 @@ export function ComposerPreviewAnnotationCards({ |
| 47 | <div className={cn("flex flex-wrap gap-1.5", className)}> | 55 | <div className={cn("flex flex-wrap gap-1.5", className)}> |
| 48 | {annotations.map((annotation) => { | 56 | {annotations.map((annotation) => { |
| 49 | const image = imagesById.get(annotation.id); | 57 | const image = imagesById.get(annotation.id); |
| 58 | const upload = image ? uploadsByImageId?.[image.id] : undefined; | ||
| 50 | const elementLabels = annotation.elements.flatMap((target) => { | 59 | const elementLabels = annotation.elements.flatMap((target) => { |
| 51 | const context = normalizeElementContextSelection(target.element); | 60 | const context = normalizeElementContextSelection(target.element); |
| 52 | return context ? [{ id: target.id, label: formatElementContextLabel(context) }] : []; | 61 | return context ? [{ id: target.id, label: formatElementContextLabel(context) }] : []; |
| ... | @@ -132,6 +141,28 @@ export function ComposerPreviewAnnotationCards({ | ... | @@ -132,6 +141,28 @@ export function ComposerPreviewAnnotationCards({ |
| 132 | label="style change" | 141 | label="style change" |
| 133 | /> | 142 | /> |
| 134 | ) : null} | 143 | ) : null} |
| 144 | {upload?.status === "uploading" ? ( | ||
| 145 | <span className="text-[10px] text-secondary-label"> | ||
| 146 | {formatAttachmentUploadProgress(upload.progress)} | ||
| 147 | </span> | ||
| 148 | ) : null} | ||
| 149 | {upload?.status === "failed" && image && onRetryUpload ? ( | ||
| 150 | <Tooltip> | ||
| 151 | <TooltipTrigger | ||
| 152 | render={ | ||
| 153 | <Button | ||
| 154 | size="icon-micro" | ||
| 155 | variant="ghost-muted" | ||
| 156 | aria-label={`Retry upload for ${image.name}`} | ||
| 157 | onClick={() => onRetryUpload(image)} | ||
| 158 | /> | ||
| 159 | } | ||
| 160 | > | ||
| 161 | <RotateCcw className="size-3" /> | ||
| 162 | </TooltipTrigger> | ||
| 163 | <TooltipPopup side="top">{upload.reason}</TooltipPopup> | ||
| 164 | </Tooltip> | ||
| 165 | ) : null} | ||
| 135 | </div> | 166 | </div> |
| 136 | </div> | 167 | </div> |
| 137 | </div> | 168 | </div> |
apps/web/src/components/settings/ProjectSettingsPanel.tsx+2| ... | @@ -46,6 +46,7 @@ import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; | ... | @@ -46,6 +46,7 @@ import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; |
| 46 | import { useT3ProjectFileState } from "../../hooks/useT3ProjectFileScripts"; | 46 | import { useT3ProjectFileState } from "../../hooks/useT3ProjectFileScripts"; |
| 47 | import { shortcutLabelForCommand } from "../../keybindings"; | 47 | import { shortcutLabelForCommand } from "../../keybindings"; |
| 48 | import { keybindingValueForCommand } from "../../lib/projectScriptKeybindings"; | 48 | import { keybindingValueForCommand } from "../../lib/projectScriptKeybindings"; |
| 49 | import { releaseProjectDraftUploads } from "../../lib/composerDraftUploads"; | ||
| 49 | import { readLocalApi } from "../../localApi"; | 50 | import { readLocalApi } from "../../localApi"; |
| 50 | import { | 51 | import { |
| 51 | buildProjectScript, | 52 | buildProjectScript, |
| ... | @@ -722,6 +723,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { | ... | @@ -722,6 +723,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { |
| 722 | return; | 723 | return; |
| 723 | } | 724 | } |
| 724 | const projectRef = scopeProjectRef(member.environmentId, member.id); | 725 | const projectRef = scopeProjectRef(member.environmentId, member.id); |
| 726 | releaseProjectDraftUploads(projectRef); | ||
| 725 | const projectDraftThread = draftStore.getDraftThreadByProjectRef(projectRef); | 727 | const projectDraftThread = draftStore.getDraftThreadByProjectRef(projectRef); |
| 726 | if (projectDraftThread) { | 728 | if (projectDraftThread) { |
| 727 | draftStore.clearDraftThread(projectDraftThread.draftId); | 729 | draftStore.clearDraftThread(projectDraftThread.draftId); |
apps/web/src/hooks/useThreadActions.ts+2| ... | @@ -20,6 +20,7 @@ import { threadEnvironment } from "../state/threads"; | ... | @@ -20,6 +20,7 @@ import { threadEnvironment } from "../state/threads"; |
| 20 | import { vcsEnvironment } from "../state/vcs"; | 20 | import { vcsEnvironment } from "../state/vcs"; |
| 21 | import { useNewThreadHandler } from "./useHandleNewThread"; | 21 | import { useNewThreadHandler } from "./useHandleNewThread"; |
| 22 | import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsState"; | 22 | import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsState"; |
| 23 | import { releaseComposerDraftUploads } from "../lib/composerDraftUploads"; | ||
| 23 | import { readLocalApi } from "../localApi"; | 24 | import { readLocalApi } from "../localApi"; |
| 24 | import { | 25 | import { |
| 25 | readEnvironmentSupportsPinning, | 26 | readEnvironmentSupportsPinning, |
| ... | @@ -364,6 +365,7 @@ export function useThreadActions() { | ... | @@ -364,6 +365,7 @@ export function useThreadActions() { |
| 364 | return deleteResult; | 365 | return deleteResult; |
| 365 | } | 366 | } |
| 366 | refreshArchivedThreadsForEnvironment(threadRef.environmentId); | 367 | refreshArchivedThreadsForEnvironment(threadRef.environmentId); |
| 368 | releaseComposerDraftUploads(threadRef); | ||
| 367 | clearComposerDraftForThread(threadRef); | 369 | clearComposerDraftForThread(threadRef); |
| 368 | clearProjectDraftThreadById( | 370 | clearProjectDraftThreadById( |
| 369 | scopeProjectRef(threadRef.environmentId, thread.projectId), | 371 | scopeProjectRef(threadRef.environmentId, thread.projectId), |
apps/web/src/lib/attachmentUploadQueue.test.ts created+306| ... | @@ -0,0 +1,306 @@ | ||
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 3 | |||
| 4 | import type { ComposerImageAttachment } from "../composerDraftStore"; | ||
| 5 | |||
| 6 | const mocks = vi.hoisted(() => ({ | ||
| 7 | createUploadUrl: Symbol("create-upload-url"), | ||
| 8 | removeUpload: Symbol("remove-upload"), | ||
| 9 | runAtomCommand: vi.fn(), | ||
| 10 | readPreparedConnection: vi.fn(), | ||
| 11 | })); | ||
| 12 | |||
| 13 | vi.mock("@t3tools/client-runtime/state/runtime", () => ({ | ||
| 14 | runAtomCommand: mocks.runAtomCommand, | ||
| 15 | })); | ||
| 16 | |||
| 17 | vi.mock("../rpc/atomRegistry", () => ({ appAtomRegistry: {} })); | ||
| 18 | |||
| 19 | vi.mock("../state/attachments", () => ({ | ||
| 20 | attachmentEnvironment: { | ||
| 21 | createUploadUrl: mocks.createUploadUrl, | ||
| 22 | remove: mocks.removeUpload, | ||
| 23 | }, | ||
| 24 | })); | ||
| 25 | |||
| 26 | vi.mock("../state/session", () => ({ | ||
| 27 | readPreparedConnection: mocks.readPreparedConnection, | ||
| 28 | })); | ||
| 29 | |||
| 30 | import { | ||
| 31 | awaitAttachmentUploads, | ||
| 32 | getUploadedAttachments, | ||
| 33 | readAttachmentUpload, | ||
| 34 | releaseAttachmentUpload, | ||
| 35 | releaseAttachmentUploads, | ||
| 36 | retryAttachmentUpload, | ||
| 37 | startAttachmentUpload, | ||
| 38 | useAttachmentUploadStore, | ||
| 39 | } from "./attachmentUploadQueue"; | ||
| 40 | |||
| 41 | type ProgressListener = (event: { | ||
| 42 | readonly lengthComputable: boolean; | ||
| 43 | readonly loaded: number; | ||
| 44 | readonly total: number; | ||
| 45 | }) => void; | ||
| 46 | |||
| 47 | class TestXmlHttpRequest { | ||
| 48 | static requests: TestXmlHttpRequest[] = []; | ||
| 49 | |||
| 50 | status = 0; | ||
| 51 | timeout = 0; | ||
| 52 | method: string | null = null; | ||
| 53 | url: string | null = null; | ||
| 54 | readonly headers = new Map<string, string>(); | ||
| 55 | readonly listeners = new Map<string, () => void>(); | ||
| 56 | progressListener: ProgressListener | null = null; | ||
| 57 | |||
| 58 | readonly upload = { | ||
| 59 | addEventListener: (_event: string, listener: ProgressListener) => { | ||
| 60 | this.progressListener = listener; | ||
| 61 | }, | ||
| 62 | }; | ||
| 63 | |||
| 64 | constructor() { | ||
| 65 | TestXmlHttpRequest.requests.push(this); | ||
| 66 | } | ||
| 67 | |||
| 68 | open(method: string, url: string): void { | ||
| 69 | this.method = method; | ||
| 70 | this.url = url; | ||
| 71 | } | ||
| 72 | |||
| 73 | setRequestHeader(name: string, value: string): void { | ||
| 74 | this.headers.set(name, value); | ||
| 75 | } | ||
| 76 | |||
| 77 | addEventListener(event: string, listener: () => void): void { | ||
| 78 | this.listeners.set(event, listener); | ||
| 79 | } | ||
| 80 | |||
| 81 | send(): void {} | ||
| 82 | |||
| 83 | abort(): void { | ||
| 84 | this.listeners.get("abort")?.(); | ||
| 85 | } | ||
| 86 | |||
| 87 | progress(loaded: number, total: number): void { | ||
| 88 | this.progressListener?.({ lengthComputable: true, loaded, total }); | ||
| 89 | } | ||
| 90 | |||
| 91 | complete(status = 204): void { | ||
| 92 | this.status = status; | ||
| 93 | this.listeners.get("load")?.(); | ||
| 94 | } | ||
| 95 | } | ||
| 96 | |||
| 97 | const firstEnvironment = EnvironmentId.make("environment-1"); | ||
| 98 | const secondEnvironment = EnvironmentId.make("environment-2"); | ||
| 99 | |||
| 100 | function makeImage(id: string): ComposerImageAttachment { | ||
| 101 | const file = new File([new Uint8Array([1, 2, 3])], `${id}.png`, { type: "image/png" }); | ||
| 102 | return { | ||
| 103 | type: "image", | ||
| 104 | id, | ||
| 105 | name: file.name, | ||
| 106 | mimeType: file.type, | ||
| 107 | sizeBytes: file.size, | ||
| 108 | previewUrl: `blob:${id}`, | ||
| 109 | file, | ||
| 110 | }; | ||
| 111 | } | ||
| 112 | |||
| 113 | describe("attachmentUploadQueue", () => { | ||
| 114 | beforeEach(() => { | ||
| 115 | TestXmlHttpRequest.requests = []; | ||
| 116 | mocks.runAtomCommand.mockReset(); | ||
| 117 | mocks.readPreparedConnection.mockReset(); | ||
| 118 | mocks.readPreparedConnection.mockReturnValue({ httpBaseUrl: "https://environment.test/" }); | ||
| 119 | mocks.runAtomCommand.mockImplementation( | ||
| 120 | async ( | ||
| 121 | _registry: unknown, | ||
| 122 | command: unknown, | ||
| 123 | target: { | ||
| 124 | readonly environmentId: EnvironmentId; | ||
| 125 | readonly input: { readonly name?: string }; | ||
| 126 | }, | ||
| 127 | ) => { | ||
| 128 | if (command === mocks.createUploadUrl) { | ||
| 129 | const attachmentId = `pending-${target.environmentId}-${target.input.name}`; | ||
| 130 | return { | ||
| 131 | _tag: "Success", | ||
| 132 | value: { | ||
| 133 | attachmentId, | ||
| 134 | relativeUrl: `/api/attachments/upload/${attachmentId}`, | ||
| 135 | expiresAt: 1, | ||
| 136 | }, | ||
| 137 | }; | ||
| 138 | } | ||
| 139 | return { _tag: "Success", value: undefined }; | ||
| 140 | }, | ||
| 141 | ); | ||
| 142 | vi.stubGlobal("XMLHttpRequest", TestXmlHttpRequest); | ||
| 143 | }); | ||
| 144 | |||
| 145 | afterEach(() => { | ||
| 146 | for (const imageId of Object.keys(useAttachmentUploadStore.getState().uploadsByImageId)) { | ||
| 147 | releaseAttachmentUpload(imageId); | ||
| 148 | } | ||
| 149 | vi.unstubAllGlobals(); | ||
| 150 | }); | ||
| 151 | |||
| 152 | it("uploads images immediately and sends attachment references", async () => { | ||
| 153 | const image = makeImage("image-1"); | ||
| 154 | startAttachmentUpload({ environmentId: firstEnvironment, image }); | ||
| 155 | await Promise.resolve(); | ||
| 156 | |||
| 157 | const request = TestXmlHttpRequest.requests[0]!; | ||
| 158 | expect(request.method).toBe("POST"); | ||
| 159 | expect(request.url).toBe( | ||
| 160 | "https://environment.test/api/attachments/upload/pending-environment-1-image-1.png", | ||
| 161 | ); | ||
| 162 | request.progress(1, 3); | ||
| 163 | expect(readAttachmentUpload(image.id)).toMatchObject({ status: "uploading", progress: 1 / 3 }); | ||
| 164 | |||
| 165 | const settled = awaitAttachmentUploads([image.id]); | ||
| 166 | request.complete(); | ||
| 167 | await settled; | ||
| 168 | |||
| 169 | expect(getUploadedAttachments({ environmentId: firstEnvironment, images: [image] })).toEqual([ | ||
| 170 | { | ||
| 171 | type: "image", | ||
| 172 | id: "pending-environment-1-image-1.png", | ||
| 173 | name: "image-1.png", | ||
| 174 | mimeType: "image/png", | ||
| 175 | sizeBytes: 3, | ||
| 176 | }, | ||
| 177 | ]); | ||
| 178 | |||
| 179 | releaseAttachmentUploads([image]); | ||
| 180 | expect(readAttachmentUpload(image.id)).toBeUndefined(); | ||
| 181 | expect(mocks.runAtomCommand).toHaveBeenCalledWith( | ||
| 182 | expect.anything(), | ||
| 183 | mocks.removeUpload, | ||
| 184 | { | ||
| 185 | environmentId: firstEnvironment, | ||
| 186 | input: { attachmentId: "pending-environment-1-image-1.png" }, | ||
| 187 | }, | ||
| 188 | expect.anything(), | ||
| 189 | ); | ||
| 190 | }); | ||
| 191 | |||
| 192 | it("retries rejected uploads", async () => { | ||
| 193 | const image = makeImage("image-retry"); | ||
| 194 | startAttachmentUpload({ environmentId: firstEnvironment, image }); | ||
| 195 | await Promise.resolve(); | ||
| 196 | |||
| 197 | let settled = awaitAttachmentUploads([image.id]); | ||
| 198 | TestXmlHttpRequest.requests[0]!.complete(500); | ||
| 199 | await settled; | ||
| 200 | expect(readAttachmentUpload(image.id)).toMatchObject({ | ||
| 201 | status: "failed", | ||
| 202 | reason: "Upload rejected (500)", | ||
| 203 | }); | ||
| 204 | |||
| 205 | retryAttachmentUpload({ environmentId: firstEnvironment, image }); | ||
| 206 | await Promise.resolve(); | ||
| 207 | settled = awaitAttachmentUploads([image.id]); | ||
| 208 | TestXmlHttpRequest.requests[1]!.complete(); | ||
| 209 | await settled; | ||
| 210 | |||
| 211 | expect(readAttachmentUpload(image.id)).toMatchObject({ status: "ready" }); | ||
| 212 | }); | ||
| 213 | |||
| 214 | it("releases an upload URL that resolves after its image was removed", async () => { | ||
| 215 | const image = makeImage("image-cancelled"); | ||
| 216 | const minted = { | ||
| 217 | _tag: "Success" as const, | ||
| 218 | value: { | ||
| 219 | attachmentId: "pending-environment-1-image-cancelled.png", | ||
| 220 | relativeUrl: "/api/attachments/upload/cancelled", | ||
| 221 | expiresAt: 1, | ||
| 222 | }, | ||
| 223 | }; | ||
| 224 | let resolveMint: (result: typeof minted) => void = () => {}; | ||
| 225 | const pendingMint = new Promise<typeof minted>((resolve) => { | ||
| 226 | resolveMint = resolve; | ||
| 227 | }); | ||
| 228 | let resolveDelete: () => void = () => {}; | ||
| 229 | const deleted = new Promise<void>((resolve) => { | ||
| 230 | resolveDelete = resolve; | ||
| 231 | }); | ||
| 232 | mocks.runAtomCommand.mockImplementation((_registry: unknown, command: unknown) => { | ||
| 233 | if (command === mocks.createUploadUrl) { | ||
| 234 | return pendingMint; | ||
| 235 | } | ||
| 236 | resolveDelete(); | ||
| 237 | return Promise.resolve({ _tag: "Success", value: undefined }); | ||
| 238 | }); | ||
| 239 | |||
| 240 | startAttachmentUpload({ environmentId: firstEnvironment, image }); | ||
| 241 | releaseAttachmentUpload(image.id); | ||
| 242 | resolveMint(minted); | ||
| 243 | await deleted; | ||
| 244 | |||
| 245 | expect(TestXmlHttpRequest.requests).toEqual([]); | ||
| 246 | expect(readAttachmentUpload(image.id)).toBeUndefined(); | ||
| 247 | expect(mocks.runAtomCommand).toHaveBeenCalledWith( | ||
| 248 | expect.anything(), | ||
| 249 | mocks.removeUpload, | ||
| 250 | { | ||
| 251 | environmentId: firstEnvironment, | ||
| 252 | input: { attachmentId: minted.value.attachmentId }, | ||
| 253 | }, | ||
| 254 | expect.anything(), | ||
| 255 | ); | ||
| 256 | }); | ||
| 257 | |||
| 258 | it("restores the previous environment after a replacement upload fails", async () => { | ||
| 259 | const image = makeImage("image-move"); | ||
| 260 | startAttachmentUpload({ environmentId: firstEnvironment, image }); | ||
| 261 | await Promise.resolve(); | ||
| 262 | let settled = awaitAttachmentUploads([image.id]); | ||
| 263 | TestXmlHttpRequest.requests[0]!.complete(); | ||
| 264 | await settled; | ||
| 265 | |||
| 266 | startAttachmentUpload({ environmentId: secondEnvironment, image }); | ||
| 267 | await Promise.resolve(); | ||
| 268 | settled = awaitAttachmentUploads([image.id]); | ||
| 269 | TestXmlHttpRequest.requests[1]!.complete(500); | ||
| 270 | await settled; | ||
| 271 | |||
| 272 | startAttachmentUpload({ environmentId: firstEnvironment, image }); | ||
| 273 | expect(readAttachmentUpload(image.id)).toMatchObject({ | ||
| 274 | status: "ready", | ||
| 275 | environmentId: firstEnvironment, | ||
| 276 | attachmentId: "pending-environment-1-image-move.png", | ||
| 277 | }); | ||
| 278 | }); | ||
| 279 | |||
| 280 | it("does not let stalled uploads block another environment", async () => { | ||
| 281 | const images = ["image-a", "image-b", "image-c", "image-d"].map(makeImage); | ||
| 282 | for (const image of images) { | ||
| 283 | startAttachmentUpload({ environmentId: firstEnvironment, image }); | ||
| 284 | } | ||
| 285 | const otherEnvironmentImage = makeImage("image-other"); | ||
| 286 | startAttachmentUpload({ environmentId: secondEnvironment, image: otherEnvironmentImage }); | ||
| 287 | await Promise.resolve(); | ||
| 288 | |||
| 289 | expect(TestXmlHttpRequest.requests).toHaveLength(4); | ||
| 290 | const otherRequest = TestXmlHttpRequest.requests.find((request) => | ||
| 291 | request.url?.includes("environment-2"), | ||
| 292 | ); | ||
| 293 | expect(otherRequest).toBeDefined(); | ||
| 294 | |||
| 295 | for (const request of TestXmlHttpRequest.requests) { | ||
| 296 | request.complete(); | ||
| 297 | } | ||
| 298 | await Promise.all([ | ||
| 299 | ...images.slice(0, 3).map((image) => awaitAttachmentUploads([image.id])), | ||
| 300 | awaitAttachmentUploads([otherEnvironmentImage.id]), | ||
| 301 | ]); | ||
| 302 | await Promise.resolve(); | ||
| 303 | TestXmlHttpRequest.requests[4]!.complete(); | ||
| 304 | await awaitAttachmentUploads([images[3]!.id]); | ||
| 305 | }); | ||
| 306 | }); | ||
apps/web/src/lib/attachmentUploadQueue.ts created+389| ... | @@ -0,0 +1,389 @@ | ||
| 1 | import { | ||
| 2 | PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES, | ||
| 3 | type ChatAttachment, | ||
| 4 | type EnvironmentId, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 6 | import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; | ||
| 7 | import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; | ||
| 8 | import { create } from "zustand"; | ||
| 9 | |||
| 10 | import type { ComposerImageAttachment } from "../composerDraftStore"; | ||
| 11 | import { appAtomRegistry } from "../rpc/atomRegistry"; | ||
| 12 | import { attachmentEnvironment } from "../state/attachments"; | ||
| 13 | import { readPreparedConnection } from "../state/session"; | ||
| 14 | import type { AttachmentUploadState, ReadyAttachmentUpload } from "./attachmentUploadState"; | ||
| 15 | |||
| 16 | const MAX_UPLOADS_PER_ENVIRONMENT = 3; | ||
| 17 | const UPLOAD_TIMEOUT_MS = 5 * 60_000; | ||
| 18 | |||
| 19 | interface AttachmentUploadStore { | ||
| 20 | readonly uploadsByImageId: Readonly<Record<string, AttachmentUploadState>>; | ||
| 21 | } | ||
| 22 | |||
| 23 | export const useAttachmentUploadStore = create<AttachmentUploadStore>(() => ({ | ||
| 24 | uploadsByImageId: {}, | ||
| 25 | })); | ||
| 26 | |||
| 27 | interface UploadJob { | ||
| 28 | readonly image: ComposerImageAttachment; | ||
| 29 | readonly environmentId: EnvironmentId; | ||
| 30 | readonly previous?: ReadyAttachmentUpload; | ||
| 31 | readonly settled: Promise<void>; | ||
| 32 | resolveSettled: () => void; | ||
| 33 | attachmentId: string | null; | ||
| 34 | cancelled: boolean; | ||
| 35 | abort: (() => void) | null; | ||
| 36 | } | ||
| 37 | |||
| 38 | const jobsByImageId = new Map<string, UploadJob>(); | ||
| 39 | const queue: UploadJob[] = []; | ||
| 40 | const activeUploadsByEnvironment = new Map<EnvironmentId, number>(); | ||
| 41 | |||
| 42 | function setUploadState(imageId: string, upload: AttachmentUploadState): void { | ||
| 43 | useAttachmentUploadStore.setState((state) => ({ | ||
| 44 | uploadsByImageId: { ...state.uploadsByImageId, [imageId]: upload }, | ||
| 45 | })); | ||
| 46 | } | ||
| 47 | |||
| 48 | function clearUploadState(imageId: string): void { | ||
| 49 | useAttachmentUploadStore.setState((state) => { | ||
| 50 | if (!(imageId in state.uploadsByImageId)) { | ||
| 51 | return state; | ||
| 52 | } | ||
| 53 | const uploadsByImageId = { ...state.uploadsByImageId }; | ||
| 54 | delete uploadsByImageId[imageId]; | ||
| 55 | return { uploadsByImageId }; | ||
| 56 | }); | ||
| 57 | } | ||
| 58 | |||
| 59 | export function readAttachmentUpload(imageId: string): AttachmentUploadState | undefined { | ||
| 60 | return useAttachmentUploadStore.getState().uploadsByImageId[imageId]; | ||
| 61 | } | ||
| 62 | |||
| 63 | function deletePendingUpload(environmentId: EnvironmentId, attachmentId: string): void { | ||
| 64 | void runAtomCommand( | ||
| 65 | appAtomRegistry, | ||
| 66 | attachmentEnvironment.remove, | ||
| 67 | { environmentId, input: { attachmentId } }, | ||
| 68 | { reportFailure: false, reportDefect: false }, | ||
| 69 | ); | ||
| 70 | } | ||
| 71 | |||
| 72 | function uploadBytes(input: { | ||
| 73 | readonly url: string; | ||
| 74 | readonly file: File; | ||
| 75 | readonly onProgress: (progress: number) => void; | ||
| 76 | }): { readonly done: Promise<void>; readonly abort: () => void } { | ||
| 77 | const xhr = new XMLHttpRequest(); | ||
| 78 | const done = new Promise<void>((resolve, reject) => { | ||
| 79 | xhr.open("POST", input.url, true); | ||
| 80 | xhr.timeout = UPLOAD_TIMEOUT_MS; | ||
| 81 | xhr.setRequestHeader("Content-Type", input.file.type); | ||
| 82 | xhr.upload.addEventListener("progress", (event) => { | ||
| 83 | if (event.lengthComputable && event.total > 0) { | ||
| 84 | input.onProgress(event.loaded / event.total); | ||
| 85 | } | ||
| 86 | }); | ||
| 87 | xhr.addEventListener("load", () => { | ||
| 88 | if (xhr.status >= 200 && xhr.status < 300) { | ||
| 89 | resolve(); | ||
| 90 | } else { | ||
| 91 | reject(new Error(`Upload rejected (${xhr.status})`)); | ||
| 92 | } | ||
| 93 | }); | ||
| 94 | xhr.addEventListener("error", () => reject(new Error("Upload failed"))); | ||
| 95 | xhr.addEventListener("timeout", () => reject(new Error("Upload timed out"))); | ||
| 96 | xhr.addEventListener("abort", () => reject(new Error("Upload cancelled"))); | ||
| 97 | xhr.send(input.file); | ||
| 98 | }); | ||
| 99 | |||
| 100 | return { done, abort: () => xhr.abort() }; | ||
| 101 | } | ||
| 102 | |||
| 103 | async function runUpload(job: UploadJob): Promise<void> { | ||
| 104 | const mimeType = PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES.find( | ||
| 105 | (supportedMimeType) => supportedMimeType === job.image.mimeType.toLowerCase(), | ||
| 106 | ); | ||
| 107 | if (!mimeType) { | ||
| 108 | setUploadState(job.image.id, { | ||
| 109 | status: "failed", | ||
| 110 | environmentId: job.environmentId, | ||
| 111 | reason: "Unsupported image type", | ||
| 112 | ...(job.previous ? { previous: job.previous } : {}), | ||
| 113 | }); | ||
| 114 | return; | ||
| 115 | } | ||
| 116 | |||
| 117 | const minted = await runAtomCommand( | ||
| 118 | appAtomRegistry, | ||
| 119 | attachmentEnvironment.createUploadUrl, | ||
| 120 | { | ||
| 121 | environmentId: job.environmentId, | ||
| 122 | input: { | ||
| 123 | name: job.image.name, | ||
| 124 | mimeType, | ||
| 125 | sizeBytes: job.image.file.size, | ||
| 126 | }, | ||
| 127 | }, | ||
| 128 | { reportFailure: false }, | ||
| 129 | ); | ||
| 130 | if (job.cancelled) { | ||
| 131 | if (minted._tag === "Success") { | ||
| 132 | deletePendingUpload(job.environmentId, minted.value.attachmentId); | ||
| 133 | } | ||
| 134 | return; | ||
| 135 | } | ||
| 136 | if (minted._tag !== "Success") { | ||
| 137 | setUploadState(job.image.id, { | ||
| 138 | status: "failed", | ||
| 139 | environmentId: job.environmentId, | ||
| 140 | reason: "Upload could not start", | ||
| 141 | ...(job.previous ? { previous: job.previous } : {}), | ||
| 142 | }); | ||
| 143 | return; | ||
| 144 | } | ||
| 145 | job.attachmentId = minted.value.attachmentId; | ||
| 146 | |||
| 147 | const connection = readPreparedConnection(job.environmentId); | ||
| 148 | const url = connection ? resolveAssetUrl(connection.httpBaseUrl, minted.value.relativeUrl) : null; | ||
| 149 | if (!url) { | ||
| 150 | setUploadState(job.image.id, { | ||
| 151 | status: "failed", | ||
| 152 | environmentId: job.environmentId, | ||
| 153 | reason: "Not connected", | ||
| 154 | attachmentId: minted.value.attachmentId, | ||
| 155 | ...(job.previous ? { previous: job.previous } : {}), | ||
| 156 | }); | ||
| 157 | return; | ||
| 158 | } | ||
| 159 | |||
| 160 | let lastStep = -1; | ||
| 161 | const upload = uploadBytes({ | ||
| 162 | url, | ||
| 163 | file: job.image.file, | ||
| 164 | onProgress: (progress) => { | ||
| 165 | const step = Math.floor(progress * 20); | ||
| 166 | if (step === lastStep || job.cancelled) { | ||
| 167 | return; | ||
| 168 | } | ||
| 169 | lastStep = step; | ||
| 170 | setUploadState(job.image.id, { | ||
| 171 | status: "uploading", | ||
| 172 | environmentId: job.environmentId, | ||
| 173 | progress, | ||
| 174 | ...(job.previous ? { previous: job.previous } : {}), | ||
| 175 | }); | ||
| 176 | }, | ||
| 177 | }); | ||
| 178 | job.abort = upload.abort; | ||
| 179 | |||
| 180 | try { | ||
| 181 | await upload.done; | ||
| 182 | if (job.cancelled) { | ||
| 183 | return; | ||
| 184 | } | ||
| 185 | setUploadState(job.image.id, { | ||
| 186 | status: "ready", | ||
| 187 | environmentId: job.environmentId, | ||
| 188 | attachmentId: minted.value.attachmentId, | ||
| 189 | }); | ||
| 190 | if (job.previous) { | ||
| 191 | deletePendingUpload(job.previous.environmentId, job.previous.attachmentId); | ||
| 192 | } | ||
| 193 | } catch (error) { | ||
| 194 | if (job.cancelled) { | ||
| 195 | return; | ||
| 196 | } | ||
| 197 | setUploadState(job.image.id, { | ||
| 198 | status: "failed", | ||
| 199 | environmentId: job.environmentId, | ||
| 200 | reason: error instanceof Error ? error.message : "Upload failed", | ||
| 201 | attachmentId: minted.value.attachmentId, | ||
| 202 | ...(job.previous ? { previous: job.previous } : {}), | ||
| 203 | }); | ||
| 204 | } finally { | ||
| 205 | job.abort = null; | ||
| 206 | } | ||
| 207 | } | ||
| 208 | |||
| 209 | function pumpUploads(): void { | ||
| 210 | for (let index = 0; index < queue.length; ) { | ||
| 211 | const job = queue[index]!; | ||
| 212 | const active = activeUploadsByEnvironment.get(job.environmentId) ?? 0; | ||
| 213 | if (active >= MAX_UPLOADS_PER_ENVIRONMENT) { | ||
| 214 | index += 1; | ||
| 215 | continue; | ||
| 216 | } | ||
| 217 | |||
| 218 | queue.splice(index, 1); | ||
| 219 | if (job.cancelled) { | ||
| 220 | continue; | ||
| 221 | } | ||
| 222 | activeUploadsByEnvironment.set(job.environmentId, active + 1); | ||
| 223 | void runUpload(job) | ||
| 224 | .catch(() => { | ||
| 225 | if (!job.cancelled) { | ||
| 226 | setUploadState(job.image.id, { | ||
| 227 | status: "failed", | ||
| 228 | environmentId: job.environmentId, | ||
| 229 | reason: "Upload failed", | ||
| 230 | ...(job.previous ? { previous: job.previous } : {}), | ||
| 231 | }); | ||
| 232 | } | ||
| 233 | }) | ||
| 234 | .finally(() => { | ||
| 235 | if (jobsByImageId.get(job.image.id) === job) { | ||
| 236 | jobsByImageId.delete(job.image.id); | ||
| 237 | } | ||
| 238 | const remaining = (activeUploadsByEnvironment.get(job.environmentId) ?? 1) - 1; | ||
| 239 | if (remaining > 0) { | ||
| 240 | activeUploadsByEnvironment.set(job.environmentId, remaining); | ||
| 241 | } else { | ||
| 242 | activeUploadsByEnvironment.delete(job.environmentId); | ||
| 243 | } | ||
| 244 | job.resolveSettled(); | ||
| 245 | pumpUploads(); | ||
| 246 | }); | ||
| 247 | } | ||
| 248 | } | ||
| 249 | |||
| 250 | export function startAttachmentUpload(input: { | ||
| 251 | readonly environmentId: EnvironmentId; | ||
| 252 | readonly image: ComposerImageAttachment; | ||
| 253 | }): void { | ||
| 254 | const existingJob = jobsByImageId.get(input.image.id); | ||
| 255 | if (existingJob?.environmentId === input.environmentId) { | ||
| 256 | return; | ||
| 257 | } | ||
| 258 | |||
| 259 | const existing = readAttachmentUpload(input.image.id); | ||
| 260 | if (existing?.status === "ready" && existing.environmentId === input.environmentId) { | ||
| 261 | return; | ||
| 262 | } | ||
| 263 | if (existing?.status === "failed" && existing.environmentId === input.environmentId) { | ||
| 264 | return; | ||
| 265 | } | ||
| 266 | if ( | ||
| 267 | existing && | ||
| 268 | "previous" in existing && | ||
| 269 | existing.previous?.environmentId === input.environmentId | ||
| 270 | ) { | ||
| 271 | cancelAttachmentUpload(input.image.id); | ||
| 272 | if (existing.status === "failed" && existing.attachmentId) { | ||
| 273 | deletePendingUpload(existing.environmentId, existing.attachmentId); | ||
| 274 | } | ||
| 275 | setUploadState(input.image.id, existing.previous); | ||
| 276 | return; | ||
| 277 | } | ||
| 278 | |||
| 279 | if (existingJob) { | ||
| 280 | cancelAttachmentUpload(input.image.id); | ||
| 281 | } | ||
| 282 | const previous = existing?.status === "ready" ? existing : existing?.previous; | ||
| 283 | let resolveSettled: () => void = () => {}; | ||
| 284 | const settled = new Promise<void>((resolve) => { | ||
| 285 | resolveSettled = resolve; | ||
| 286 | }); | ||
| 287 | const job: UploadJob = { | ||
| 288 | image: input.image, | ||
| 289 | environmentId: input.environmentId, | ||
| 290 | ...(previous ? { previous } : {}), | ||
| 291 | settled, | ||
| 292 | resolveSettled, | ||
| 293 | attachmentId: null, | ||
| 294 | cancelled: false, | ||
| 295 | abort: null, | ||
| 296 | }; | ||
| 297 | |||
| 298 | jobsByImageId.set(input.image.id, job); | ||
| 299 | queue.push(job); | ||
| 300 | setUploadState(input.image.id, { | ||
| 301 | status: "uploading", | ||
| 302 | environmentId: input.environmentId, | ||
| 303 | progress: 0, | ||
| 304 | ...(previous ? { previous } : {}), | ||
| 305 | }); | ||
| 306 | pumpUploads(); | ||
| 307 | } | ||
| 308 | |||
| 309 | export function cancelAttachmentUpload(imageId: string): void { | ||
| 310 | const job = jobsByImageId.get(imageId); | ||
| 311 | if (!job) { | ||
| 312 | return; | ||
| 313 | } | ||
| 314 | job.cancelled = true; | ||
| 315 | jobsByImageId.delete(imageId); | ||
| 316 | const queuedIndex = queue.indexOf(job); | ||
| 317 | if (queuedIndex !== -1) { | ||
| 318 | queue.splice(queuedIndex, 1); | ||
| 319 | } | ||
| 320 | job.abort?.(); | ||
| 321 | if (job.attachmentId) { | ||
| 322 | deletePendingUpload(job.environmentId, job.attachmentId); | ||
| 323 | } | ||
| 324 | job.resolveSettled(); | ||
| 325 | } | ||
| 326 | |||
| 327 | export function releaseAttachmentUpload(imageId: string): void { | ||
| 328 | const upload = readAttachmentUpload(imageId); | ||
| 329 | cancelAttachmentUpload(imageId); | ||
| 330 | if (upload?.status === "ready") { | ||
| 331 | deletePendingUpload(upload.environmentId, upload.attachmentId); | ||
| 332 | } else if (upload) { | ||
| 333 | if (upload.status === "failed" && upload.attachmentId) { | ||
| 334 | deletePendingUpload(upload.environmentId, upload.attachmentId); | ||
| 335 | } | ||
| 336 | if (upload.previous) { | ||
| 337 | deletePendingUpload(upload.previous.environmentId, upload.previous.attachmentId); | ||
| 338 | } | ||
| 339 | } | ||
| 340 | clearUploadState(imageId); | ||
| 341 | } | ||
| 342 | |||
| 343 | export function retryAttachmentUpload(input: { | ||
| 344 | readonly environmentId: EnvironmentId; | ||
| 345 | readonly image: ComposerImageAttachment; | ||
| 346 | }): void { | ||
| 347 | const previous = readAttachmentUpload(input.image.id); | ||
| 348 | cancelAttachmentUpload(input.image.id); | ||
| 349 | if (previous?.status === "failed" && previous.attachmentId) { | ||
| 350 | deletePendingUpload(previous.environmentId, previous.attachmentId); | ||
| 351 | } | ||
| 352 | if (previous && "previous" in previous && previous.previous) { | ||
| 353 | setUploadState(input.image.id, previous.previous); | ||
| 354 | } else { | ||
| 355 | clearUploadState(input.image.id); | ||
| 356 | } | ||
| 357 | startAttachmentUpload(input); | ||
| 358 | } | ||
| 359 | |||
| 360 | export async function awaitAttachmentUploads(imageIds: ReadonlyArray<string>): Promise<void> { | ||
| 361 | await Promise.all(imageIds.map((imageId) => jobsByImageId.get(imageId)?.settled)); | ||
| 362 | } | ||
| 363 | |||
| 364 | export function getUploadedAttachments(input: { | ||
| 365 | readonly environmentId: EnvironmentId; | ||
| 366 | readonly images: ReadonlyArray<ComposerImageAttachment>; | ||
| 367 | }): ChatAttachment[] | null { | ||
| 368 | const attachments: ChatAttachment[] = []; | ||
| 369 | for (const image of input.images) { | ||
| 370 | const upload = readAttachmentUpload(image.id); | ||
| 371 | if (upload?.status !== "ready" || upload.environmentId !== input.environmentId) { | ||
| 372 | return null; | ||
| 373 | } | ||
| 374 | attachments.push({ | ||
| 375 | type: "image", | ||
| 376 | id: upload.attachmentId, | ||
| 377 | name: image.name, | ||
| 378 | mimeType: image.mimeType, | ||
| 379 | sizeBytes: image.sizeBytes, | ||
| 380 | }); | ||
| 381 | } | ||
| 382 | return attachments; | ||
| 383 | } | ||
| 384 | |||
| 385 | export function releaseAttachmentUploads(images: ReadonlyArray<ComposerImageAttachment>): void { | ||
| 386 | for (const image of images) { | ||
| 387 | releaseAttachmentUpload(image.id); | ||
| 388 | } | ||
| 389 | } | ||
apps/web/src/lib/attachmentUploadState.test.ts created+75| ... | @@ -0,0 +1,75 @@ | ||
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | import { describe, expect, it } from "vite-plus/test"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | attachmentUploadBlockReason, | ||
| 6 | formatAttachmentUploadProgress, | ||
| 7 | } from "./attachmentUploadState"; | ||
| 8 | |||
| 9 | const environmentId = EnvironmentId.make("environment-1"); | ||
| 10 | |||
| 11 | describe("attachmentUploadBlockReason", () => { | ||
| 12 | it("allows uploaded images from the active environment", () => { | ||
| 13 | expect( | ||
| 14 | attachmentUploadBlockReason({ | ||
| 15 | imageIds: ["image-1"], | ||
| 16 | environmentId, | ||
| 17 | uploadsByImageId: { | ||
| 18 | "image-1": { | ||
| 19 | status: "ready", | ||
| 20 | environmentId, | ||
| 21 | attachmentId: "pending-1", | ||
| 22 | }, | ||
| 23 | }, | ||
| 24 | }), | ||
| 25 | ).toBeNull(); | ||
| 26 | }); | ||
| 27 | |||
| 28 | it("blocks images that are missing or uploading", () => { | ||
| 29 | expect( | ||
| 30 | attachmentUploadBlockReason({ | ||
| 31 | imageIds: ["image-1", "image-2"], | ||
| 32 | environmentId, | ||
| 33 | uploadsByImageId: { | ||
| 34 | "image-1": { status: "uploading", environmentId, progress: 0.5 }, | ||
| 35 | }, | ||
| 36 | }), | ||
| 37 | ).toBe("Images still uploading"); | ||
| 38 | }); | ||
| 39 | |||
| 40 | it("asks the user to retry or remove failed uploads", () => { | ||
| 41 | expect( | ||
| 42 | attachmentUploadBlockReason({ | ||
| 43 | imageIds: ["image-1"], | ||
| 44 | environmentId, | ||
| 45 | uploadsByImageId: { | ||
| 46 | "image-1": { status: "failed", environmentId, reason: "Upload failed" }, | ||
| 47 | }, | ||
| 48 | }), | ||
| 49 | ).toBe("Retry or remove the failed image"); | ||
| 50 | }); | ||
| 51 | |||
| 52 | it("does not accept an upload from another environment", () => { | ||
| 53 | expect( | ||
| 54 | attachmentUploadBlockReason({ | ||
| 55 | imageIds: ["image-1"], | ||
| 56 | environmentId, | ||
| 57 | uploadsByImageId: { | ||
| 58 | "image-1": { | ||
| 59 | status: "ready", | ||
| 60 | environmentId: EnvironmentId.make("environment-2"), | ||
| 61 | attachmentId: "pending-1", | ||
| 62 | }, | ||
| 63 | }, | ||
| 64 | }), | ||
| 65 | ).toBe("Image still uploading"); | ||
| 66 | }); | ||
| 67 | }); | ||
| 68 | |||
| 69 | describe("formatAttachmentUploadProgress", () => { | ||
| 70 | it("formats bounded whole percentages", () => { | ||
| 71 | expect(formatAttachmentUploadProgress(0.429)).toBe("42%"); | ||
| 72 | expect(formatAttachmentUploadProgress(2)).toBe("100%"); | ||
| 73 | expect(formatAttachmentUploadProgress(Number.NaN)).toBe("0%"); | ||
| 74 | }); | ||
| 75 | }); | ||
apps/web/src/lib/attachmentUploadState.ts created+54| ... | @@ -0,0 +1,54 @@ | ||
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | export type ReadyAttachmentUpload = { | ||
| 4 | readonly status: "ready"; | ||
| 5 | readonly environmentId: EnvironmentId; | ||
| 6 | readonly attachmentId: string; | ||
| 7 | }; | ||
| 8 | |||
| 9 | export type AttachmentUploadState = | ||
| 10 | | { | ||
| 11 | readonly status: "uploading"; | ||
| 12 | readonly environmentId: EnvironmentId; | ||
| 13 | readonly progress: number; | ||
| 14 | readonly previous?: ReadyAttachmentUpload; | ||
| 15 | } | ||
| 16 | | ReadyAttachmentUpload | ||
| 17 | | { | ||
| 18 | readonly status: "failed"; | ||
| 19 | readonly environmentId: EnvironmentId; | ||
| 20 | readonly reason: string; | ||
| 21 | readonly attachmentId?: string; | ||
| 22 | readonly previous?: ReadyAttachmentUpload; | ||
| 23 | }; | ||
| 24 | |||
| 25 | export function attachmentUploadBlockReason(input: { | ||
| 26 | readonly imageIds: ReadonlyArray<string>; | ||
| 27 | readonly uploadsByImageId: Readonly<Record<string, AttachmentUploadState>>; | ||
| 28 | readonly environmentId: EnvironmentId; | ||
| 29 | }): string | null { | ||
| 30 | let pending = 0; | ||
| 31 | let failed = 0; | ||
| 32 | |||
| 33 | for (const imageId of input.imageIds) { | ||
| 34 | const upload = input.uploadsByImageId[imageId]; | ||
| 35 | if (upload?.status === "failed" && upload.environmentId === input.environmentId) { | ||
| 36 | failed += 1; | ||
| 37 | } else if (upload?.status !== "ready" || upload.environmentId !== input.environmentId) { | ||
| 38 | pending += 1; | ||
| 39 | } | ||
| 40 | } | ||
| 41 | |||
| 42 | if (failed > 0) { | ||
| 43 | return failed === 1 ? "Retry or remove the failed image" : "Retry or remove the failed images"; | ||
| 44 | } | ||
| 45 | if (pending > 0) { | ||
| 46 | return pending === 1 ? "Image still uploading" : "Images still uploading"; | ||
| 47 | } | ||
| 48 | return null; | ||
| 49 | } | ||
| 50 | |||
| 51 | export function formatAttachmentUploadProgress(progress: number): string { | ||
| 52 | const bounded = Math.max(0, Math.min(1, Number.isFinite(progress) ? progress : 0)); | ||
| 53 | return `${Math.floor(bounded * 100)}%`; | ||
| 54 | } | ||
apps/web/src/lib/composerDraftUploads.ts created+23| ... | @@ -0,0 +1,23 @@ | ||
| 1 | import type { ScopedProjectRef, ScopedThreadRef } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | import { type DraftId, useComposerDraftStore } from "../composerDraftStore"; | ||
| 4 | import { releaseAttachmentUploads } from "./attachmentUploadQueue"; | ||
| 5 | |||
| 6 | export function releaseComposerDraftUploads(target: ScopedThreadRef | DraftId): void { | ||
| 7 | const draft = useComposerDraftStore.getState().getComposerDraft(target); | ||
| 8 | if (draft) { | ||
| 9 | releaseAttachmentUploads(draft.images); | ||
| 10 | } | ||
| 11 | } | ||
| 12 | |||
| 13 | export function releaseProjectDraftUploads(projectRef: ScopedProjectRef): void { | ||
| 14 | const store = useComposerDraftStore.getState(); | ||
| 15 | for (const [draftKey, session] of Object.entries(store.draftThreadsByThreadKey)) { | ||
| 16 | if ( | ||
| 17 | session.environmentId === projectRef.environmentId && | ||
| 18 | session.projectId === projectRef.projectId | ||
| 19 | ) { | ||
| 20 | releaseAttachmentUploads(store.draftsByThreadKey[draftKey]?.images ?? []); | ||
| 21 | } | ||
| 22 | } | ||
| 23 | } | ||
apps/web/src/state/attachments.ts created+15| ... | @@ -0,0 +1,15 @@ | ||
| 1 | import { WS_METHODS } from "@t3tools/contracts"; | ||
| 2 | import { createEnvironmentRpcCommand } from "@t3tools/client-runtime/state/runtime"; | ||
| 3 | |||
| 4 | import { connectionAtomRuntime } from "../connection/runtime"; | ||
| 5 | |||
| 6 | export const attachmentEnvironment = { | ||
| 7 | createUploadUrl: createEnvironmentRpcCommand(connectionAtomRuntime, { | ||
| 8 | label: "environment-command:attachments:create-upload-url", | ||
| 9 | tag: WS_METHODS.attachmentsCreateUploadUrl, | ||
| 10 | }), | ||
| 11 | remove: createEnvironmentRpcCommand(connectionAtomRuntime, { | ||
| 12 | label: "environment-command:attachments:delete", | ||
| 13 | tag: WS_METHODS.attachmentsDelete, | ||
| 14 | }), | ||
| 15 | }; | ||
docs/user/composer.md+3| ... | @@ -4,6 +4,9 @@ Messages can contain up to 120,000 characters. If a draft is longer, T3 Code kee | ... | @@ -4,6 +4,9 @@ Messages can contain up to 120,000 characters. If a draft is longer, T3 Code kee |
| 4 | composer and shows how many characters need to be removed. Shorten the draft or split it into | 4 | composer and shows how many characters need to be removed. Shorten the draft or split it into |
| 5 | multiple messages, then send again in the same thread. | 5 | multiple messages, then send again in the same thread. |
| 6 | 6 | ||
| 7 | On servers that support direct uploads, images upload as soon as you add them. The send button | ||
| 8 | becomes available after every upload finishes. Failed uploads can be retried or removed. | ||
| 9 | |||
| 7 | ## Commands and skills | 10 | ## Commands and skills |
| 8 | 11 | ||
| 9 | Type `/` to open the command menu. Type `$` to find and add a skill. Skill rows show their source, | 12 | Type `/` to open the command menu. Type `$` to find and add a skill. Skill rows show their source, |
packages/contracts/src/assets.test.ts created+30| ... | @@ -0,0 +1,30 @@ | ||
| 1 | import * as Schema from "effect/Schema"; | ||
| 2 | import { describe, expect, it } from "vite-plus/test"; | ||
| 3 | |||
| 4 | import { AttachmentCreateUploadUrlInput } from "./assets.ts"; | ||
| 5 | import { PROVIDER_SEND_TURN_MAX_IMAGE_BYTES } from "./orchestration.ts"; | ||
| 6 | |||
| 7 | const isUploadInput = Schema.is(AttachmentCreateUploadUrlInput); | ||
| 8 | |||
| 9 | const uploadInput = { | ||
| 10 | name: "screenshot.png", | ||
| 11 | mimeType: "image/png", | ||
| 12 | sizeBytes: 3, | ||
| 13 | } as const; | ||
| 14 | |||
| 15 | describe("AttachmentCreateUploadUrlInput", () => { | ||
| 16 | it("accepts supported image attachments", () => { | ||
| 17 | expect(isUploadInput(uploadInput)).toBe(true); | ||
| 18 | }); | ||
| 19 | |||
| 20 | it("rejects image types that providers do not support", () => { | ||
| 21 | expect(isUploadInput({ ...uploadInput, mimeType: "image/svg+xml" })).toBe(false); | ||
| 22 | }); | ||
| 23 | |||
| 24 | it("rejects empty and oversized uploads", () => { | ||
| 25 | expect(isUploadInput({ ...uploadInput, sizeBytes: 0 })).toBe(false); | ||
| 26 | expect( | ||
| 27 | isUploadInput({ ...uploadInput, sizeBytes: PROVIDER_SEND_TURN_MAX_IMAGE_BYTES + 1 }), | ||
| 28 | ).toBe(false); | ||
| 29 | }); | ||
| 30 | }); | ||
packages/contracts/src/assets.ts+41-2| ... | @@ -1,7 +1,11 @@ | ... | @@ -1,7 +1,11 @@ |
| 1 | import * as Schema from "effect/Schema"; | 1 | import * as Schema from "effect/Schema"; |
| 2 | 2 | ||
| 3 | import { ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; | 3 | import { NonNegativeInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; |
| 4 | import { ProjectFaviconPath } from "./orchestration.ts"; | 4 | import { |
| 5 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, | ||
| 6 | PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES, | ||
| 7 | ProjectFaviconPath, | ||
| 8 | } from "./orchestration.ts"; | ||
| 5 | 9 | ||
| 6 | const ASSET_PATH_MAX_LENGTH = 1024; | 10 | const ASSET_PATH_MAX_LENGTH = 1024; |
| 7 | 11 | ||
| ... | @@ -36,6 +40,41 @@ export const AssetCreateUrlResult = Schema.Struct({ | ... | @@ -36,6 +40,41 @@ export const AssetCreateUrlResult = Schema.Struct({ |
| 36 | }); | 40 | }); |
| 37 | export type AssetCreateUrlResult = typeof AssetCreateUrlResult.Type; | 41 | export type AssetCreateUrlResult = typeof AssetCreateUrlResult.Type; |
| 38 | 42 | ||
| 43 | export const ATTACHMENT_UPLOAD_URL_TTL_MS = 10 * 60_000; | ||
| 44 | |||
| 45 | export const AttachmentCreateUploadUrlInput = Schema.Struct({ | ||
| 46 | name: TrimmedNonEmptyString.check(Schema.isMaxLength(255)), | ||
| 47 | mimeType: Schema.Literals(PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES), | ||
| 48 | sizeBytes: NonNegativeInt.check( | ||
| 49 | Schema.isGreaterThanOrEqualTo(1), | ||
| 50 | Schema.isLessThanOrEqualTo(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES), | ||
| 51 | ), | ||
| 52 | }); | ||
| 53 | export type AttachmentCreateUploadUrlInput = typeof AttachmentCreateUploadUrlInput.Type; | ||
| 54 | |||
| 55 | export const AttachmentCreateUploadUrlResult = Schema.Struct({ | ||
| 56 | attachmentId: TrimmedNonEmptyString.check(Schema.isMaxLength(256)), | ||
| 57 | relativeUrl: TrimmedNonEmptyString.check(Schema.isMaxLength(4096)), | ||
| 58 | expiresAt: Schema.Number, | ||
| 59 | }); | ||
| 60 | export type AttachmentCreateUploadUrlResult = typeof AttachmentCreateUploadUrlResult.Type; | ||
| 61 | |||
| 62 | export const AttachmentDeleteInput = Schema.Struct({ | ||
| 63 | attachmentId: TrimmedNonEmptyString.check(Schema.isMaxLength(256)), | ||
| 64 | }); | ||
| 65 | export type AttachmentDeleteInput = typeof AttachmentDeleteInput.Type; | ||
| 66 | |||
| 67 | export class AttachmentUploadSigningKeyError extends Schema.TaggedErrorClass<AttachmentUploadSigningKeyError>()( | ||
| 68 | "AttachmentUploadSigningKeyError", | ||
| 69 | { | ||
| 70 | cause: Schema.Defect(), | ||
| 71 | }, | ||
| 72 | ) { | ||
| 73 | override get message(): string { | ||
| 74 | return "Failed to load the attachment upload signing key."; | ||
| 75 | } | ||
| 76 | } | ||
| 77 | |||
| 39 | export class AssetWorkspaceContextNotFoundError extends Schema.TaggedErrorClass<AssetWorkspaceContextNotFoundError>()( | 78 | export class AssetWorkspaceContextNotFoundError extends Schema.TaggedErrorClass<AssetWorkspaceContextNotFoundError>()( |
| 40 | "AssetWorkspaceContextNotFoundError", | 79 | "AssetWorkspaceContextNotFoundError", |
| 41 | { | 80 | { |
packages/contracts/src/environment.test.ts+13| ... | @@ -26,4 +26,17 @@ describe("ExecutionEnvironmentDescriptor", () => { | ... | @@ -26,4 +26,17 @@ describe("ExecutionEnvironmentDescriptor", () => { |
| 26 | }).capabilities.pullRequests, | 26 | }).capabilities.pullRequests, |
| 27 | ).toBe(true); | 27 | ).toBe(true); |
| 28 | }); | 28 | }); |
| 29 | |||
| 30 | it("treats a missing attachment upload capability as unsupported", () => { | ||
| 31 | expect(decodeDescriptor(descriptor).capabilities.attachmentUploads).toBeUndefined(); | ||
| 32 | }); | ||
| 33 | |||
| 34 | it("preserves an advertised attachment upload capability", () => { | ||
| 35 | expect( | ||
| 36 | decodeDescriptor({ | ||
| 37 | ...descriptor, | ||
| 38 | capabilities: { ...descriptor.capabilities, attachmentUploads: true }, | ||
| 39 | }).capabilities.attachmentUploads, | ||
| 40 | ).toBe(true); | ||
| 41 | }); | ||
| 29 | }); | 42 | }); |
packages/contracts/src/environment.ts+2| ... | @@ -48,6 +48,8 @@ export type ServerSelfUpdateCapability = typeof ServerSelfUpdateCapability.Type; | ... | @@ -48,6 +48,8 @@ export type ServerSelfUpdateCapability = typeof ServerSelfUpdateCapability.Type; |
| 48 | export const ExecutionEnvironmentCapabilities = Schema.Struct({ | 48 | export const ExecutionEnvironmentCapabilities = Schema.Struct({ |
| 49 | repositoryIdentity: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), | 49 | repositoryIdentity: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), |
| 50 | connectionProbe: Schema.optionalKey(Schema.Boolean), | 50 | connectionProbe: Schema.optionalKey(Schema.Boolean), |
| 51 | /** Missing on older servers, which still accept inline image attachments. */ | ||
| 52 | attachmentUploads: Schema.optionalKey(Schema.Boolean), | ||
| 51 | /** Server exposes the pull-request list, detail, activity, diff, and mutation APIs. Absent on | 53 | /** Server exposes the pull-request list, detail, activity, diff, and mutation APIs. Absent on |
| 52 | servers from before the pull-request workspace shipped, so clients must not probe them. */ | 54 | servers from before the pull-request workspace shipped, so clients must not probe them. */ |
| 53 | pullRequests: Schema.optionalKey(Schema.Boolean), | 55 | pullRequests: Schema.optionalKey(Schema.Boolean), |
packages/contracts/src/orchestration.test.ts+43| ... | @@ -5,6 +5,7 @@ import * as Schema from "effect/Schema"; | ... | @@ -5,6 +5,7 @@ import * as Schema from "effect/Schema"; |
| 5 | import { | 5 | import { |
| 6 | DEFAULT_PROVIDER_INTERACTION_MODE, | 6 | DEFAULT_PROVIDER_INTERACTION_MODE, |
| 7 | DEFAULT_RUNTIME_MODE, | 7 | DEFAULT_RUNTIME_MODE, |
| 8 | ClientOrchestrationCommand, | ||
| 8 | ModelSelection, | 9 | ModelSelection, |
| 9 | OrchestrationCommand, | 10 | OrchestrationCommand, |
| 10 | OrchestrationDispatchCommandError, | 11 | OrchestrationDispatchCommandError, |
| ... | @@ -35,6 +36,7 @@ const decodeProjectCreateCommand = Schema.decodeUnknownEffect(ProjectCreateComma | ... | @@ -35,6 +36,7 @@ const decodeProjectCreateCommand = Schema.decodeUnknownEffect(ProjectCreateComma |
| 35 | const decodeProjectCreatedPayload = Schema.decodeUnknownEffect(ProjectCreatedPayload); | 36 | const decodeProjectCreatedPayload = Schema.decodeUnknownEffect(ProjectCreatedPayload); |
| 36 | const decodeProjectMetaUpdatedPayload = Schema.decodeUnknownEffect(ProjectMetaUpdatedPayload); | 37 | const decodeProjectMetaUpdatedPayload = Schema.decodeUnknownEffect(ProjectMetaUpdatedPayload); |
| 37 | const decodeThreadTurnStartCommand = Schema.decodeUnknownEffect(ThreadTurnStartCommand); | 38 | const decodeThreadTurnStartCommand = Schema.decodeUnknownEffect(ThreadTurnStartCommand); |
| 39 | const decodeClientOrchestrationCommand = Schema.decodeUnknownEffect(ClientOrchestrationCommand); | ||
| 38 | const decodeThreadTurnStartRequestedPayload = Schema.decodeUnknownEffect( | 40 | const decodeThreadTurnStartRequestedPayload = Schema.decodeUnknownEffect( |
| 39 | ThreadTurnStartRequestedPayload, | 41 | ThreadTurnStartRequestedPayload, |
| 40 | ); | 42 | ); |
| ... | @@ -241,6 +243,47 @@ it.effect("decodes thread.turn.start defaults for provider and runtime mode", () | ... | @@ -241,6 +243,47 @@ it.effect("decodes thread.turn.start defaults for provider and runtime mode", () |
| 241 | }), | 243 | }), |
| 242 | ); | 244 | ); |
| 243 | 245 | ||
| 246 | it.effect("accepts both inline and uploaded image attachments from clients", () => | ||
| 247 | Effect.gen(function* () { | ||
| 248 | const command = yield* decodeClientOrchestrationCommand({ | ||
| 249 | type: "thread.turn.start", | ||
| 250 | commandId: "cmd-turn-attachments", | ||
| 251 | threadId: "thread-1", | ||
| 252 | message: { | ||
| 253 | messageId: "msg-attachments", | ||
| 254 | role: "user", | ||
| 255 | text: "hello", | ||
| 256 | attachments: [ | ||
| 257 | { | ||
| 258 | type: "image", | ||
| 259 | name: "legacy.png", | ||
| 260 | mimeType: "image/png", | ||
| 261 | sizeBytes: 3, | ||
| 262 | dataUrl: "data:image/png;base64,YWJj", | ||
| 263 | }, | ||
| 264 | { | ||
| 265 | type: "image", | ||
| 266 | id: "pending-00000000-0000-4000-8000-000000000001", | ||
| 267 | name: "uploaded.png", | ||
| 268 | mimeType: "image/png", | ||
| 269 | sizeBytes: 3, | ||
| 270 | }, | ||
| 271 | ], | ||
| 272 | }, | ||
| 273 | runtimeMode: "full-access", | ||
| 274 | interactionMode: "default", | ||
| 275 | createdAt: "2026-01-01T00:00:00.000Z", | ||
| 276 | }); | ||
| 277 | |||
| 278 | if (command.type !== "thread.turn.start") { | ||
| 279 | assert.fail(`Expected thread.turn.start, received ${command.type}.`); | ||
| 280 | } | ||
| 281 | assert.strictEqual(command.message.attachments.length, 2); | ||
| 282 | assert.strictEqual("dataUrl" in command.message.attachments[0]!, true); | ||
| 283 | assert.strictEqual("id" in command.message.attachments[1]!, true); | ||
| 284 | }), | ||
| 285 | ); | ||
| 286 | |||
| 244 | it.effect("preserves explicit provider and runtime mode in thread.turn.start", () => | 287 | it.effect("preserves explicit provider and runtime mode in thread.turn.start", () => |
| 245 | Effect.gen(function* () { | 288 | Effect.gen(function* () { |
| 246 | const parsed = yield* decodeThreadTurnStartCommand({ | 289 | const parsed = yield* decodeThreadTurnStartCommand({ |
packages/contracts/src/orchestration.ts+1-1| ... | @@ -863,7 +863,7 @@ const ClientThreadTurnStartCommand = Schema.Struct({ | ... | @@ -863,7 +863,7 @@ const ClientThreadTurnStartCommand = Schema.Struct({ |
| 863 | messageId: MessageId, | 863 | messageId: MessageId, |
| 864 | role: Schema.Literal("user"), | 864 | role: Schema.Literal("user"), |
| 865 | text: Schema.String, | 865 | text: Schema.String, |
| 866 | attachments: Schema.Array(UploadChatAttachment), | 866 | attachments: Schema.Array(Schema.Union([UploadChatAttachment, ChatAttachment])), |
| 867 | }), | 867 | }), |
| 868 | modelSelection: Schema.optional(ModelSelection), | 868 | modelSelection: Schema.optional(ModelSelection), |
| 869 | titleSeed: Schema.optional(TrimmedNonEmptyString), | 869 | titleSeed: Schema.optional(TrimmedNonEmptyString), |
packages/contracts/src/rpc.ts+24-1| ... | @@ -18,7 +18,15 @@ import { | ... | @@ -18,7 +18,15 @@ import { |
| 18 | FilesystemBrowseResult, | 18 | FilesystemBrowseResult, |
| 19 | FilesystemBrowseError, | 19 | FilesystemBrowseError, |
| 20 | } from "./filesystem.ts"; | 20 | } from "./filesystem.ts"; |
| 21 | import { AssetAccessError, AssetCreateUrlInput, AssetCreateUrlResult } from "./assets.ts"; | 21 | import { |
| 22 | AssetAccessError, | ||
| 23 | AssetCreateUrlInput, | ||
| 24 | AssetCreateUrlResult, | ||
| 25 | AttachmentCreateUploadUrlInput, | ||
| 26 | AttachmentCreateUploadUrlResult, | ||
| 27 | AttachmentDeleteInput, | ||
| 28 | AttachmentUploadSigningKeyError, | ||
| 29 | } from "./assets.ts"; | ||
| 22 | import { | 30 | import { |
| 23 | GitActionProgressEvent, | 31 | GitActionProgressEvent, |
| 24 | VcsSwitchRefInput, | 32 | VcsSwitchRefInput, |
| ... | @@ -215,6 +223,8 @@ export const WS_METHODS = { | ... | @@ -215,6 +223,8 @@ export const WS_METHODS = { |
| 215 | // Filesystem methods | 223 | // Filesystem methods |
| 216 | filesystemBrowse: "filesystem.browse", | 224 | filesystemBrowse: "filesystem.browse", |
| 217 | assetsCreateUrl: "assets.createUrl", | 225 | assetsCreateUrl: "assets.createUrl", |
| 226 | attachmentsCreateUploadUrl: "attachments.createUploadUrl", | ||
| 227 | attachmentsDelete: "attachments.delete", | ||
| 218 | 228 | ||
| 219 | // Provider methods | 229 | // Provider methods |
| 220 | providerUploadFeedback: "provider.uploadFeedback", | 230 | providerUploadFeedback: "provider.uploadFeedback", |
| ... | @@ -673,6 +683,17 @@ export const WsAssetsCreateUrlRpc = Rpc.make(WS_METHODS.assetsCreateUrl, { | ... | @@ -673,6 +683,17 @@ export const WsAssetsCreateUrlRpc = Rpc.make(WS_METHODS.assetsCreateUrl, { |
| 673 | error: Schema.Union([AssetAccessError, EnvironmentAuthorizationError]), | 683 | error: Schema.Union([AssetAccessError, EnvironmentAuthorizationError]), |
| 674 | }); | 684 | }); |
| 675 | 685 | ||
| 686 | export const WsAttachmentsCreateUploadUrlRpc = Rpc.make(WS_METHODS.attachmentsCreateUploadUrl, { | ||
| 687 | payload: AttachmentCreateUploadUrlInput, | ||
| 688 | success: AttachmentCreateUploadUrlResult, | ||
| 689 | error: Schema.Union([AttachmentUploadSigningKeyError, EnvironmentAuthorizationError]), | ||
| 690 | }); | ||
| 691 | |||
| 692 | export const WsAttachmentsDeleteRpc = Rpc.make(WS_METHODS.attachmentsDelete, { | ||
| 693 | payload: AttachmentDeleteInput, | ||
| 694 | error: EnvironmentAuthorizationError, | ||
| 695 | }); | ||
| 696 | |||
| 676 | export const WsProviderUploadFeedbackRpc = Rpc.make(WS_METHODS.providerUploadFeedback, { | 697 | export const WsProviderUploadFeedbackRpc = Rpc.make(WS_METHODS.providerUploadFeedback, { |
| 677 | payload: ProviderUploadFeedbackInput, | 698 | payload: ProviderUploadFeedbackInput, |
| 678 | success: ProviderUploadFeedbackResult, | 699 | success: ProviderUploadFeedbackResult, |
| ... | @@ -1048,6 +1069,8 @@ export const WsRpcGroup = RpcGroup.make( | ... | @@ -1048,6 +1069,8 @@ export const WsRpcGroup = RpcGroup.make( |
| 1048 | WsShellOpenInEditorRpc, | 1069 | WsShellOpenInEditorRpc, |
| 1049 | WsFilesystemBrowseRpc, | 1070 | WsFilesystemBrowseRpc, |
| 1050 | WsAssetsCreateUrlRpc, | 1071 | WsAssetsCreateUrlRpc, |
| 1072 | WsAttachmentsCreateUploadUrlRpc, | ||
| 1073 | WsAttachmentsDeleteRpc, | ||
| 1051 | WsProviderUploadFeedbackRpc, | 1074 | WsProviderUploadFeedbackRpc, |
| 1052 | WsSubscribeVcsStatusRpc, | 1075 | WsSubscribeVcsStatusRpc, |
| 1053 | WsVcsPullRpc, | 1076 | WsVcsPullRpc, |