From ea16367b30b3b61ec7ca0a28cb3bf6c86ac70914 Mon Sep 17 00:00:00 2001 From: Mark S <49048586+msegec@users.noreply.github.com> Date: Tue, 25 Aug 2026 07:12:49 +0800 Subject: [PATCH 01/25] feat(contracts): add projects.createUploadUrl for workspace uploads --- apps/server/src/auth/RpcAuthorization.ts | 1 + apps/server/src/ws.ts | 6 ++++ packages/contracts/src/project.ts | 40 ++++++++++++++++++++++++ packages/contracts/src/rpc.ts | 16 ++++++++++ 4 files changed, 63 insertions(+) diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 28ceac4cec99..f64d14784aff 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -81,6 +81,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.projectsSearchContents]: AuthOrchestrationReadScope, [WS_METHODS.projectsSearchEntries]: AuthOrchestrationReadScope, [WS_METHODS.projectsWriteFile]: AuthOrchestrationOperateScope, + [WS_METHODS.projectsCreateUploadUrl]: AuthOrchestrationOperateScope, [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope, [WS_METHODS.filesystemBrowse]: AuthOrchestrationReadScope, [WS_METHODS.assetsCreateUrl]: AuthOrchestrationReadScope, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 55b0be07c667..ff4d012bd6fd 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -34,6 +34,7 @@ import { OrchestrationGetTurnDiffError, ORCHESTRATION_WS_METHODS, type ProjectId, + ProjectCreateUploadUrlError, type ProjectEntriesFailure, type ProjectFileFailure, type ProjectFileOperation, @@ -1938,6 +1939,11 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "workspace" }, ), + // Task 3 replaces this with the real handler. + [WS_METHODS.projectsCreateUploadUrl]: () => + Effect.fail( + new ProjectCreateUploadUrlError({ message: "Workspace uploads are not wired up yet." }), + ), [WS_METHODS.shellOpenInEditor]: (input) => observeRpcEffect(WS_METHODS.shellOpenInEditor, externalLauncher.launchEditor(input), { "rpc.aggregate": "workspace", diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index 757c000a065a..e036fb0ea387 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -298,3 +298,43 @@ export class ProjectWriteFileError extends Schema.TaggedErrorClass()( + "ProjectUploadTargetExistsError", + { + cwd: TrimmedNonEmptyString, + relativePath: TrimmedNonEmptyString, + }, +) { + override get message(): string { + return `A file already exists at '${this.relativePath}' in '${this.cwd}'.`; + } +} + +export class ProjectCreateUploadUrlError extends Schema.TaggedErrorClass()( + "ProjectCreateUploadUrlError", + { + cwd: Schema.optional(TrimmedNonEmptyString), + relativePath: Schema.optional(TrimmedNonEmptyString), + message: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect()), + }, +) {} diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 14363cfedff9..0e3d8600532c 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -112,6 +112,9 @@ import { RelayClientStatusSchema, } from "./relayClient.ts"; import { + ProjectCreateUploadUrlError, + ProjectCreateUploadUrlInput, + ProjectCreateUploadUrlResult, ProjectListEntriesError, ProjectListEntriesInput, ProjectListEntriesResult, @@ -124,6 +127,7 @@ import { ProjectSearchEntriesError, ProjectSearchEntriesInput, ProjectSearchEntriesResult, + ProjectUploadTargetExistsError, ProjectWriteFileError, ProjectWriteFileInput, ProjectWriteFileResult, @@ -216,6 +220,7 @@ export const WS_METHODS = { projectsSearchContents: "projects.searchContents", projectsSearchEntries: "projects.searchEntries", projectsWriteFile: "projects.writeFile", + projectsCreateUploadUrl: "projects.createUploadUrl", // Shell methods shellOpenInEditor: "shell.openInEditor", @@ -666,6 +671,16 @@ export const WsProjectsWriteFileRpc = Rpc.make(WS_METHODS.projectsWriteFile, { error: Schema.Union([ProjectWriteFileError, EnvironmentAuthorizationError]), }); +export const WsProjectsCreateUploadUrlRpc = Rpc.make(WS_METHODS.projectsCreateUploadUrl, { + payload: ProjectCreateUploadUrlInput, + success: ProjectCreateUploadUrlResult, + error: Schema.Union([ + ProjectCreateUploadUrlError, + ProjectUploadTargetExistsError, + EnvironmentAuthorizationError, + ]), +}); + export const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, { payload: LaunchEditorInput, error: Schema.Union([ExternalLauncherError, EnvironmentAuthorizationError]), @@ -1066,6 +1081,7 @@ export const WsRpcGroup = RpcGroup.make( WsProjectsSearchContentsRpc, WsProjectsSearchEntriesRpc, WsProjectsWriteFileRpc, + WsProjectsCreateUploadUrlRpc, WsShellOpenInEditorRpc, WsFilesystemBrowseRpc, WsAssetsCreateUrlRpc, From 552e1a0317e217ec4d8579b7032f02d8f80eca0c Mon Sep 17 00:00:00 2001 From: Mark S <49048586+msegec@users.noreply.github.com> Date: Tue, 25 Aug 2026 07:18:56 +0800 Subject: [PATCH 02/25] feat(server): sign and store workspace file uploads --- .../src/workspace/WorkspaceUpload.test.ts | 216 ++++++++++++++++ apps/server/src/workspace/WorkspaceUpload.ts | 230 ++++++++++++++++++ 2 files changed, 446 insertions(+) create mode 100644 apps/server/src/workspace/WorkspaceUpload.test.ts create mode 100644 apps/server/src/workspace/WorkspaceUpload.ts diff --git a/apps/server/src/workspace/WorkspaceUpload.test.ts b/apps/server/src/workspace/WorkspaceUpload.test.ts new file mode 100644 index 000000000000..862be40e36e2 --- /dev/null +++ b/apps/server/src/workspace/WorkspaceUpload.test.ts @@ -0,0 +1,216 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import { PROJECT_UPLOAD_URL_TTL_MS } from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; + +import { base64UrlEncode, signPayload } from "../auth/utils.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ServerConfig from "../config.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as WorkspaceEntries from "./WorkspaceEntries.ts"; +import * as WorkspacePaths from "./WorkspacePaths.ts"; +import { + WORKSPACE_UPLOAD_ROUTE_PREFIX, + issueWorkspaceUploadUrl, + storeWorkspaceUpload, + validateWorkspaceUploadToken, +} from "./WorkspaceUpload.ts"; + +const testLayer = Layer.empty.pipe( + Layer.provideMerge(ServerSecretStore.layer), + Layer.provideMerge(WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePaths.layer))), + Layer.provideMerge(WorkspacePaths.layer), + Layer.provideMerge(VcsProcess.layer), + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-workspace-upload-test-" })), + Layer.provideMerge(NodeServices.layer), +); + +const makeTempWorkspaceRoot = Effect.fn("makeTempWorkspaceRoot")(function* () { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-workspace-upload-", + }); +}); + +function tokenFromRelativeUrl(relativeUrl: string): string { + return relativeUrl.slice(`${WORKSPACE_UPLOAD_ROUTE_PREFIX}/`.length); +} + +const AttachmentUploadClaimsForTest = Schema.Struct({ + version: Schema.Literal(1), + kind: Schema.Literal("attachment-upload"), + attachmentId: Schema.String, + name: Schema.String, + mimeType: Schema.String, + sizeBytes: Schema.Number, + expiresAt: Schema.Number, +}); +const encodeAttachmentUploadClaimsForTest = Schema.encodeSync( + Schema.fromJsonString(AttachmentUploadClaimsForTest), +); + +describe("WorkspaceUpload", () => { + it.effect("mints, validates, and stores an upload roundtrip", () => + Effect.gen(function* () { + const cwd = yield* makeTempWorkspaceRoot(); + const bytes = new Uint8Array([1, 2, 3, 4]); + + const issued = yield* issueWorkspaceUploadUrl({ + cwd, + relativePath: "sub/dir/file.bin", + sizeBytes: bytes.byteLength, + }); + expect(issued.relativePath).toBe("sub/dir/file.bin"); + + const claims = yield* validateWorkspaceUploadToken(tokenFromRelativeUrl(issued.relativeUrl)); + if (!claims) { + throw new Error("Expected valid upload claims."); + } + + const result = yield* storeWorkspaceUpload(claims, bytes); + expect(result).toEqual({ ok: true, relativePath: "sub/dir/file.bin" }); + + const finalPath = NodePath.join(cwd, "sub/dir/file.bin"); + expect(NodeFS.existsSync(finalPath)).toBe(true); + expect(NodeFS.readFileSync(finalPath)).toEqual(Buffer.from(bytes)); + const siblingEntries = NodeFS.readdirSync(NodePath.dirname(finalPath)); + expect(siblingEntries.some((entry) => entry.endsWith(".part"))).toBe(false); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("rejects a mint target that escapes the workspace root", () => + Effect.gen(function* () { + const cwd = yield* makeTempWorkspaceRoot(); + + const error = yield* issueWorkspaceUploadUrl({ + cwd, + relativePath: "../outside.txt", + sizeBytes: 3, + }).pipe(Effect.flip); + + expect(error._tag).toBe("ProjectCreateUploadUrlError"); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("rejects mint on an existing file without overwrite, allows it with overwrite", () => + Effect.gen(function* () { + const cwd = yield* makeTempWorkspaceRoot(); + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.writeFileString(NodePath.join(cwd, "existing.txt"), "old"); + + const rejected = yield* issueWorkspaceUploadUrl({ + cwd, + relativePath: "existing.txt", + sizeBytes: 3, + }).pipe(Effect.flip); + expect(rejected._tag).toBe("ProjectUploadTargetExistsError"); + + const bytes = new Uint8Array([9, 9, 9]); + const issued = yield* issueWorkspaceUploadUrl({ + cwd, + relativePath: "existing.txt", + sizeBytes: bytes.byteLength, + overwrite: true, + }); + const claims = yield* validateWorkspaceUploadToken(tokenFromRelativeUrl(issued.relativeUrl)); + if (!claims) { + throw new Error("Expected valid upload claims."); + } + + const result = yield* storeWorkspaceUpload(claims, bytes); + expect(result).toEqual({ ok: true, relativePath: "existing.txt" }); + expect(NodeFS.readFileSync(NodePath.join(cwd, "existing.txt"))).toEqual(Buffer.from(bytes)); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("rejects a store body whose size does not match the claims", () => + Effect.gen(function* () { + const cwd = yield* makeTempWorkspaceRoot(); + const issued = yield* issueWorkspaceUploadUrl({ + cwd, + relativePath: "file.bin", + sizeBytes: 4, + }); + const claims = yield* validateWorkspaceUploadToken(tokenFromRelativeUrl(issued.relativeUrl)); + if (!claims) { + throw new Error("Expected valid upload claims."); + } + + const result = yield* storeWorkspaceUpload(claims, new Uint8Array([1, 2, 3])); + expect(result).toMatchObject({ ok: false, status: 400 }); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("rejects a store when the target appeared after mint without overwrite", () => + Effect.gen(function* () { + const cwd = yield* makeTempWorkspaceRoot(); + const fileSystem = yield* FileSystem.FileSystem; + const bytes = new Uint8Array([5, 6, 7]); + + const issued = yield* issueWorkspaceUploadUrl({ + cwd, + relativePath: "race.txt", + sizeBytes: bytes.byteLength, + }); + const claims = yield* validateWorkspaceUploadToken(tokenFromRelativeUrl(issued.relativeUrl)); + if (!claims) { + throw new Error("Expected valid upload claims."); + } + + yield* fileSystem.writeFileString(NodePath.join(cwd, "race.txt"), "raced"); + + const result = yield* storeWorkspaceUpload(claims, bytes); + expect(result).toMatchObject({ ok: false, status: 409 }); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("rejects tampered, malformed, expired, and cross-kind tokens", () => + Effect.gen(function* () { + const cwd = yield* makeTempWorkspaceRoot(); + const issued = yield* issueWorkspaceUploadUrl({ + cwd, + relativePath: "file.bin", + sizeBytes: 3, + }); + const token = tokenFromRelativeUrl(issued.relativeUrl); + const [payload, signature] = token.split("."); + + expect(yield* validateWorkspaceUploadToken(`${payload}x.${signature}`)).toBeNull(); + expect(yield* validateWorkspaceUploadToken("garbage")).toBeNull(); + + yield* TestClock.adjust(PROJECT_UPLOAD_URL_TTL_MS + 1); + expect(yield* validateWorkspaceUploadToken(token)).toBeNull(); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("rejects an attachment-upload token presented to the workspace validator", () => + Effect.gen(function* () { + const secretStore = yield* ServerSecretStore.ServerSecretStore; + const secret = yield* secretStore.getOrCreateRandom("asset-access-signing-key", 32); + const nowMs = yield* Clock.currentTimeMillis; + const encodedPayload = base64UrlEncode( + encodeAttachmentUploadClaimsForTest({ + version: 1, + kind: "attachment-upload", + attachmentId: "pending-00000000-0000-4000-8000-000000000000", + name: "file.png", + mimeType: "image/png", + sizeBytes: 3, + expiresAt: nowMs + 60_000, + }), + ); + const token = `${encodedPayload}.${signPayload(encodedPayload, secret)}`; + + expect(yield* validateWorkspaceUploadToken(token)).toBeNull(); + }).pipe(Effect.provide(testLayer)), + ); +}); diff --git a/apps/server/src/workspace/WorkspaceUpload.ts b/apps/server/src/workspace/WorkspaceUpload.ts new file mode 100644 index 000000000000..5f1a37bb6e6f --- /dev/null +++ b/apps/server/src/workspace/WorkspaceUpload.ts @@ -0,0 +1,230 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeCrypto from "node:crypto"; + +import { + PROJECT_UPLOAD_URL_TTL_MS, + ProjectCreateUploadUrlError, + ProjectUploadTargetExistsError, + type ProjectCreateUploadUrlInput, +} from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import { + base64UrlDecodeUtf8, + base64UrlEncode, + signPayload, + timingSafeEqualBase64Url, +} from "../auth/utils.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as WorkspaceEntries from "./WorkspaceEntries.ts"; +import * as WorkspacePaths from "./WorkspacePaths.ts"; + +export const WORKSPACE_UPLOAD_ROUTE_PREFIX = "/api/workspace/upload"; + +// Asset download and attachment upload tokens share this key; the signed +// claim kind keeps the token spaces separate. +const SIGNING_SECRET_NAME = "asset-access-signing-key"; + +const WorkspaceUploadClaims = Schema.Struct({ + version: Schema.Literal(1), + kind: Schema.Literal("workspace-upload"), + cwd: Schema.String, + relativePath: Schema.String, + sizeBytes: Schema.Number, + overwrite: Schema.Boolean, + expiresAt: Schema.Number, +}); +export type WorkspaceUploadClaims = typeof WorkspaceUploadClaims.Type; + +const workspaceUploadClaimsJson = Schema.fromJsonString(WorkspaceUploadClaims); +const decodeWorkspaceUploadClaims = Schema.decodeUnknownOption(workspaceUploadClaimsJson); +const encodeWorkspaceUploadClaims = Schema.encodeSync(workspaceUploadClaimsJson); + +function decodeClaims(encodedPayload: string): WorkspaceUploadClaims | null { + try { + return Option.getOrNull(decodeWorkspaceUploadClaims(base64UrlDecodeUtf8(encodedPayload))); + } catch { + return null; + } +} + +const loadSigningSecret = Effect.gen(function* () { + const secretStore = yield* ServerSecretStore.ServerSecretStore; + return yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32); +}); + +export const issueWorkspaceUploadUrl = Effect.fn("WorkspaceUpload.issueUrl")(function* ( + input: ProjectCreateUploadUrlInput, +) { + const secret = yield* loadSigningSecret.pipe( + Effect.mapError( + (cause) => + new ProjectCreateUploadUrlError({ + cwd: input.cwd, + relativePath: input.relativePath, + message: "Failed to load the upload signing key.", + cause, + }), + ), + ); + + const workspacePaths = yield* WorkspacePaths.WorkspacePaths; + const target = yield* workspacePaths + .resolveRelativePathWithinRoot({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + }) + .pipe( + Effect.mapError( + (error) => + new ProjectCreateUploadUrlError({ + cwd: input.cwd, + relativePath: input.relativePath, + message: error.message, + cause: error, + }), + ), + ); + + const fileSystem = yield* FileSystem.FileSystem; + const targetExists = yield* fileSystem.exists(target.absolutePath).pipe( + Effect.mapError( + (cause) => + new ProjectCreateUploadUrlError({ + cwd: input.cwd, + relativePath: target.relativePath, + message: `Failed to check for an existing file at '${target.relativePath}' in '${input.cwd}'.`, + cause, + }), + ), + ); + if (targetExists && input.overwrite !== true) { + return yield* new ProjectUploadTargetExistsError({ + cwd: input.cwd, + relativePath: target.relativePath, + }); + } + + const nowMs = yield* Clock.currentTimeMillis; + const expiresAt = nowMs + PROJECT_UPLOAD_URL_TTL_MS; + const encodedPayload = base64UrlEncode( + encodeWorkspaceUploadClaims({ + version: 1, + kind: "workspace-upload", + cwd: input.cwd, + relativePath: target.relativePath, + sizeBytes: input.sizeBytes, + overwrite: input.overwrite === true, + expiresAt, + }), + ); + + return { + relativePath: target.relativePath, + relativeUrl: `${WORKSPACE_UPLOAD_ROUTE_PREFIX}/${encodedPayload}.${signPayload(encodedPayload, secret)}`, + expiresAt, + }; +}); + +export const validateWorkspaceUploadToken = Effect.fn("WorkspaceUpload.validateToken")(function* ( + token: string, +) { + const [encodedPayload, signature, unexpectedSegment] = token.split("."); + if (!encodedPayload || !signature || unexpectedSegment) { + return null; + } + + const secret = yield* loadSigningSecret.pipe( + Effect.tapError((cause) => + Effect.logError("Failed to load the workspace upload signing key.", { cause }), + ), + Effect.orElseSucceed(() => null), + ); + if (!secret || !timingSafeEqualBase64Url(signature, signPayload(encodedPayload, secret))) { + return null; + } + + const claims = decodeClaims(encodedPayload); + if (!claims || claims.expiresAt <= (yield* Clock.currentTimeMillis)) { + return null; + } + return claims; +}); + +export type StoreWorkspaceUploadResult = + | { readonly ok: true; readonly relativePath: string } + | { readonly ok: false; readonly status: number; readonly detail: string }; + +export const storeWorkspaceUpload = Effect.fn("WorkspaceUpload.store")(function* ( + claims: WorkspaceUploadClaims, + bytes: Uint8Array, +) { + if (bytes.byteLength !== claims.sizeBytes) { + return { + ok: false, + status: 400, + detail: `Body was ${bytes.byteLength} bytes, expected ${claims.sizeBytes}.`, + } satisfies StoreWorkspaceUploadResult; + } + + const workspacePaths = yield* WorkspacePaths.WorkspacePaths; + const target = yield* workspacePaths + .resolveRelativePathWithinRoot({ + workspaceRoot: claims.cwd, + relativePath: claims.relativePath, + }) + .pipe(Effect.catchTag("WorkspacePathOutsideRootError", () => Effect.succeed(null))); + if (!target) { + return { + ok: false, + status: 500, + detail: "Failed to resolve the workspace upload target.", + } satisfies StoreWorkspaceUploadResult; + } + + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const partPath = `${target.absolutePath}.${NodeCrypto.randomUUID()}.part`; + return yield* Effect.gen(function* () { + const targetExists = yield* fileSystem.exists(target.absolutePath); + if (targetExists && !claims.overwrite) { + return { + ok: false, + status: 409, + detail: "A file already exists at this path.", + } satisfies StoreWorkspaceUploadResult; + } + + yield* fileSystem.makeDirectory(path.dirname(target.absolutePath), { recursive: true }); + yield* fileSystem.writeFile(partPath, bytes); + yield* fileSystem.rename(partPath, target.absolutePath); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + yield* workspaceEntries.refresh(claims.cwd); + + return { ok: true, relativePath: target.relativePath } satisfies StoreWorkspaceUploadResult; + }).pipe( + Effect.catch((cause) => + fileSystem.remove(partPath, { force: true }).pipe( + Effect.orElseSucceed(() => undefined), + Effect.andThen( + Effect.logError("Failed to persist workspace upload.", { + cwd: claims.cwd, + relativePath: claims.relativePath, + cause, + }), + ), + Effect.as({ + ok: false, + status: 500, + detail: "Failed to persist upload.", + } satisfies StoreWorkspaceUploadResult), + ), + ), + ); +}); From 774d836a86538df1655fad56014d97cf87d25a0a Mon Sep 17 00:00:00 2001 From: Mark S <49048586+msegec@users.noreply.github.com> Date: Tue, 25 Aug 2026 07:22:45 +0800 Subject: [PATCH 03/25] docs(server): name workspace uploads among signing-key users --- apps/server/src/workspace/WorkspaceUpload.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/src/workspace/WorkspaceUpload.ts b/apps/server/src/workspace/WorkspaceUpload.ts index 5f1a37bb6e6f..8f89a93d4af5 100644 --- a/apps/server/src/workspace/WorkspaceUpload.ts +++ b/apps/server/src/workspace/WorkspaceUpload.ts @@ -26,8 +26,8 @@ import * as WorkspacePaths from "./WorkspacePaths.ts"; export const WORKSPACE_UPLOAD_ROUTE_PREFIX = "/api/workspace/upload"; -// Asset download and attachment upload tokens share this key; the signed -// claim kind keeps the token spaces separate. +// Asset download, attachment upload, and workspace upload tokens share this +// key; the signed claim kind keeps the token spaces separate. const SIGNING_SECRET_NAME = "asset-access-signing-key"; const WorkspaceUploadClaims = Schema.Struct({ From 04c5fe41c47be9767dc773750e1939a53ad2e44f Mon Sep 17 00:00:00 2001 From: Mark S <49048586+msegec@users.noreply.github.com> Date: Tue, 25 Aug 2026 07:27:37 +0800 Subject: [PATCH 04/25] feat(server): serve workspace uploads over signed POST route --- apps/server/src/http.ts | 50 ++++++++++++++++++++++++ apps/server/src/server.test.ts | 70 ++++++++++++++++++++++++++++++++++ apps/server/src/server.ts | 2 + apps/server/src/ws.ts | 11 +++--- 4 files changed, 127 insertions(+), 6 deletions(-) diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index c3104e7bc420..73f71d80977a 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -33,6 +33,11 @@ import { storeAttachmentUpload, validateAttachmentUploadToken, } from "./assets/AttachmentUpload.ts"; +import { + WORKSPACE_UPLOAD_ROUTE_PREFIX, + storeWorkspaceUpload, + validateWorkspaceUploadToken, +} from "./workspace/WorkspaceUpload.ts"; import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { traceRelayRequest } from "./cloud/traceRelayRequest.ts"; @@ -280,6 +285,51 @@ export const attachmentUploadRouteLayer = HttpRouter.add( }), ); +export const workspaceUploadRouteLayer = HttpRouter.add( + "POST", + `${WORKSPACE_UPLOAD_ROUTE_PREFIX}/*`, + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const url = HttpServerRequest.toURL(request); + if (Option.isNone(url)) { + return HttpServerResponse.text("Bad Request", { status: 400 }); + } + + const token = url.value.pathname.slice(`${WORKSPACE_UPLOAD_ROUTE_PREFIX}/`.length); + if (!token) { + return HttpServerResponse.text("Not Found", { status: 404 }); + } + const claims = yield* validateWorkspaceUploadToken(token); + if (!claims) { + return HttpServerResponse.text("Not Found", { status: 404 }); + } + + const contentLengthHeader = request.headers["content-length"]; + if ( + contentLengthHeader !== undefined && + (!Number.isInteger(Number(contentLengthHeader)) || + Number(contentLengthHeader) !== claims.sizeBytes) + ) { + return HttpServerResponse.text("Content-Length must match the upload size.", { + status: 400, + }); + } + + const body = yield* request.arrayBuffer.pipe( + Effect.provideService(HttpServerRequest.MaxBodySize, FileSystem.Size(claims.sizeBytes)), + Effect.orElseSucceed(() => null), + ); + if (body === null) { + return HttpServerResponse.text("Failed to read the upload body.", { status: 400 }); + } + + const stored = yield* storeWorkspaceUpload(claims, new Uint8Array(body)); + return stored.ok + ? HttpServerResponse.empty({ status: 204 }) + : HttpServerResponse.text(stored.detail, { status: stored.status }); + }), +); + export const staticAndDevRouteLayer = HttpRouter.add( "GET", "*", diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 5e4f19172eff..92e046d2e9ca 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -133,6 +133,7 @@ import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; +import { WORKSPACE_UPLOAD_ROUTE_PREFIX } from "./workspace/WorkspaceUpload.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; import * as VcsDriver from "./vcs/VcsDriver.ts"; import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; @@ -5127,6 +5128,75 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("uploads workspace file bytes through a signed URL issued by websocket rpc", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaceDir = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-ws-project-upload-", + }); + + yield* buildAppUnderTest(); + + const wsUrl = yield* getWsServerUrl("/ws"); + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + const issued = yield* client[WS_METHODS.projectsCreateUploadUrl]({ + cwd: workspaceDir, + relativePath: "uploaded/dropped.bin", + sizeBytes: 6, + overwrite: false, + }); + assert.equal(issued.relativePath, "uploaded/dropped.bin"); + + const badContentLength = yield* HttpClient.post(issued.relativeUrl, { + body: HttpBody.uint8Array(new Uint8Array([1, 2, 3]), "application/octet-stream"), + }); + assert.equal(badContentLength.status, 400); + + const response = yield* HttpClient.post(issued.relativeUrl, { + body: HttpBody.uint8Array( + new Uint8Array([1, 2, 3, 4, 5, 6]), + "application/octet-stream", + ), + }); + assert.equal(response.status, 204); + + const persisted = yield* fs.readFile( + path.join(workspaceDir, "uploaded", "dropped.bin"), + ); + assert.deepEqual(Array.from(persisted), [1, 2, 3, 4, 5, 6]); + + const notFoundResponse = yield* HttpClient.post( + `${WORKSPACE_UPLOAD_ROUTE_PREFIX}/not-a-real-token`, + { body: HttpBody.uint8Array(new Uint8Array([1, 2, 3, 4, 5, 6])) }, + ); + assert.equal(notFoundResponse.status, 404); + + const conflictTarget = yield* client[WS_METHODS.projectsCreateUploadUrl]({ + cwd: workspaceDir, + relativePath: "uploaded/conflict.bin", + sizeBytes: 6, + overwrite: false, + }); + yield* fs.writeFile( + path.join(workspaceDir, "uploaded", "conflict.bin"), + new Uint8Array([9, 9, 9, 9, 9, 9]), + ); + const conflictResponse = yield* HttpClient.post(conflictTarget.relativeUrl, { + body: HttpBody.uint8Array( + new Uint8Array([1, 2, 3, 4, 5, 6]), + "application/octet-stream", + ), + }); + assert.equal(conflictResponse.status, 409); + }), + ), + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("creates a missing workspace root during websocket project.create dispatch", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 0a31bf376dae..bf46f74c0908 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -18,6 +18,7 @@ import { staticAndDevRouteLayer, browserApiCorsLayer, httpCompressionLayer, + workspaceUploadRouteLayer, } from "./http.ts"; import { guardHttpResponseWriteErrors } from "./httpResponseErrorGuard.ts"; import { fixPath } from "./os-jank.ts"; @@ -458,6 +459,7 @@ export const makeRoutesLayer = Layer.mergeAll( otlpTracesProxyRouteLayer, assetRouteLayer, attachmentUploadRouteLayer, + workspaceUploadRouteLayer, staticAndDevRouteLayer, websocketRpcRouteLayer, ), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index ff4d012bd6fd..ee5975f30ffc 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -34,7 +34,6 @@ import { OrchestrationGetTurnDiffError, ORCHESTRATION_WS_METHODS, type ProjectId, - ProjectCreateUploadUrlError, type ProjectEntriesFailure, type ProjectFileFailure, type ProjectFileOperation, @@ -97,6 +96,7 @@ import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; import * as PreviewManager from "./preview/Manager.ts"; import { issueAssetUrl } from "./assets/AssetAccess.ts"; import { deletePendingAttachment, issueAttachmentUploadUrl } from "./assets/AttachmentUpload.ts"; +import { issueWorkspaceUploadUrl } from "./workspace/WorkspaceUpload.ts"; import * as PortScanner from "./preview/PortScanner.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; @@ -1939,11 +1939,10 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "workspace" }, ), - // Task 3 replaces this with the real handler. - [WS_METHODS.projectsCreateUploadUrl]: () => - Effect.fail( - new ProjectCreateUploadUrlError({ message: "Workspace uploads are not wired up yet." }), - ), + [WS_METHODS.projectsCreateUploadUrl]: (input) => + observeRpcEffect(WS_METHODS.projectsCreateUploadUrl, issueWorkspaceUploadUrl(input), { + "rpc.aggregate": "workspace", + }), [WS_METHODS.shellOpenInEditor]: (input) => observeRpcEffect(WS_METHODS.shellOpenInEditor, externalLauncher.launchEditor(input), { "rpc.aggregate": "workspace", From a0fd65d8bb3ff86778b9768c4db1e8a643251e8b Mon Sep 17 00:00:00 2001 From: Mark S <49048586+msegec@users.noreply.github.com> Date: Tue, 25 Aug 2026 07:35:21 +0800 Subject: [PATCH 05/25] feat(web): queue workspace file uploads Adds the createUploadUrl command atom and a client-side upload queue for workspace files: FIFO pump capped at 3 concurrent uploads per environment, XHR-based byte upload with progress, an overwrite confirm flow for ProjectUploadTargetExistsError, and retry/cancel/dismiss for failed rows. --- apps/web/src/lib/workspaceUploadQueue.test.ts | 363 ++++++++++++++++++ apps/web/src/lib/workspaceUploadQueue.ts | 333 ++++++++++++++++ .../src/state/projectCommands.ts | 4 + 3 files changed, 700 insertions(+) create mode 100644 apps/web/src/lib/workspaceUploadQueue.test.ts create mode 100644 apps/web/src/lib/workspaceUploadQueue.ts diff --git a/apps/web/src/lib/workspaceUploadQueue.test.ts b/apps/web/src/lib/workspaceUploadQueue.test.ts new file mode 100644 index 000000000000..907364273107 --- /dev/null +++ b/apps/web/src/lib/workspaceUploadQueue.test.ts @@ -0,0 +1,363 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => ({ + createUploadUrl: Symbol("create-upload-url"), + runAtomCommand: vi.fn(), + readPreparedConnection: vi.fn(), + requestConfirmDialog: vi.fn(), +})); + +vi.mock("@t3tools/client-runtime/state/runtime", () => ({ + runAtomCommand: mocks.runAtomCommand, +})); + +vi.mock("../rpc/atomRegistry", () => ({ appAtomRegistry: {} })); + +vi.mock("../state/projects", () => ({ + projectEnvironment: { + createUploadUrl: mocks.createUploadUrl, + }, +})); + +vi.mock("../state/session", () => ({ + readPreparedConnection: mocks.readPreparedConnection, +})); + +vi.mock("../confirmDialog", () => ({ + requestConfirmDialog: mocks.requestConfirmDialog, +})); + +import { + cancelWorkspaceUpload, + dismissWorkspaceUpload, + retryWorkspaceUpload, + startWorkspaceUploads, + useWorkspaceUploadStore, +} from "./workspaceUploadQueue"; + +type ProgressListener = (event: { + readonly lengthComputable: boolean; + readonly loaded: number; + readonly total: number; +}) => void; + +class TestXmlHttpRequest { + static requests: TestXmlHttpRequest[] = []; + + status = 0; + timeout = 0; + method: string | null = null; + url: string | null = null; + readonly headers = new Map(); + readonly listeners = new Map void>(); + progressListener: ProgressListener | null = null; + + readonly upload = { + addEventListener: (_event: string, listener: ProgressListener) => { + this.progressListener = listener; + }, + }; + + constructor() { + TestXmlHttpRequest.requests.push(this); + } + + open(method: string, url: string): void { + this.method = method; + this.url = url; + } + + setRequestHeader(name: string, value: string): void { + this.headers.set(name, value); + } + + addEventListener(event: string, listener: () => void): void { + this.listeners.set(event, listener); + } + + send(): void {} + + abort(): void { + this.listeners.get("abort")?.(); + } + + progress(loaded: number, total: number): void { + this.progressListener?.({ lengthComputable: true, loaded, total }); + } + + complete(status = 204): void { + this.status = status; + this.listeners.get("load")?.(); + } +} + +const environmentId = EnvironmentId.make("environment-1"); +const cwd = "/workspace/project"; + +function makeFile(name: string): File { + return new File([new Uint8Array([1, 2, 3])], name); +} + +function mintedResult(relativePath: string) { + return { + _tag: "Success" as const, + value: { + relativePath, + relativeUrl: `/api/workspace/upload/token-${relativePath}`, + expiresAt: 1, + }, + }; +} + +function targetExistsFailure(relativePath: string) { + return { + _tag: "Failure" as const, + cause: Cause.fail({ + _tag: "ProjectUploadTargetExistsError", + cwd, + relativePath, + }), + }; +} + +function genericMintFailure() { + return { + _tag: "Failure" as const, + cause: Cause.fail({ + _tag: "ProjectCreateUploadUrlError", + message: "boom", + }), + }; +} + +function uploadsById() { + return useWorkspaceUploadStore.getState().uploadsById; +} + +function findUpload(name: string) { + return Object.entries(uploadsById()).find(([, upload]) => upload.name === name); +} + +describe("workspaceUploadQueue", () => { + beforeEach(() => { + TestXmlHttpRequest.requests = []; + mocks.runAtomCommand.mockReset(); + mocks.readPreparedConnection.mockReset(); + mocks.requestConfirmDialog.mockReset(); + mocks.readPreparedConnection.mockReturnValue({ httpBaseUrl: "https://environment.test/" }); + mocks.runAtomCommand.mockImplementation( + async ( + _registry: unknown, + command: unknown, + target: { readonly input: { readonly relativePath: string } }, + ) => { + if (command === mocks.createUploadUrl) { + return mintedResult(target.input.relativePath); + } + throw new Error("unexpected command"); + }, + ); + vi.stubGlobal("XMLHttpRequest", TestXmlHttpRequest); + }); + + afterEach(() => { + useWorkspaceUploadStore.setState({ uploadsById: {} }); + vi.unstubAllGlobals(); + }); + + it("uploads a file, reports progress, then removes the entry and calls onUploaded", async () => { + const onUploaded = vi.fn(); + const file = makeFile("notes.txt"); + startWorkspaceUploads({ environmentId, cwd, files: [file], onUploaded }); + await Promise.resolve(); + await Promise.resolve(); + + const request = TestXmlHttpRequest.requests[0]!; + expect(request.method).toBe("POST"); + expect(request.url).toBe("https://environment.test/api/workspace/upload/token-notes.txt"); + expect(request.headers.has("Content-Type")).toBe(false); + + request.progress(1, 2); + const [uploadId, uploading] = findUpload("notes.txt")!; + expect(uploading).toMatchObject({ + status: "uploading", + relativePath: "notes.txt", + environmentId, + cwd, + progress: 0.5, + }); + + request.complete(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + expect(uploadsById()[uploadId]).toBeUndefined(); + expect(onUploaded).toHaveBeenCalledTimes(1); + }); + + it("marks the entry failed with a reason when minting fails", async () => { + mocks.runAtomCommand.mockResolvedValue(genericMintFailure()); + const file = makeFile("broken.txt"); + startWorkspaceUploads({ environmentId, cwd, files: [file], onUploaded: vi.fn() }); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + const [, failed] = findUpload("broken.txt")!; + expect(failed).toMatchObject({ status: "failed", reason: "Upload could not start" }); + expect(TestXmlHttpRequest.requests).toHaveLength(0); + }); + + it("re-mints with overwrite and uploads when the user confirms replacing an existing file", async () => { + let call = 0; + mocks.runAtomCommand.mockImplementation( + async ( + _registry: unknown, + command: unknown, + target: { + readonly input: { readonly relativePath: string; readonly overwrite?: boolean }; + }, + ) => { + if (command !== mocks.createUploadUrl) throw new Error("unexpected command"); + call += 1; + if (call === 1) { + expect(target.input.overwrite).toBeUndefined(); + return targetExistsFailure(target.input.relativePath); + } + expect(target.input.overwrite).toBe(true); + return mintedResult(target.input.relativePath); + }, + ); + mocks.requestConfirmDialog.mockResolvedValue(true); + + const file = makeFile("existing.txt"); + startWorkspaceUploads({ environmentId, cwd, files: [file], onUploaded: vi.fn() }); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + expect(mocks.requestConfirmDialog).toHaveBeenCalledWith( + "Replace existing.txt?\nA file named 'existing.txt' already exists in this project.", + ); + expect(TestXmlHttpRequest.requests).toHaveLength(1); + TestXmlHttpRequest.requests[0]!.complete(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + expect(findUpload("existing.txt")).toBeUndefined(); + }); + + it("marks the entry failed with 'File already exists' when the user declines to replace", async () => { + mocks.runAtomCommand.mockImplementation( + async ( + _registry: unknown, + command: unknown, + target: { readonly input: { readonly relativePath: string } }, + ) => { + if (command !== mocks.createUploadUrl) throw new Error("unexpected command"); + return targetExistsFailure(target.input.relativePath); + }, + ); + mocks.requestConfirmDialog.mockResolvedValue(false); + + const file = makeFile("existing.txt"); + startWorkspaceUploads({ environmentId, cwd, files: [file], onUploaded: vi.fn() }); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + const [, failed] = findUpload("existing.txt")!; + expect(failed).toMatchObject({ status: "failed", reason: "File already exists" }); + expect(TestXmlHttpRequest.requests).toHaveLength(0); + }); + + it("cancelWorkspaceUpload aborts the in-flight XHR and removes the entry", async () => { + const file = makeFile("cancel-me.txt"); + startWorkspaceUploads({ environmentId, cwd, files: [file], onUploaded: vi.fn() }); + await Promise.resolve(); + await Promise.resolve(); + + const request = TestXmlHttpRequest.requests[0]!; + const [uploadId] = findUpload("cancel-me.txt")!; + cancelWorkspaceUpload(uploadId); + + expect(uploadsById()[uploadId]).toBeUndefined(); + await Promise.resolve(); + await Promise.resolve(); + expect(request.listeners.has("abort")).toBe(true); + }); + + it("caps concurrent uploads at 3 per environment", async () => { + const files = ["a.txt", "b.txt", "c.txt", "d.txt"].map(makeFile); + startWorkspaceUploads({ environmentId, cwd, files, onUploaded: vi.fn() }); + await Promise.resolve(); + await Promise.resolve(); + + expect(TestXmlHttpRequest.requests).toHaveLength(3); + + for (const request of TestXmlHttpRequest.requests) { + request.complete(); + } + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + expect(TestXmlHttpRequest.requests).toHaveLength(4); + }); + + it("retryWorkspaceUpload restarts a failed entry", async () => { + mocks.runAtomCommand.mockResolvedValueOnce(genericMintFailure()); + const file = makeFile("retry.txt"); + startWorkspaceUploads({ environmentId, cwd, files: [file], onUploaded: vi.fn() }); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + const [uploadId, failed] = findUpload("retry.txt")!; + expect(failed).toMatchObject({ status: "failed" }); + + mocks.runAtomCommand.mockImplementation( + async ( + _registry: unknown, + command: unknown, + target: { readonly input: { readonly relativePath: string } }, + ) => { + if (command !== mocks.createUploadUrl) throw new Error("unexpected command"); + return mintedResult(target.input.relativePath); + }, + ); + retryWorkspaceUpload(uploadId); + await Promise.resolve(); + await Promise.resolve(); + + expect(TestXmlHttpRequest.requests).toHaveLength(1); + expect(uploadsById()[uploadId]).toMatchObject({ status: "uploading" }); + + TestXmlHttpRequest.requests[0]!.complete(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + expect(uploadsById()[uploadId]).toBeUndefined(); + }); + + it("dismissWorkspaceUpload removes a failed entry", async () => { + mocks.runAtomCommand.mockResolvedValue(genericMintFailure()); + const file = makeFile("dismiss-me.txt"); + startWorkspaceUploads({ environmentId, cwd, files: [file], onUploaded: vi.fn() }); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + const [uploadId] = findUpload("dismiss-me.txt")!; + dismissWorkspaceUpload(uploadId); + + expect(uploadsById()[uploadId]).toBeUndefined(); + }); +}); diff --git a/apps/web/src/lib/workspaceUploadQueue.ts b/apps/web/src/lib/workspaceUploadQueue.ts new file mode 100644 index 000000000000..5b266496e7ab --- /dev/null +++ b/apps/web/src/lib/workspaceUploadQueue.ts @@ -0,0 +1,333 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; +import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; +import * as Cause from "effect/Cause"; +import { create } from "zustand"; + +import { requestConfirmDialog } from "../confirmDialog"; +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { projectEnvironment } from "../state/projects"; +import { readPreparedConnection } from "../state/session"; +import { randomUUID } from "./utils"; + +const MAX_UPLOADS_PER_ENVIRONMENT = 3; +const UPLOAD_TIMEOUT_MS = 5 * 60_000; + +export type WorkspaceUploadState = + | { + readonly status: "uploading"; + readonly name: string; + readonly relativePath: string; + readonly environmentId: EnvironmentId; + readonly cwd: string; + readonly progress: number; + } + | { + readonly status: "failed"; + readonly name: string; + readonly relativePath: string; + readonly environmentId: EnvironmentId; + readonly cwd: string; + readonly reason: string; + }; + +interface WorkspaceUploadStore { + readonly uploadsById: Readonly>; +} + +export const useWorkspaceUploadStore = create(() => ({ + uploadsById: {}, +})); + +interface UploadJob { + readonly id: string; + readonly environmentId: EnvironmentId; + readonly cwd: string; + readonly relativePath: string; + readonly file: File; + readonly onUploaded: () => void; + overwrite: boolean; + cancelled: boolean; + abort: (() => void) | null; +} + +// Jobs stay here from queueing through a terminal state (failed) so retry can +// reuse the original File. Success and cancellation remove the entry. +const jobsById = new Map(); +const queue: UploadJob[] = []; +const activeUploadsByEnvironment = new Map(); + +function setUploadState(uploadId: string, upload: WorkspaceUploadState): void { + useWorkspaceUploadStore.setState((state) => ({ + uploadsById: { ...state.uploadsById, [uploadId]: upload }, + })); +} + +function clearUploadState(uploadId: string): void { + useWorkspaceUploadStore.setState((state) => { + if (!(uploadId in state.uploadsById)) { + return state; + } + const uploadsById = { ...state.uploadsById }; + delete uploadsById[uploadId]; + return { uploadsById }; + }); +} + +function failJob(job: UploadJob, reason: string): void { + setUploadState(job.id, { + status: "failed", + name: job.file.name, + relativePath: job.relativePath, + environmentId: job.environmentId, + cwd: job.cwd, + reason, + }); +} + +function uploadBytes(input: { + readonly url: string; + readonly file: File; + readonly onProgress: (progress: number) => void; +}): { readonly done: Promise; readonly abort: () => void } { + const xhr = new XMLHttpRequest(); + const done = new Promise((resolve, reject) => { + xhr.open("POST", input.url, true); + xhr.timeout = UPLOAD_TIMEOUT_MS; + xhr.upload.addEventListener("progress", (event) => { + if (event.lengthComputable && event.total > 0) { + input.onProgress(event.loaded / event.total); + } + }); + xhr.addEventListener("load", () => { + if (xhr.status >= 200 && xhr.status < 300) { + resolve(); + } else { + reject(new Error(`Upload rejected (${xhr.status})`)); + } + }); + xhr.addEventListener("error", () => reject(new Error("Upload failed"))); + xhr.addEventListener("timeout", () => reject(new Error("Upload timed out"))); + xhr.addEventListener("abort", () => reject(new Error("Upload cancelled"))); + xhr.send(input.file); + }); + + return { done, abort: () => xhr.abort() }; +} + +function mintUploadUrl(job: UploadJob) { + return runAtomCommand( + appAtomRegistry, + projectEnvironment.createUploadUrl, + { + environmentId: job.environmentId, + input: { + cwd: job.cwd, + relativePath: job.relativePath, + sizeBytes: job.file.size, + ...(job.overwrite ? { overwrite: true } : {}), + }, + }, + { reportFailure: false }, + ); +} + +function isTargetExistsFailure(cause: unknown): boolean { + return ( + typeof cause === "object" && + cause !== null && + "_tag" in cause && + cause._tag === "ProjectUploadTargetExistsError" + ); +} + +async function runUpload(job: UploadJob): Promise { + let minted = await mintUploadUrl(job); + if (job.cancelled) { + jobsById.delete(job.id); + return; + } + + if (minted._tag !== "Success") { + if (!isTargetExistsFailure(Cause.squash(minted.cause))) { + failJob(job, "Upload could not start"); + return; + } + + const confirmed = await requestConfirmDialog( + `Replace ${job.file.name}?\nA file named '${job.relativePath}' already exists in this project.`, + ); + if (job.cancelled) { + jobsById.delete(job.id); + return; + } + if (confirmed !== true) { + failJob(job, "File already exists"); + return; + } + + job.overwrite = true; + minted = await mintUploadUrl(job); + if (job.cancelled) { + jobsById.delete(job.id); + return; + } + if (minted._tag !== "Success") { + failJob(job, "Upload could not start"); + return; + } + } + + const connection = readPreparedConnection(job.environmentId); + const url = connection ? resolveAssetUrl(connection.httpBaseUrl, minted.value.relativeUrl) : null; + if (!url) { + failJob(job, "Not connected"); + return; + } + + let lastStep = -1; + const upload = uploadBytes({ + url, + file: job.file, + onProgress: (progress) => { + const step = Math.floor(progress * 20); + if (step === lastStep || job.cancelled) { + return; + } + lastStep = step; + setUploadState(job.id, { + status: "uploading", + name: job.file.name, + relativePath: job.relativePath, + environmentId: job.environmentId, + cwd: job.cwd, + progress, + }); + }, + }); + job.abort = upload.abort; + + try { + await upload.done; + if (job.cancelled) { + jobsById.delete(job.id); + return; + } + jobsById.delete(job.id); + clearUploadState(job.id); + job.onUploaded(); + } catch (error) { + if (job.cancelled) { + jobsById.delete(job.id); + return; + } + failJob(job, error instanceof Error ? error.message : "Upload failed"); + } finally { + job.abort = null; + } +} + +function pumpUploads(): void { + for (let index = 0; index < queue.length; ) { + const job = queue[index]!; + const active = activeUploadsByEnvironment.get(job.environmentId) ?? 0; + if (active >= MAX_UPLOADS_PER_ENVIRONMENT) { + index += 1; + continue; + } + + queue.splice(index, 1); + if (job.cancelled) { + continue; + } + activeUploadsByEnvironment.set(job.environmentId, active + 1); + void runUpload(job) + .catch(() => { + if (!job.cancelled) { + failJob(job, "Upload failed"); + } + }) + .finally(() => { + const remaining = (activeUploadsByEnvironment.get(job.environmentId) ?? 1) - 1; + if (remaining > 0) { + activeUploadsByEnvironment.set(job.environmentId, remaining); + } else { + activeUploadsByEnvironment.delete(job.environmentId); + } + pumpUploads(); + }); + } +} + +export function startWorkspaceUploads(input: { + readonly environmentId: EnvironmentId; + readonly cwd: string; + readonly files: ReadonlyArray; + readonly onUploaded: () => void; +}): void { + for (const file of input.files) { + const id = randomUUID(); + // Uploads land at the project root in v1. + const relativePath = file.name; + const job: UploadJob = { + id, + environmentId: input.environmentId, + cwd: input.cwd, + relativePath, + file, + onUploaded: input.onUploaded, + overwrite: false, + cancelled: false, + abort: null, + }; + jobsById.set(id, job); + queue.push(job); + setUploadState(id, { + status: "uploading", + name: file.name, + relativePath, + environmentId: input.environmentId, + cwd: input.cwd, + progress: 0, + }); + } + pumpUploads(); +} + +export function cancelWorkspaceUpload(uploadId: string): void { + const job = jobsById.get(uploadId); + if (!job) { + return; + } + job.cancelled = true; + jobsById.delete(uploadId); + const queuedIndex = queue.indexOf(job); + if (queuedIndex !== -1) { + queue.splice(queuedIndex, 1); + } + job.abort?.(); + clearUploadState(uploadId); +} + +export function retryWorkspaceUpload(uploadId: string): void { + const job = jobsById.get(uploadId); + if (!job) { + return; + } + job.cancelled = false; + setUploadState(job.id, { + status: "uploading", + name: job.file.name, + relativePath: job.relativePath, + environmentId: job.environmentId, + cwd: job.cwd, + progress: 0, + }); + queue.push(job); + pumpUploads(); +} + +export function dismissWorkspaceUpload(uploadId: string): void { + jobsById.delete(uploadId); + clearUploadState(uploadId); +} diff --git a/packages/client-runtime/src/state/projectCommands.ts b/packages/client-runtime/src/state/projectCommands.ts index 3defcc321547..ae5e7adf314a 100644 --- a/packages/client-runtime/src/state/projectCommands.ts +++ b/packages/client-runtime/src/state/projectCommands.ts @@ -102,5 +102,9 @@ export function createProjectEnvironmentAtoms( JSON.stringify([environmentId, input.cwd, input.relativePath]), }, }), + createUploadUrl: createEnvironmentRpcCommand(runtime, { + label: "environment-data:projects:create-upload-url", + tag: WS_METHODS.projectsCreateUploadUrl, + }), }; } From 493bdf4bdfae63ec8961e4002bc82ece5aee3b2d Mon Sep 17 00:00:00 2001 From: Mark S <49048586+msegec@users.noreply.github.com> Date: Tue, 25 Aug 2026 07:46:20 +0800 Subject: [PATCH 06/25] feat(web): upload files from the files view --- .../src/components/files/FileBrowserPanel.tsx | 196 +++++++++++++++++- docs/user/files.md | 27 +++ 2 files changed, 220 insertions(+), 3 deletions(-) create mode 100644 docs/user/files.md diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index cbe20f4d3a8d..2dbb45752a0a 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -5,8 +5,9 @@ import type { import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts"; import { FileTree, useFileTree, useFileTreeSearch } from "@pierre/trees/react"; import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; -import { RotateCw } from "lucide-react"; -import { useEffect, useMemo, useRef } from "react"; +import { RotateCw, Upload, XIcon } from "lucide-react"; +import type { DragEvent as ReactDragEvent, ReactNode } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Button } from "~/components/ui/button"; import { InputGroup, InputGroupInput } from "~/components/ui/input-group"; @@ -15,10 +16,19 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { useComposerHandleContext } from "~/composerHandleContext"; import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; import { useTheme } from "~/hooks/useTheme"; +import { + cancelWorkspaceUpload, + dismissWorkspaceUpload, + retryWorkspaceUpload, + startWorkspaceUploads, + useWorkspaceUploadStore, + type WorkspaceUploadState, +} from "~/lib/workspaceUploadQueue"; import { cn } from "~/lib/utils"; import { readLocalApi } from "~/localApi"; import { T3_PIERRE_ICONS } from "~/pierre-icons"; +import { makeWorkspaceFileDropHandlers } from "../chat/workspaceFileDrop"; import { createFileTreeDragMentionController } from "./fileTreeDragMention"; import { useProjectEntriesQuery } from "./projectFilesQueryState"; @@ -71,6 +81,97 @@ function RefreshFilesButton(props: { isPending: boolean; onRefresh: () => void } ); } +function UploadFilesButton(props: { onClick: () => void }) { + return ( + + + } + > + + + Upload files + + ); +} + +function UploadRowButton(props: { label: string; icon: ReactNode; onClick: () => void }) { + return ( + + + } + > + {props.icon} + + {props.label} + + ); +} + +function UploadRow(props: { id: string; upload: WorkspaceUploadState }) { + const { id, upload } = props; + return ( +
+ + {upload.name}} + /> + {upload.name} + + {upload.status === "uploading" ? ( + <> + + {Math.round(upload.progress * 100)}% + + } + onClick={() => cancelWorkspaceUpload(id)} + /> + + ) : ( + <> + + + {upload.reason} + + } + /> + {upload.reason} + + } + onClick={() => retryWorkspaceUpload(id)} + /> + } + onClick={() => dismissWorkspaceUpload(id)} + /> + + )} +
+ ); +} + function FileSearchField(props: { ariaLabel: string; name: string; @@ -111,6 +212,56 @@ export default function FileBrowserPanel({ const { resolvedTheme } = useTheme(); const composerRef = useComposerHandleContext(); const entriesQuery = useProjectEntriesQuery(environmentId, cwd); + const [dragActive, setDragActive] = useState(false); + const fileInputRef = useRef(null); + const handleAddFiles = useCallback( + (files: File[]) => { + if (files.length === 0) return; + startWorkspaceUploads({ + environmentId, + cwd, + files, + onUploaded: () => entriesQuery.refresh(), + }); + }, + [cwd, entriesQuery, environmentId], + ); + // The shared drop handlers manage drag-active state, but their onDrop reads + // event.dataTransfer.files directly, which includes an unreadable stand-in + // File for a dropped directory. Filter with dataTransfer.items instead so + // directories never reach the upload queue. + const fileDropHandlers = useMemo( + () => makeWorkspaceFileDropHandlers({ setDragActive, addFiles: handleAddFiles }), + [handleAddFiles], + ); + const handleDrop = useCallback( + (event: ReactDragEvent) => { + if (!event.dataTransfer.types.includes("Files")) return; + event.preventDefault(); + setDragActive(false); + const items = Array.from(event.dataTransfer.items); + const supportsEntries = items.length > 0 && typeof items[0]?.webkitGetAsEntry === "function"; + const files = supportsEntries + ? items.flatMap((item) => { + if (item.kind !== "file") return []; + const entry = item.webkitGetAsEntry(); + if (entry !== null && !entry.isFile) return []; + const file = item.getAsFile(); + return file ? [file] : []; + }) + : Array.from(event.dataTransfer.files); + handleAddFiles(files); + }, + [handleAddFiles], + ); + const uploadsById = useWorkspaceUploadStore((state) => state.uploadsById); + const uploads = useMemo( + () => + Object.entries(uploadsById).filter( + ([, upload]) => upload.environmentId === environmentId && upload.cwd === cwd, + ), + [cwd, environmentId, uploadsById], + ); const entries = entriesQuery.data?.entries ?? []; const entryKinds = useMemo( () => new Map(entries.map((entry) => [entry.path, entry.kind] as const)), @@ -353,14 +504,33 @@ export default function FileBrowserPanel({ return (
+ {dragActive ? ( +
+
+
+
+ ) : null}
+ fileInputRef.current?.click()} /> + { + handleAddFiles(Array.from(event.target.files ?? [])); + event.target.value = ""; + }} + />
{entriesQuery.error && entriesQuery.data === null ? (
{entriesQuery.error}
@@ -382,6 +562,16 @@ export default function FileBrowserPanel({ }} /> )} + {uploads.length > 0 ? ( +
+ {uploads.map(([id, upload]) => ( + + ))} +
+ ) : null}
); } diff --git a/docs/user/files.md b/docs/user/files.md new file mode 100644 index 000000000000..bdecc6991d7a --- /dev/null +++ b/docs/user/files.md @@ -0,0 +1,27 @@ +# Files view + +The files view shows your project's folder tree: directories and files in one list you can +expand, collapse, and select. Click a file to open it in the preview pane. + +## Opening and searching + +Type in the search field to filter the tree to matching names. Press Escape to clear the search +and show the full tree again. + +Right-click a file for **Copy mention** and **Add to chat**, so you can reference it in your +message without leaving the tree. + +## Uploading files + +Drag files from your computer onto the files view, or select the upload button at the top of the +panel to choose files from a picker. Uploads land in the project's root folder. Dragging a folder +does not upload its contents; drop the files themselves. + +If a file with that name already exists, T3 Code asks before replacing it. Decline and the upload +is cancelled; the existing file is left as it was. + +While a file uploads, its row shows progress and a cancel button. A failed upload shows the reason +and gives you retry and dismiss buttons. + +Uploading works the same way whether you're connected locally, over a remote network, or through a +tunnel. From 4d68e1d7af67c189664606cfbc2c9f455780dd96 Mon Sep 17 00:00:00 2001 From: Mark S <49048586+msegec@users.noreply.github.com> Date: Tue, 25 Aug 2026 07:49:24 +0800 Subject: [PATCH 07/25] perf(web): scope files-panel renders to its own uploads --- .../src/components/files/FileBrowserPanel.tsx | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index 2dbb45752a0a..bc546ff8e886 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -8,6 +8,7 @@ import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; import { RotateCw, Upload, XIcon } from "lucide-react"; import type { DragEvent as ReactDragEvent, ReactNode } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useShallow } from "zustand/react/shallow"; import { Button } from "~/components/ui/button"; import { InputGroup, InputGroupInput } from "~/components/ui/input-group"; @@ -254,14 +255,26 @@ export default function FileBrowserPanel({ }, [handleAddFiles], ); - const uploadsById = useWorkspaceUploadStore((state) => state.uploadsById); - const uploads = useMemo( - () => - Object.entries(uploadsById).filter( - ([, upload]) => upload.environmentId === environmentId && upload.cwd === cwd, - ), - [cwd, environmentId, uploadsById], + // Flattened [id, state, id, state, ...] so the shallow compare sees stable + // string ids and per-upload state refs; uploads for other panels never + // re-render this one. + const uploadEntries = useWorkspaceUploadStore( + useShallow((state) => + Object.entries(state.uploadsById) + .filter(([, upload]) => upload.environmentId === environmentId && upload.cwd === cwd) + .flat(), + ), ); + const uploads = useMemo(() => { + const pairs: Array<[string, WorkspaceUploadState]> = []; + for (let index = 0; index < uploadEntries.length; index += 2) { + pairs.push([ + uploadEntries[index] as string, + uploadEntries[index + 1] as WorkspaceUploadState, + ]); + } + return pairs; + }, [uploadEntries]); const entries = entriesQuery.data?.entries ?? []; const entryKinds = useMemo( () => new Map(entries.map((entry) => [entry.path, entry.kind] as const)), From fed2a7a346555234688ca782d752248f9a7a24b5 Mon Sep 17 00:00:00 2001 From: Mark S <49048586+msegec@users.noreply.github.com> Date: Tue, 25 Aug 2026 08:03:57 +0800 Subject: [PATCH 08/25] fix(web,server): harden workspace uploads after review Floor the workspace upload body limit at 1 byte so a 0-byte upload token can't disable NodeStream's max-body check for a chunked request with no Content-Length. Route the overwrite confirm dialog through readLocalApi() like every other caller instead of calling requestConfirmDialog directly. Extract the duplicated XHR upload helper (attachments, workspace) into apps/web/src/lib/uploadXhr.ts. Raise the workspace upload timeout to 10 minutes to match the 100 MiB max and the upload token TTL. Scope the files view upload docs to web and desktop. --- apps/server/src/http.ts | 6 ++- apps/server/src/server.test.ts | 50 +++++++++++++++++++ apps/web/src/lib/attachmentUploadQueue.ts | 36 ++----------- apps/web/src/lib/uploadXhr.ts | 34 +++++++++++++ apps/web/src/lib/workspaceUploadQueue.test.ts | 4 +- apps/web/src/lib/workspaceUploadQueue.ts | 42 +++------------- docs/user/files.md | 2 + 7 files changed, 105 insertions(+), 69 deletions(-) create mode 100644 apps/web/src/lib/uploadXhr.ts diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 73f71d80977a..6bfc94a446fc 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -315,8 +315,12 @@ export const workspaceUploadRouteLayer = HttpRouter.add( }); } + // NodeStream.toArrayBuffer treats a falsy maxBytes as "no limit", so a + // 0-byte claim (empty files are a valid upload) would otherwise disable + // the body limit entirely. Floor it at 1 byte; an empty body still passes. + const maxBodySize = FileSystem.Size(Math.max(claims.sizeBytes, 1)); const body = yield* request.arrayBuffer.pipe( - Effect.provideService(HttpServerRequest.MaxBodySize, FileSystem.Size(claims.sizeBytes)), + Effect.provideService(HttpServerRequest.MaxBodySize, maxBodySize), Effect.orElseSucceed(() => null), ); if (body === null) { diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 92e046d2e9ca..c74c45113eb2 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -5197,6 +5197,56 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("floors the workspace upload body limit for zero-byte claims", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaceDir = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-ws-project-upload-empty-", + }); + + yield* buildAppUnderTest(); + + const wsUrl = yield* getWsServerUrl("/ws"); + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + const emptyTarget = yield* client[WS_METHODS.projectsCreateUploadUrl]({ + cwd: workspaceDir, + relativePath: "empty.bin", + sizeBytes: 0, + overwrite: false, + }); + const emptyResponse = yield* HttpClient.post(emptyTarget.relativeUrl, { + body: HttpBody.uint8Array(new Uint8Array(0), "application/octet-stream"), + }); + assert.equal(emptyResponse.status, 204); + const emptyPath = path.join(workspaceDir, "empty.bin"); + assert.isTrue(yield* fs.exists(emptyPath)); + assert.equal((yield* fs.readFile(emptyPath)).byteLength, 0); + + // Mint a second zero-byte claim and post a chunked body (no + // Content-Length, so the header check above is skipped) that + // exceeds it. NodeStream.toArrayBuffer treats a falsy maxBytes + // as unlimited, so this only fails once the limit is floored at + // 1 byte. + const oversizedTarget = yield* client[WS_METHODS.projectsCreateUploadUrl]({ + cwd: workspaceDir, + relativePath: "empty-oversized.bin", + sizeBytes: 0, + overwrite: false, + }); + const oversizedResponse = yield* HttpClient.post(oversizedTarget.relativeUrl, { + body: HttpBody.stream(Stream.make(new Uint8Array([1, 2, 3, 4, 5, 6]))), + }); + assert.equal(oversizedResponse.status, 400); + assert.isFalse(yield* fs.exists(path.join(workspaceDir, "empty-oversized.bin"))); + }), + ), + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("creates a missing workspace root during websocket project.create dispatch", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/web/src/lib/attachmentUploadQueue.ts b/apps/web/src/lib/attachmentUploadQueue.ts index 37eb924ca256..26602d8a6919 100644 --- a/apps/web/src/lib/attachmentUploadQueue.ts +++ b/apps/web/src/lib/attachmentUploadQueue.ts @@ -12,6 +12,7 @@ import { appAtomRegistry } from "../rpc/atomRegistry"; import { attachmentEnvironment } from "../state/attachments"; import { readPreparedConnection } from "../state/session"; import type { AttachmentUploadState, ReadyAttachmentUpload } from "./attachmentUploadState"; +import { uploadXhr } from "./uploadXhr"; const MAX_UPLOADS_PER_ENVIRONMENT = 3; const UPLOAD_TIMEOUT_MS = 5 * 60_000; @@ -69,37 +70,6 @@ function deletePendingUpload(environmentId: EnvironmentId, attachmentId: string) ); } -function uploadBytes(input: { - readonly url: string; - readonly file: File; - readonly onProgress: (progress: number) => void; -}): { readonly done: Promise; readonly abort: () => void } { - const xhr = new XMLHttpRequest(); - const done = new Promise((resolve, reject) => { - xhr.open("POST", input.url, true); - xhr.timeout = UPLOAD_TIMEOUT_MS; - xhr.setRequestHeader("Content-Type", input.file.type); - xhr.upload.addEventListener("progress", (event) => { - if (event.lengthComputable && event.total > 0) { - input.onProgress(event.loaded / event.total); - } - }); - xhr.addEventListener("load", () => { - if (xhr.status >= 200 && xhr.status < 300) { - resolve(); - } else { - reject(new Error(`Upload rejected (${xhr.status})`)); - } - }); - xhr.addEventListener("error", () => reject(new Error("Upload failed"))); - xhr.addEventListener("timeout", () => reject(new Error("Upload timed out"))); - xhr.addEventListener("abort", () => reject(new Error("Upload cancelled"))); - xhr.send(input.file); - }); - - return { done, abort: () => xhr.abort() }; -} - async function runUpload(job: UploadJob): Promise { const mimeType = PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES.find( (supportedMimeType) => supportedMimeType === job.image.mimeType.toLowerCase(), @@ -158,9 +128,11 @@ async function runUpload(job: UploadJob): Promise { } let lastStep = -1; - const upload = uploadBytes({ + const upload = uploadXhr({ url, file: job.image.file, + contentType: job.image.file.type, + timeoutMs: UPLOAD_TIMEOUT_MS, onProgress: (progress) => { const step = Math.floor(progress * 20); if (step === lastStep || job.cancelled) { diff --git a/apps/web/src/lib/uploadXhr.ts b/apps/web/src/lib/uploadXhr.ts new file mode 100644 index 000000000000..871fce25af3f --- /dev/null +++ b/apps/web/src/lib/uploadXhr.ts @@ -0,0 +1,34 @@ +export function uploadXhr(input: { + readonly url: string; + readonly file: File; + readonly contentType?: string; + readonly timeoutMs: number; + readonly onProgress: (progress: number) => void; +}): { readonly done: Promise; readonly abort: () => void } { + const xhr = new XMLHttpRequest(); + const done = new Promise((resolve, reject) => { + xhr.open("POST", input.url, true); + xhr.timeout = input.timeoutMs; + if (input.contentType !== undefined) { + xhr.setRequestHeader("Content-Type", input.contentType); + } + xhr.upload.addEventListener("progress", (event) => { + if (event.lengthComputable && event.total > 0) { + input.onProgress(event.loaded / event.total); + } + }); + xhr.addEventListener("load", () => { + if (xhr.status >= 200 && xhr.status < 300) { + resolve(); + } else { + reject(new Error(`Upload rejected (${xhr.status})`)); + } + }); + xhr.addEventListener("error", () => reject(new Error("Upload failed"))); + xhr.addEventListener("timeout", () => reject(new Error("Upload timed out"))); + xhr.addEventListener("abort", () => reject(new Error("Upload cancelled"))); + xhr.send(input.file); + }); + + return { done, abort: () => xhr.abort() }; +} diff --git a/apps/web/src/lib/workspaceUploadQueue.test.ts b/apps/web/src/lib/workspaceUploadQueue.test.ts index 907364273107..8c2922dadc35 100644 --- a/apps/web/src/lib/workspaceUploadQueue.test.ts +++ b/apps/web/src/lib/workspaceUploadQueue.test.ts @@ -25,8 +25,8 @@ vi.mock("../state/session", () => ({ readPreparedConnection: mocks.readPreparedConnection, })); -vi.mock("../confirmDialog", () => ({ - requestConfirmDialog: mocks.requestConfirmDialog, +vi.mock("../localApi", () => ({ + readLocalApi: () => ({ dialogs: { confirm: mocks.requestConfirmDialog } }), })); import { diff --git a/apps/web/src/lib/workspaceUploadQueue.ts b/apps/web/src/lib/workspaceUploadQueue.ts index 5b266496e7ab..17f0dfe72363 100644 --- a/apps/web/src/lib/workspaceUploadQueue.ts +++ b/apps/web/src/lib/workspaceUploadQueue.ts @@ -4,14 +4,17 @@ import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; import * as Cause from "effect/Cause"; import { create } from "zustand"; -import { requestConfirmDialog } from "../confirmDialog"; +import { readLocalApi } from "../localApi"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { projectEnvironment } from "../state/projects"; import { readPreparedConnection } from "../state/session"; import { randomUUID } from "./utils"; +import { uploadXhr } from "./uploadXhr"; const MAX_UPLOADS_PER_ENVIRONMENT = 3; -const UPLOAD_TIMEOUT_MS = 5 * 60_000; +// Matches the upload token TTL (see PROJECT_UPLOAD_URL_TTL_MS), since +// workspace uploads allow files up to 100 MiB. +const UPLOAD_TIMEOUT_MS = 10 * 60_000; export type WorkspaceUploadState = | { @@ -85,36 +88,6 @@ function failJob(job: UploadJob, reason: string): void { }); } -function uploadBytes(input: { - readonly url: string; - readonly file: File; - readonly onProgress: (progress: number) => void; -}): { readonly done: Promise; readonly abort: () => void } { - const xhr = new XMLHttpRequest(); - const done = new Promise((resolve, reject) => { - xhr.open("POST", input.url, true); - xhr.timeout = UPLOAD_TIMEOUT_MS; - xhr.upload.addEventListener("progress", (event) => { - if (event.lengthComputable && event.total > 0) { - input.onProgress(event.loaded / event.total); - } - }); - xhr.addEventListener("load", () => { - if (xhr.status >= 200 && xhr.status < 300) { - resolve(); - } else { - reject(new Error(`Upload rejected (${xhr.status})`)); - } - }); - xhr.addEventListener("error", () => reject(new Error("Upload failed"))); - xhr.addEventListener("timeout", () => reject(new Error("Upload timed out"))); - xhr.addEventListener("abort", () => reject(new Error("Upload cancelled"))); - xhr.send(input.file); - }); - - return { done, abort: () => xhr.abort() }; -} - function mintUploadUrl(job: UploadJob) { return runAtomCommand( appAtomRegistry, @@ -154,7 +127,7 @@ async function runUpload(job: UploadJob): Promise { return; } - const confirmed = await requestConfirmDialog( + const confirmed = await readLocalApi()?.dialogs.confirm( `Replace ${job.file.name}?\nA file named '${job.relativePath}' already exists in this project.`, ); if (job.cancelled) { @@ -186,9 +159,10 @@ async function runUpload(job: UploadJob): Promise { } let lastStep = -1; - const upload = uploadBytes({ + const upload = uploadXhr({ url, file: job.file, + timeoutMs: UPLOAD_TIMEOUT_MS, onProgress: (progress) => { const step = Math.floor(progress * 20); if (step === lastStep || job.cancelled) { diff --git a/docs/user/files.md b/docs/user/files.md index bdecc6991d7a..a953804d070c 100644 --- a/docs/user/files.md +++ b/docs/user/files.md @@ -13,6 +13,8 @@ message without leaving the tree. ## Uploading files +Uploading is available in the web and desktop apps. The mobile files view does not support it. + Drag files from your computer onto the files view, or select the upload button at the top of the panel to choose files from a picker. Uploads land in the project's root folder. Dragging a folder does not upload its contents; drop the files themselves. From b298d644bddc6ab0745fe745336f7f51a4c57608 Mon Sep 17 00:00:00 2001 From: Mark S <49048586+msegec@users.noreply.github.com> Date: Tue, 25 Aug 2026 09:09:40 +0800 Subject: [PATCH 09/25] fix(web,server): close upload races and dedupe the drop overlay Store the non-overwrite upload with an atomic hard link so a concurrent upload gets a 409 instead of silently replacing the file, and ignore a second retry click while the retried job is already uploading. Share one drop-overlay component between the chat and files views, reuse the attachment progress formatter, cap the uploads strip height, size the row buttons to the compact-row contract, and name the mint target in the resolve error message. --- apps/server/src/workspace/WorkspaceUpload.ts | 24 ++++++++++++++++-- apps/web/src/components/ChatView.tsx | 16 ++++-------- .../chat/WorkspaceFileDropOverlay.tsx | 22 ++++++++++++++++ .../src/components/files/FileBrowserPanel.tsx | 25 ++++++++----------- apps/web/src/lib/workspaceUploadQueue.test.ts | 18 +++++++++++++ apps/web/src/lib/workspaceUploadQueue.ts | 4 +++ 6 files changed, 81 insertions(+), 28 deletions(-) create mode 100644 apps/web/src/components/chat/WorkspaceFileDropOverlay.tsx diff --git a/apps/server/src/workspace/WorkspaceUpload.ts b/apps/server/src/workspace/WorkspaceUpload.ts index 8f89a93d4af5..0e289a8928c7 100644 --- a/apps/server/src/workspace/WorkspaceUpload.ts +++ b/apps/server/src/workspace/WorkspaceUpload.ts @@ -85,7 +85,7 @@ export const issueWorkspaceUploadUrl = Effect.fn("WorkspaceUpload.issueUrl")(fun new ProjectCreateUploadUrlError({ cwd: input.cwd, relativePath: input.relativePath, - message: error.message, + message: `Failed to resolve '${input.relativePath}' within '${input.cwd}'.`, cause: error, }), ), @@ -202,7 +202,27 @@ export const storeWorkspaceUpload = Effect.fn("WorkspaceUpload.store")(function* yield* fileSystem.makeDirectory(path.dirname(target.absolutePath), { recursive: true }); yield* fileSystem.writeFile(partPath, bytes); - yield* fileSystem.rename(partPath, target.absolutePath); + if (claims.overwrite) { + yield* fileSystem.rename(partPath, target.absolutePath); + } else { + // rename replaces a file created after the exists check above; link fails + // atomically instead, so concurrent non-overwrite uploads cannot clobber. + const conflict = yield* fileSystem.link(partPath, target.absolutePath).pipe( + Effect.as(false), + Effect.catchIf( + (error) => error.reason._tag === "AlreadyExists", + () => Effect.succeed(true), + ), + ); + yield* fileSystem.remove(partPath, { force: true }); + if (conflict) { + return { + ok: false, + status: 409, + detail: "A file already exists at this path.", + } satisfies StoreWorkspaceUploadResult; + } + } const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; yield* workspaceEntries.refresh(claims.cwd); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index cb1cf698535a..d650673fd78e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -156,6 +156,7 @@ import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer"; import { subscribePreviewAction } from "./preview/previewActionBus"; import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic"; import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop"; +import { WorkspaceFileDropOverlay } from "./chat/WorkspaceFileDropOverlay"; import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore, @@ -6669,18 +6670,11 @@ function ChatViewContent(props: ChatViewProps) { onDrop={workspaceFileDropHandlers.onDrop} > {isWorkspaceFileDragActive ? ( - + /> ) : null} {/* Provider status overlays the timeline without changing its content height. */}
diff --git a/apps/web/src/components/chat/WorkspaceFileDropOverlay.tsx b/apps/web/src/components/chat/WorkspaceFileDropOverlay.tsx new file mode 100644 index 000000000000..4d96ce250a04 --- /dev/null +++ b/apps/web/src/components/chat/WorkspaceFileDropOverlay.tsx @@ -0,0 +1,22 @@ +import type { ComponentPropsWithoutRef, ReactNode } from "react"; + +/** Full-surface drop treatment shared by the chat and files-view drop targets. */ +export function WorkspaceFileDropOverlay( + props: { icon: ReactNode; label: string } & ComponentPropsWithoutRef<"div">, +) { + const { icon, label, ...rest } = props; + return ( +
+
+ {icon} + {label} +
+
+ ); +} diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index bc546ff8e886..f3654e097853 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -17,6 +17,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { useComposerHandleContext } from "~/composerHandleContext"; import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; import { useTheme } from "~/hooks/useTheme"; +import { formatAttachmentUploadProgress } from "~/lib/attachmentUploadState"; import { cancelWorkspaceUpload, dismissWorkspaceUpload, @@ -30,6 +31,7 @@ import { readLocalApi } from "~/localApi"; import { T3_PIERRE_ICONS } from "~/pierre-icons"; import { makeWorkspaceFileDropHandlers } from "../chat/workspaceFileDrop"; +import { WorkspaceFileDropOverlay } from "../chat/WorkspaceFileDropOverlay"; import { createFileTreeDragMentionController } from "./fileTreeDragMention"; import { useProjectEntriesQuery } from "./projectFilesQueryState"; @@ -110,8 +112,8 @@ function UploadRowButton(props: { label: string; icon: ReactNode; onClick: () => render={