From 152e75f9f49e1229cdee9453e334d571bd792a97 Mon Sep 17 00:00:00 2001 From: h4rzx <0tox@iterelle.tech> Date: Mon, 22 Jun 2026 15:33:23 -0500 Subject: [PATCH 1/2] fix(workspaces): persist durable workspace identity Add projection-backed workspace records and repair workspace grouping when threads move between branch, local, and worktree contexts. Harden desktop dev sandbox startup so the workspace layout can be verified reliably. --- apps/desktop/scripts/dev-electron.mjs | 51 ++-- apps/desktop/scripts/electron-launcher.mjs | 15 +- .../src/app/DesktopBackendOutputLog.ts | 15 +- apps/desktop/src/app/DesktopConsole.test.ts | 39 ++++ apps/desktop/src/app/DesktopConsole.ts | 40 ++++ .../src/app/DesktopObservability.test.ts | 48 ++++ .../src/electron/ElectronProtocol.test.ts | 64 ++++- apps/desktop/src/electron/ElectronProtocol.ts | 54 ++++- apps/desktop/src/main.ts | 2 + apps/server/src/http.ts | 5 +- .../Layers/ProjectionPipeline.test.ts | 182 +++++++++++++++ .../Layers/ProjectionPipeline.ts | 71 +++++- .../Layers/ProjectionSnapshotQuery.ts | 218 +++++++++++------- .../src/orchestration/projector.test.ts | 4 + apps/server/src/orchestration/projector.ts | 37 ++- .../orchestration/threadWorkspaceIdentity.ts | 80 +++++++ .../Layers/ProjectionRepositories.test.ts | 1 + .../persistence/Layers/ProjectionThreads.ts | 5 + .../Layers/ProjectionWorkspaces.ts | 95 ++++++++ apps/server/src/persistence/Migrations.ts | 4 + .../Migrations/033_ProjectionWorkspaces.ts | 110 +++++++++ .../034_RebuildProjectionWorkspaces.ts | 65 ++++++ .../persistence/Services/ProjectionThreads.ts | 5 + .../Services/ProjectionWorkspaces.ts | 40 ++++ apps/server/src/server.test.ts | 20 ++ .../web/src/components/ChatView.logic.test.ts | 33 +++ apps/web/src/components/ChatView.logic.ts | 11 + apps/web/src/components/ChatView.tsx | 17 +- apps/web/src/components/Sidebar.logic.test.ts | 39 ++++ apps/web/src/components/Sidebar.logic.ts | 33 ++- docs/project/harness-enhancements.md | 22 +- packages/contracts/src/baseSchemas.ts | 2 + packages/contracts/src/orchestration.ts | 24 ++ 33 files changed, 1311 insertions(+), 140 deletions(-) create mode 100644 apps/desktop/src/app/DesktopConsole.test.ts create mode 100644 apps/desktop/src/app/DesktopConsole.ts create mode 100644 apps/server/src/persistence/Layers/ProjectionWorkspaces.ts create mode 100644 apps/server/src/persistence/Migrations/033_ProjectionWorkspaces.ts create mode 100644 apps/server/src/persistence/Migrations/034_RebuildProjectionWorkspaces.ts create mode 100644 apps/server/src/persistence/Services/ProjectionWorkspaces.ts diff --git a/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs index c28d5ec358b6..b00c3bcca03f 100644 --- a/apps/desktop/scripts/dev-electron.mjs +++ b/apps/desktop/scripts/dev-electron.mjs @@ -44,14 +44,6 @@ await waitForResources({ tcpPort: port, }); -const childEnv = { ...process.env }; -delete childEnv.ELECTRON_RUN_AS_NODE; -const devProtocolClient = resolveDevProtocolClient(); -if (devProtocolClient) { - childEnv.T3CODE_DESKTOP_APP_USER_MODEL_ID = devProtocolClient.appBundleId; - childEnv.T3CODE_DESKTOP_PROTOCOL_REGISTRATION_MANAGED = "1"; -} - let shuttingDown = false; let restartTimer = null; let currentApp = null; @@ -75,6 +67,37 @@ function cleanupStaleDevApps() { NodeChildProcess.spawnSync("pkill", ["-f", "--", `--t3code-dev-root=${desktopDir}`], { stdio: "ignore", }); + NodeChildProcess.spawnSync( + "pkill", + ["-f", "--", `${NodePath.join(desktopDir, ".electron-runtime")}/T3 Code (Dev).app`], + { + stdio: "ignore", + }, + ); +} + +function isShellScript(path) { + try { + const buffer = Buffer.alloc(2); + const fd = NodeFS.openSync(path, "r"); + try { + NodeFS.readSync(fd, buffer, 0, buffer.length, 0); + return buffer[0] === 0x23 && buffer[1] === 0x21; + } finally { + NodeFS.closeSync(fd); + } + } catch { + return false; + } +} + +cleanupStaleDevApps(); +const childEnv = { ...process.env }; +delete childEnv.ELECTRON_RUN_AS_NODE; +const devProtocolClient = resolveDevProtocolClient(); +if (devProtocolClient) { + childEnv.T3CODE_DESKTOP_APP_USER_MODEL_ID = devProtocolClient.appBundleId; + childEnv.T3CODE_DESKTOP_PROTOCOL_REGISTRATION_MANAGED = "1"; } function startApp() { @@ -85,11 +108,12 @@ function startApp() { const electronArgs = remoteDebuggingPort ? [`--remote-debugging-port=${remoteDebuggingPort}`] : []; - const launchArgs = devProtocolClient - ? electronArgs - : [...electronArgs, `--t3code-dev-root=${desktopDir}`, "dist-electron/main.cjs"]; - const electronCommand = resolveElectronLaunchCommand(launchArgs); - const app = NodeChildProcess.spawn(electronCommand.electronPath, electronCommand.args, { + const electronCommand = resolveElectronLaunchCommand(electronArgs); + const launchArgs = + devProtocolClient && isShellScript(electronCommand.electronPath) + ? electronCommand.args + : [...electronCommand.args, `--t3code-dev-root=${desktopDir}`, "dist-electron/main.cjs"]; + const app = NodeChildProcess.spawn(electronCommand.electronPath, launchArgs, { cwd: desktopDir, env: childEnv, stdio: "inherit", @@ -233,7 +257,6 @@ async function shutdown(exitCode) { } startWatchers(); -cleanupStaleDevApps(); startApp(); process.once("SIGINT", () => { diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index 69df02fb80d1..e7e0efcb088a 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -20,7 +20,7 @@ export const APP_BUNDLE_ID = isDevelopment ? `com.t3tools.t3code.dev.${devBundleIdSuffix || "local"}` : "com.t3tools.t3code"; const APP_PROTOCOL_SCHEMES = isDevelopment ? ["t3code-dev"] : ["t3code"]; -const LAUNCHER_VERSION = 12; +const LAUNCHER_VERSION = 13; const defaultIconPath = NodePath.join(desktopDir, "resources", "icon.icns"); const developmentMacIconPngPath = NodePath.join( repoRoot, @@ -100,9 +100,8 @@ function shellSingleQuote(value) { return `'${value.replaceAll("'", "'\\''")}'`; } -function writeDevelopmentLauncherScript(targetBinaryPath, electronBinaryPath) { - const mainEntryPath = NodePath.join(desktopDir, "dist-electron", "main.cjs"); - const envEntries = [ +function resolveDevelopmentLauncherEnvEntries() { + return [ ["VITE_DEV_SERVER_URL", process.env.VITE_DEV_SERVER_URL], ["T3CODE_PORT", process.env.T3CODE_PORT], ["T3CODE_HOME", process.env.T3CODE_HOME], @@ -111,6 +110,11 @@ function writeDevelopmentLauncherScript(targetBinaryPath, electronBinaryPath) { ["T3CODE_OTLP_EXPORT_INTERVAL_MS", process.env.T3CODE_OTLP_EXPORT_INTERVAL_MS], ["T3CODE_DESKTOP_APP_USER_MODEL_ID", APP_BUNDLE_ID], ].filter((entry) => typeof entry[1] === "string" && entry[1].trim().length > 0); +} + +function writeDevelopmentLauncherScript(targetBinaryPath, electronBinaryPath) { + const mainEntryPath = NodePath.join(desktopDir, "dist-electron", "main.cjs"); + const envEntries = resolveDevelopmentLauncherEnvEntries(); NodeFS.writeFileSync( targetBinaryPath, [ @@ -278,6 +282,9 @@ function buildMacLauncher(electronBinaryPath) { iconMtimeMs: NodeFS.statSync(iconPath).mtimeMs, appBundleId: APP_BUNDLE_ID, appProtocolSchemes: APP_PROTOCOL_SCHEMES, + ...(isDevelopment + ? { launcherEnvEntries: Object.fromEntries(resolveDevelopmentLauncherEnvEntries()) } + : {}), }; const currentMetadata = readJson(metadataPath); diff --git a/apps/desktop/src/app/DesktopBackendOutputLog.ts b/apps/desktop/src/app/DesktopBackendOutputLog.ts index cad83229deb1..67cbf1015d31 100644 --- a/apps/desktop/src/app/DesktopBackendOutputLog.ts +++ b/apps/desktop/src/app/DesktopBackendOutputLog.ts @@ -11,6 +11,7 @@ import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; +import { ensureConsoleStreamGuard, isIgnorableConsoleStreamError } from "./DesktopConsole.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; export const DESKTOP_LOG_FILE_MAX_BYTES = 10 * 1024 * 1024; @@ -267,12 +268,18 @@ const writeDevelopmentConsoleOutput = ( streamName: "stdout" | "stderr", chunk: Uint8Array, ): Effect.Effect => - Effect.try({ - try: () => { + Effect.suspend(() => { + try { const output = streamName === "stderr" ? process.stderr : process.stdout; + ensureConsoleStreamGuard(output); + if (!output.writable || output.destroyed || output.writableEnded) return Effect.void; output.write(chunk); - }, - catch: (cause) => new DesktopBackendConsoleWriteError({ streamName, cause }), + return Effect.void; + } catch (cause) { + return isIgnorableConsoleStreamError(cause) + ? Effect.void + : Effect.fail(new DesktopBackendConsoleWriteError({ streamName, cause })); + } }).pipe( Effect.catchTags({ DesktopBackendConsoleWriteError: (error) => Effect.logError(error.message, { error }), diff --git a/apps/desktop/src/app/DesktopConsole.test.ts b/apps/desktop/src/app/DesktopConsole.test.ts new file mode 100644 index 000000000000..f1f3418e688d --- /dev/null +++ b/apps/desktop/src/app/DesktopConsole.test.ts @@ -0,0 +1,39 @@ +import { assert, describe, it } from "@effect/vitest"; + +import "./DesktopConsole.ts"; + +describe("DesktopConsole", () => { + it("ignores EPIPE thrown by console stdout writes", () => { + const originalWrite = process.stdout.write; + process.stdout.write = function () { + throw Object.assign(new Error("write EPIPE"), { code: "EPIPE" }); + } as typeof process.stdout.write; + + try { + const guardedLog: (...data: Array) => void = console["log"].bind(console); + assert.doesNotThrow(() => guardedLog("ignored broken stdout")); + } finally { + process.stdout.write = originalWrite; + } + }); + + it("ignores EPIPE emitted by console stdout writes", async () => { + const originalWrite = process.stdout.write; + process.stdout.write = function (...args: Parameters) { + const error = Object.assign(new Error("write EPIPE"), { code: "EPIPE" }); + queueMicrotask(() => process.stdout.emit("error", error)); + for (const arg of args) { + if (typeof arg === "function") arg(); + } + return false; + } as typeof process.stdout.write; + + try { + const guardedLog: (...data: Array) => void = console["log"].bind(console); + guardedLog("ignored broken stdout"); + await new Promise((resolve) => setImmediate(resolve)); + } finally { + process.stdout.write = originalWrite; + } + }); +}); diff --git a/apps/desktop/src/app/DesktopConsole.ts b/apps/desktop/src/app/DesktopConsole.ts new file mode 100644 index 000000000000..cabf89a4b2c9 --- /dev/null +++ b/apps/desktop/src/app/DesktopConsole.ts @@ -0,0 +1,40 @@ +const guardedConsoleStreams = new WeakSet(); + +export function isIgnorableConsoleStreamError(cause: unknown): boolean { + if (!(cause instanceof Error)) return false; + const errorCode = "code" in cause && typeof cause.code === "string" ? cause.code : undefined; + return errorCode === "EPIPE" || errorCode === "ERR_STREAM_DESTROYED"; +} + +export function ensureConsoleStreamGuard(output: NodeJS.WriteStream): void { + if (guardedConsoleStreams.has(output)) return; + guardedConsoleStreams.add(output); + output.on("error", (cause) => { + if (isIgnorableConsoleStreamError(cause)) return; + throw cause; + }); +} + +function guardConsoleMethod) => void>(method: T): T { + return ((...args: Parameters) => { + try { + method(...args); + } catch (cause) { + if (!isIgnorableConsoleStreamError(cause)) { + throw cause; + } + } + }) as T; +} + +export function installDesktopConsoleGuards(): void { + ensureConsoleStreamGuard(process.stdout); + ensureConsoleStreamGuard(process.stderr); + console.log = guardConsoleMethod(console.log.bind(console)); + console.info = guardConsoleMethod(console.info.bind(console)); + console.warn = guardConsoleMethod(console.warn.bind(console)); + console.error = guardConsoleMethod(console.error.bind(console)); + console.debug = guardConsoleMethod(console.debug.bind(console)); +} + +installDesktopConsoleGuards(); diff --git a/apps/desktop/src/app/DesktopObservability.test.ts b/apps/desktop/src/app/DesktopObservability.test.ts index a78de48d5e19..c75be714b46e 100644 --- a/apps/desktop/src/app/DesktopObservability.test.ts +++ b/apps/desktop/src/app/DesktopObservability.test.ts @@ -159,4 +159,52 @@ describe("DesktopObservability", () => { Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)), ), ); + + it.effect("ignores a broken development console pipe while persisting backend child output", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-backend-output-epipe-test-", + }); + const environmentLayer = makeEnvironmentLayer(baseDir); + const logPath = yield* Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + return environment.path.join(environment.logDir, "server-child.log"); + }).pipe(Effect.provide(environmentLayer)); + + const originalWrite = process.stdout.write; + process.stdout.write = function (...args: Parameters) { + const error = Object.assign(new Error("write EPIPE"), { code: "EPIPE" }); + queueMicrotask(() => process.stdout.emit("error", error)); + for (const arg of args) { + if (typeof arg === "function") arg(); + } + return false; + } as typeof process.stdout.write; + + try { + yield* Effect.gen(function* () { + const outputLog = yield* DesktopObservability.DesktopBackendOutputLog; + yield* outputLog.writeOutputChunk("stdout", new TextEncoder().encode("hello server\n")); + yield* Effect.promise(() => new Promise((resolve) => setImmediate(resolve))); + }).pipe( + Effect.annotateLogs({ runId: "test-run" }), + Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))), + ); + } finally { + process.stdout.write = originalWrite; + } + + const log = yield* fileSystem.readFileString(logPath); + const lines = log.trimEnd().split("\n"); + const output = yield* decodeDesktopBackendChildLogRecord(lines[0] ?? ""); + + assert.equal(output.message, "backend child process output"); + assert.equal(output.annotations.stream, "stdout"); + assert.equal(output.annotations.text, "hello server\n"); + }).pipe( + Effect.scoped, + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)), + ), + ); }); diff --git a/apps/desktop/src/electron/ElectronProtocol.test.ts b/apps/desktop/src/electron/ElectronProtocol.test.ts index 56fe009fee22..b0e209c36390 100644 --- a/apps/desktop/src/electron/ElectronProtocol.test.ts +++ b/apps/desktop/src/electron/ElectronProtocol.test.ts @@ -3,15 +3,22 @@ import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import { beforeEach, vi } from "vite-plus/test"; -const { handleMock, netFetchMock, unhandleMock } = vi.hoisted(() => ({ - handleMock: vi.fn(), - netFetchMock: vi.fn(), - unhandleMock: vi.fn(), -})); +const { handleMock, netFetchMock, registerSchemesAsPrivilegedMock, unhandleMock } = vi.hoisted( + () => ({ + handleMock: vi.fn(), + netFetchMock: vi.fn(), + registerSchemesAsPrivilegedMock: vi.fn(), + unhandleMock: vi.fn(), + }), +); vi.mock("electron", () => ({ net: { fetch: netFetchMock }, - protocol: { handle: handleMock, unhandle: unhandleMock }, + protocol: { + handle: handleMock, + registerSchemesAsPrivileged: registerSchemesAsPrivilegedMock, + unhandle: unhandleMock, + }, })); import * as ElectronProtocol from "./ElectronProtocol.ts"; @@ -23,6 +30,37 @@ describe("ElectronProtocol", () => { unhandleMock.mockReset(); }); + it("registers desktop URL schemes with browser-compatible privileges before app ready", () => { + assert.deepEqual(registerSchemesAsPrivilegedMock.mock.calls, [ + [ + [ + { + scheme: "t3code", + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + corsEnabled: true, + stream: true, + codeCache: true, + }, + }, + { + scheme: "t3code-dev", + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + corsEnabled: true, + stream: true, + codeCache: true, + }, + }, + ], + ], + ]); + }); + it.effect("proxies the stable renderer origin to the current app server", () => Effect.gen(function* () { let handler: ((request: Request) => Promise) | undefined; @@ -43,7 +81,16 @@ describe("ElectronProtocol", () => { assert.isDefined(handler); const response = yield* Effect.promise(() => - handler!(new Request("t3code-dev://app/api/health?verbose=1")), + handler!( + new Request("t3code-dev://app/api/health?verbose=1", { + headers: { + Accept: "application/json", + Origin: "t3code-dev://app", + Referer: "t3code-dev://app/", + "Sec-Fetch-Site": "same-origin", + }, + }), + ), ); assert.equal(yield* Effect.promise(() => response.text()), "ok"); assert.include( @@ -70,6 +117,9 @@ describe("ElectronProtocol", () => { ["t3code-dev"], ); assert.equal(netFetchMock.mock.calls[0]?.[0], "http://127.0.0.1:3773/api/health?verbose=1"); + assert.deepEqual(Array.from(netFetchMock.mock.calls[0]?.[1]?.headers ?? []), [ + ["accept", "application/json"], + ]); assert.deepEqual(unhandleMock.mock.calls, [["t3code-dev"]]); }).pipe(Effect.provide(ElectronProtocol.layer)), ); diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index 757c26178d0d..51ed250de026 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -11,6 +11,31 @@ export const DESKTOP_HOST = "app"; export const DESKTOP_PRODUCTION_SCHEME = "t3code"; export const DESKTOP_DEVELOPMENT_SCHEME = "t3code-dev"; +Electron.protocol.registerSchemesAsPrivileged([ + { + scheme: DESKTOP_PRODUCTION_SCHEME, + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + corsEnabled: true, + stream: true, + codeCache: true, + }, + }, + { + scheme: DESKTOP_DEVELOPMENT_SCHEME, + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + corsEnabled: true, + stream: true, + codeCache: true, + }, + }, +]); + export function getDesktopScheme(isDevelopment: boolean): string { return isDevelopment ? DESKTOP_DEVELOPMENT_SCHEME : DESKTOP_PRODUCTION_SCHEME; } @@ -103,6 +128,25 @@ function withContentSecurityPolicy(response: Response, policy: string): Response }); } +const PROXIED_REQUEST_HEADERS = new Set([ + "accept", + "accept-language", + "content-type", + "if-modified-since", + "if-none-match", + "range", +]); + +function makeProxyRequestHeaders(headers: Headers): Headers { + const output = new Headers(); + for (const [name, value] of headers) { + if (PROXIED_REQUEST_HEADERS.has(name.toLowerCase())) { + output.set(name, value); + } + } + return output; +} + async function proxyRequest( request: Request, targetOrigin: URL, @@ -116,14 +160,18 @@ async function proxyRequest( const targetUrl = new URL(`${requestUrl.pathname}${requestUrl.search}`, targetOrigin); const init: RequestInit = { method: request.method, - headers: request.headers, + headers: makeProxyRequestHeaders(request.headers), }; if (request.method !== "GET" && request.method !== "HEAD") { init.body = request.body; (init as RequestInit & { duplex: "half" }).duplex = "half"; } - const response = await Electron.net.fetch(targetUrl.toString(), init); - return withContentSecurityPolicy(response, contentSecurityPolicy); + try { + const response = await Electron.net.fetch(targetUrl.toString(), init); + return withContentSecurityPolicy(response, contentSecurityPolicy); + } catch { + return new Response("Desktop protocol proxy request failed.", { status: 502 }); + } } export const make = Effect.gen(function* () { diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index b88eb18e57f9..82e43e27b540 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1,3 +1,5 @@ +import "./app/DesktopConsole.ts"; + import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index ce9b498cb1f1..32352b52b888 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -44,13 +44,16 @@ import { browserApiCorsAllowedHeaders, browserApiCorsAllowedMethods } from "./ht const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces"; const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); +const DESKTOP_APP_ORIGINS = ["t3code://app", "t3code-dev://app"] as const; export const browserApiCorsLayer = Layer.unwrap( Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; const devOrigin = config.devUrl?.origin; return HttpRouter.cors({ - ...(devOrigin ? { allowedOrigins: [devOrigin], credentials: true } : {}), + ...(devOrigin + ? { allowedOrigins: [devOrigin, ...DESKTOP_APP_ORIGINS], credentials: true } + : {}), allowedMethods: browserApiCorsAllowedMethods, allowedHeaders: browserApiCorsAllowedHeaders, maxAge: 600, diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 0999000ed4f9..1024c2499dbe 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -173,6 +173,188 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { } }), ); + + it.effect("persists workspace identity and updates workspace metadata on branch rename", () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const createdAt = "2026-01-01T00:00:00.000Z"; + const renamedAt = "2026-01-01T00:05:00.000Z"; + + yield* eventStore.append({ + type: "thread.created", + eventId: EventId.make("evt-workspace-thread-created"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-workspace"), + occurredAt: createdAt, + commandId: CommandId.make("cmd-workspace-thread-created"), + causationEventId: null, + correlationId: CommandId.make("cmd-workspace-thread-created"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-workspace"), + projectId: ProjectId.make("project-workspace"), + title: "Workspace thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + branch: "feature/old-name", + worktreePath: "/repo/.t3/worktrees/checks", + createdAt, + updatedAt: createdAt, + }, + }); + yield* eventStore.append({ + type: "thread.meta-updated", + eventId: EventId.make("evt-workspace-thread-renamed"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-workspace"), + occurredAt: renamedAt, + commandId: CommandId.make("cmd-workspace-thread-renamed"), + causationEventId: null, + correlationId: CommandId.make("cmd-workspace-thread-renamed"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-workspace"), + branch: "feature/new-name", + updatedAt: renamedAt, + }, + }); + + yield* projectionPipeline.bootstrap; + + const rows = yield* sql<{ + readonly threadWorkspaceId: string | null; + readonly workspaceId: string; + readonly branch: string | null; + readonly worktreePath: string | null; + }>` + SELECT + threads.workspace_id AS "threadWorkspaceId", + workspaces.workspace_id AS "workspaceId", + workspaces.branch, + workspaces.worktree_path AS "worktreePath" + FROM projection_threads AS threads + INNER JOIN projection_workspaces AS workspaces + ON workspaces.workspace_id = threads.workspace_id + WHERE threads.thread_id = 'thread-workspace' + `; + + assert.deepEqual(rows, [ + { + threadWorkspaceId: "project-workspace:workspace:worktree:/repo/.t3/worktrees/checks", + workspaceId: "project-workspace:workspace:worktree:/repo/.t3/worktrees/checks", + branch: "feature/new-name", + worktreePath: "/repo/.t3/worktrees/checks", + }, + ]); + }), + ); + + it.effect( + "moves one thread to a worktree workspace without relabeling sibling branch threads", + () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const createdAt = "2026-01-01T00:00:00.000Z"; + const movedAt = "2026-01-01T00:05:00.000Z"; + + for (const threadId of ["thread-branch-a", "thread-branch-b"] as const) { + yield* eventStore.append({ + type: "thread.created", + eventId: EventId.make(`evt-${threadId}-created`), + aggregateKind: "thread", + aggregateId: ThreadId.make(threadId), + occurredAt: createdAt, + commandId: CommandId.make(`cmd-${threadId}-created`), + causationEventId: null, + correlationId: CommandId.make(`cmd-${threadId}-created`), + metadata: {}, + payload: { + threadId: ThreadId.make(threadId), + projectId: ProjectId.make("project-workspace-move"), + title: threadId, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + branch: "feat/enhancements", + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }); + } + + yield* eventStore.append({ + type: "thread.meta-updated", + eventId: EventId.make("evt-thread-branch-a-moved"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-branch-a"), + occurredAt: movedAt, + commandId: CommandId.make("cmd-thread-branch-a-moved"), + causationEventId: null, + correlationId: CommandId.make("cmd-thread-branch-a-moved"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-branch-a"), + branch: "t3code/handle-greeting", + worktreePath: "/repo/.t3/worktrees/handle-greeting", + updatedAt: movedAt, + }, + }); + + yield* projectionPipeline.bootstrap; + + const rows = yield* sql<{ + readonly threadId: string; + readonly threadWorkspaceId: string | null; + readonly threadBranch: string | null; + readonly threadWorktreePath: string | null; + readonly workspaceBranch: string | null; + readonly workspaceWorktreePath: string | null; + }>` + SELECT + threads.thread_id AS "threadId", + threads.workspace_id AS "threadWorkspaceId", + threads.branch AS "threadBranch", + threads.worktree_path AS "threadWorktreePath", + workspaces.branch AS "workspaceBranch", + workspaces.worktree_path AS "workspaceWorktreePath" + FROM projection_threads AS threads + INNER JOIN projection_workspaces AS workspaces + ON workspaces.workspace_id = threads.workspace_id + WHERE threads.thread_id IN ('thread-branch-a', 'thread-branch-b') + ORDER BY threads.thread_id ASC + `; + + assert.deepEqual(rows, [ + { + threadId: "thread-branch-a", + threadWorkspaceId: + "project-workspace-move:workspace:worktree:/repo/.t3/worktrees/handle-greeting", + threadBranch: "t3code/handle-greeting", + threadWorktreePath: "/repo/.t3/worktrees/handle-greeting", + workspaceBranch: "t3code/handle-greeting", + workspaceWorktreePath: "/repo/.t3/worktrees/handle-greeting", + }, + { + threadId: "thread-branch-b", + threadWorkspaceId: "project-workspace-move:workspace:branch:feat/enhancements", + threadBranch: "feat/enhancements", + threadWorktreePath: null, + workspaceBranch: "feat/enhancements", + workspaceWorktreePath: null, + }, + ]); + }), + ); }); it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-base-")))( diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index bf30dcae4a5a..2f7d7fb3c51d 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -3,6 +3,7 @@ import { type ChatAttachment, type OrchestrationEvent, type OrchestrationSessionStatus, + type ProjectId, ThreadId, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; @@ -29,6 +30,7 @@ import { ProjectionThreadProposedPlanRepository, } from "../../persistence/Services/ProjectionThreadProposedPlans.ts"; import { ProjectionThreadSessionRepository } from "../../persistence/Services/ProjectionThreadSessions.ts"; +import { ProjectionWorkspaceRepository } from "../../persistence/Services/ProjectionWorkspaces.ts"; import { type ProjectionTurn, ProjectionTurnRepository, @@ -41,6 +43,7 @@ import { ProjectionThreadActivityRepositoryLive } from "../../persistence/Layers import { ProjectionThreadMessageRepositoryLive } from "../../persistence/Layers/ProjectionThreadMessages.ts"; import { ProjectionThreadProposedPlanRepositoryLive } from "../../persistence/Layers/ProjectionThreadProposedPlans.ts"; import { ProjectionThreadSessionRepositoryLive } from "../../persistence/Layers/ProjectionThreadSessions.ts"; +import { ProjectionWorkspaceRepositoryLive } from "../../persistence/Layers/ProjectionWorkspaces.ts"; import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts"; import { ProjectionThreadRepositoryLive } from "../../persistence/Layers/ProjectionThreads.ts"; import { ServerConfig } from "../../config.ts"; @@ -48,7 +51,10 @@ import { OrchestrationProjectionPipeline, type OrchestrationProjectionPipelineShape, } from "../Services/ProjectionPipeline.ts"; -import { resolveThreadWorkspaceIdentityPatch } from "../threadWorkspaceIdentity.ts"; +import { + deriveThreadWorkspaceRecord, + resolveThreadWorkspaceRecordForPatch, +} from "../threadWorkspaceIdentity.ts"; import { attachmentRelativePath, parseAttachmentIdFromRelativePath, @@ -479,6 +485,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti const projectionThreadProposedPlanRepository = yield* ProjectionThreadProposedPlanRepository; const projectionThreadActivityRepository = yield* ProjectionThreadActivityRepository; const projectionThreadSessionRepository = yield* ProjectionThreadSessionRepository; + const projectionWorkspaceRepository = yield* ProjectionWorkspaceRepository; const projectionTurnRepository = yield* ProjectionTurnRepository; const projectionPendingApprovalRepository = yield* ProjectionPendingApprovalRepository; @@ -590,14 +597,43 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti }); }); + const upsertWorkspaceForThread = Effect.fn("ProjectionPipeline.upsertWorkspaceForThread")( + function* (input: { + readonly projectId: ProjectId; + readonly workspaceId?: string | null | undefined; + readonly branch: string | null; + readonly worktreePath: string | null; + readonly createdAt: string; + readonly updatedAt: string; + }) { + const workspace = deriveThreadWorkspaceRecord(input); + yield* projectionWorkspaceRepository.upsert({ + ...workspace, + createdAt: input.createdAt, + updatedAt: input.updatedAt, + archivedAt: null, + deletedAt: null, + }); + return workspace; + }, + ); + const applyThreadsProjection: ProjectorDefinition["apply"] = Effect.fn( "applyThreadsProjection", )(function* (event, attachmentSideEffects) { switch (event.type) { case "thread.created": + const workspace = yield* upsertWorkspaceForThread({ + projectId: event.payload.projectId, + branch: event.payload.branch, + worktreePath: event.payload.worktreePath, + createdAt: event.payload.createdAt, + updatedAt: event.payload.updatedAt, + }); yield* projectionThreadRepository.upsert({ threadId: event.payload.threadId, projectId: event.payload.projectId, + workspaceId: workspace.workspaceId, title: event.payload.title, modelSelection: event.payload.modelSelection, runtimeMode: event.payload.runtimeMode, @@ -659,9 +695,39 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ...(event.payload.modelSelection !== undefined ? { modelSelection: event.payload.modelSelection } : {}), - ...resolveThreadWorkspaceIdentityPatch(existingRow.value, event.payload), + ...(() => { + const threadWorkspaceUpdate = resolveThreadWorkspaceRecordForPatch({ + projectId: existingRow.value.projectId, + thread: existingRow.value, + patch: event.payload, + }); + return { + ...threadWorkspaceUpdate.patch, + ...(threadWorkspaceUpdate.workspace + ? { workspaceId: threadWorkspaceUpdate.workspace.workspaceId } + : {}), + }; + })(), updatedAt: event.payload.updatedAt, }); + { + const updatedRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isSome(updatedRow)) { + const workspace = yield* upsertWorkspaceForThread({ + projectId: updatedRow.value.projectId, + branch: updatedRow.value.branch, + worktreePath: updatedRow.value.worktreePath, + createdAt: updatedRow.value.createdAt, + updatedAt: event.payload.updatedAt, + }); + yield* projectionThreadRepository.upsert({ + ...updatedRow.value, + workspaceId: workspace.workspaceId, + }); + } + } return; } @@ -1594,6 +1660,7 @@ export const OrchestrationProjectionPipelineLive = Layer.effect( Layer.provideMerge(ProjectionThreadProposedPlanRepositoryLive), Layer.provideMerge(ProjectionThreadActivityRepositoryLive), Layer.provideMerge(ProjectionThreadSessionRepositoryLive), + Layer.provideMerge(ProjectionWorkspaceRepositoryLive), Layer.provideMerge(ProjectionTurnRepositoryLive), Layer.provideMerge(ProjectionPendingApprovalRepositoryLive), Layer.provideMerge(ProjectionStateRepositoryLive), diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index e36db35b1074..a402a773206e 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -253,6 +253,30 @@ function mapProposedPlanRow( }; } +function mapThreadWorkspaceFields(row: Schema.Schema.Type): { + readonly workspaceId?: NonNullable< + Schema.Schema.Type["workspaceId"] + >; + readonly workspaceBranch?: string | null; + readonly workspaceWorktreePath?: string | null; + readonly workspaceLocalCheckout?: boolean; +} { + if (row.workspaceId === null) { + return {}; + } + + return { + workspaceId: row.workspaceId, + ...(row.workspaceBranch !== undefined ? { workspaceBranch: row.workspaceBranch } : {}), + ...(row.workspaceWorktreePath !== undefined + ? { workspaceWorktreePath: row.workspaceWorktreePath } + : {}), + ...(row.workspaceLocalCheckout != null + ? { workspaceLocalCheckout: row.workspaceLocalCheckout > 0 } + : {}), + }; +} + function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { return (cause: unknown): ProjectionRepositoryError => Schema.isSchemaError(cause) @@ -321,25 +345,31 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { execute: () => sql` SELECT - thread_id AS "threadId", - project_id AS "projectId", - title, - model_selection_json AS "modelSelection", - runtime_mode AS "runtimeMode", - interaction_mode AS "interactionMode", - branch, - worktree_path AS "worktreePath", - latest_turn_id AS "latestTurnId", - created_at AS "createdAt", - updated_at AS "updatedAt", - archived_at AS "archivedAt", - latest_user_message_at AS "latestUserMessageAt", - pending_approval_count AS "pendingApprovalCount", - pending_user_input_count AS "pendingUserInputCount", - has_actionable_proposed_plan AS "hasActionableProposedPlan", - deleted_at AS "deletedAt" - FROM projection_threads - ORDER BY created_at ASC, thread_id ASC + threads.thread_id AS "threadId", + threads.project_id AS "projectId", + threads.workspace_id AS "workspaceId", + workspaces.branch AS "workspaceBranch", + workspaces.worktree_path AS "workspaceWorktreePath", + workspaces.local_checkout AS "workspaceLocalCheckout", + threads.title, + threads.model_selection_json AS "modelSelection", + threads.runtime_mode AS "runtimeMode", + threads.interaction_mode AS "interactionMode", + threads.branch, + threads.worktree_path AS "worktreePath", + threads.latest_turn_id AS "latestTurnId", + threads.created_at AS "createdAt", + threads.updated_at AS "updatedAt", + threads.archived_at AS "archivedAt", + threads.latest_user_message_at AS "latestUserMessageAt", + threads.pending_approval_count AS "pendingApprovalCount", + threads.pending_user_input_count AS "pendingUserInputCount", + threads.has_actionable_proposed_plan AS "hasActionableProposedPlan", + threads.deleted_at AS "deletedAt" + FROM projection_threads AS threads + LEFT JOIN projection_workspaces AS workspaces + ON workspaces.workspace_id = threads.workspace_id + ORDER BY threads.created_at ASC, threads.thread_id ASC `, }); @@ -349,27 +379,33 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { execute: () => sql` SELECT - thread_id AS "threadId", - project_id AS "projectId", - title, - model_selection_json AS "modelSelection", - runtime_mode AS "runtimeMode", - interaction_mode AS "interactionMode", - branch, - worktree_path AS "worktreePath", - latest_turn_id AS "latestTurnId", - created_at AS "createdAt", - updated_at AS "updatedAt", - archived_at AS "archivedAt", - latest_user_message_at AS "latestUserMessageAt", - pending_approval_count AS "pendingApprovalCount", - pending_user_input_count AS "pendingUserInputCount", - has_actionable_proposed_plan AS "hasActionableProposedPlan", - deleted_at AS "deletedAt" - FROM projection_threads - WHERE deleted_at IS NULL - AND archived_at IS NULL - ORDER BY project_id ASC, created_at ASC, thread_id ASC + threads.thread_id AS "threadId", + threads.project_id AS "projectId", + threads.workspace_id AS "workspaceId", + workspaces.branch AS "workspaceBranch", + workspaces.worktree_path AS "workspaceWorktreePath", + workspaces.local_checkout AS "workspaceLocalCheckout", + threads.title, + threads.model_selection_json AS "modelSelection", + threads.runtime_mode AS "runtimeMode", + threads.interaction_mode AS "interactionMode", + threads.branch, + threads.worktree_path AS "worktreePath", + threads.latest_turn_id AS "latestTurnId", + threads.created_at AS "createdAt", + threads.updated_at AS "updatedAt", + threads.archived_at AS "archivedAt", + threads.latest_user_message_at AS "latestUserMessageAt", + threads.pending_approval_count AS "pendingApprovalCount", + threads.pending_user_input_count AS "pendingUserInputCount", + threads.has_actionable_proposed_plan AS "hasActionableProposedPlan", + threads.deleted_at AS "deletedAt" + FROM projection_threads AS threads + LEFT JOIN projection_workspaces AS workspaces + ON workspaces.workspace_id = threads.workspace_id + WHERE threads.deleted_at IS NULL + AND threads.archived_at IS NULL + ORDER BY threads.project_id ASC, threads.created_at ASC, threads.thread_id ASC `, }); @@ -379,27 +415,33 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { execute: () => sql` SELECT - thread_id AS "threadId", - project_id AS "projectId", - title, - model_selection_json AS "modelSelection", - runtime_mode AS "runtimeMode", - interaction_mode AS "interactionMode", - branch, - worktree_path AS "worktreePath", - latest_turn_id AS "latestTurnId", - created_at AS "createdAt", - updated_at AS "updatedAt", - archived_at AS "archivedAt", - latest_user_message_at AS "latestUserMessageAt", - pending_approval_count AS "pendingApprovalCount", - pending_user_input_count AS "pendingUserInputCount", - has_actionable_proposed_plan AS "hasActionableProposedPlan", - deleted_at AS "deletedAt" - FROM projection_threads - WHERE deleted_at IS NULL - AND archived_at IS NOT NULL - ORDER BY project_id ASC, archived_at DESC, thread_id DESC + threads.thread_id AS "threadId", + threads.project_id AS "projectId", + threads.workspace_id AS "workspaceId", + workspaces.branch AS "workspaceBranch", + workspaces.worktree_path AS "workspaceWorktreePath", + workspaces.local_checkout AS "workspaceLocalCheckout", + threads.title, + threads.model_selection_json AS "modelSelection", + threads.runtime_mode AS "runtimeMode", + threads.interaction_mode AS "interactionMode", + threads.branch, + threads.worktree_path AS "worktreePath", + threads.latest_turn_id AS "latestTurnId", + threads.created_at AS "createdAt", + threads.updated_at AS "updatedAt", + threads.archived_at AS "archivedAt", + threads.latest_user_message_at AS "latestUserMessageAt", + threads.pending_approval_count AS "pendingApprovalCount", + threads.pending_user_input_count AS "pendingUserInputCount", + threads.has_actionable_proposed_plan AS "hasActionableProposedPlan", + threads.deleted_at AS "deletedAt" + FROM projection_threads AS threads + LEFT JOIN projection_workspaces AS workspaces + ON workspaces.workspace_id = threads.workspace_id + WHERE threads.deleted_at IS NULL + AND threads.archived_at IS NOT NULL + ORDER BY threads.project_id ASC, threads.archived_at DESC, threads.thread_id DESC `, }); @@ -741,27 +783,33 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { execute: ({ threadId }) => sql` SELECT - thread_id AS "threadId", - project_id AS "projectId", - title, - model_selection_json AS "modelSelection", - runtime_mode AS "runtimeMode", - interaction_mode AS "interactionMode", - branch, - worktree_path AS "worktreePath", - latest_turn_id AS "latestTurnId", - created_at AS "createdAt", - updated_at AS "updatedAt", - archived_at AS "archivedAt", - latest_user_message_at AS "latestUserMessageAt", - pending_approval_count AS "pendingApprovalCount", - pending_user_input_count AS "pendingUserInputCount", - has_actionable_proposed_plan AS "hasActionableProposedPlan", - deleted_at AS "deletedAt" - FROM projection_threads - WHERE thread_id = ${threadId} - AND deleted_at IS NULL - AND archived_at IS NULL + threads.thread_id AS "threadId", + threads.project_id AS "projectId", + threads.workspace_id AS "workspaceId", + workspaces.branch AS "workspaceBranch", + workspaces.worktree_path AS "workspaceWorktreePath", + workspaces.local_checkout AS "workspaceLocalCheckout", + threads.title, + threads.model_selection_json AS "modelSelection", + threads.runtime_mode AS "runtimeMode", + threads.interaction_mode AS "interactionMode", + threads.branch, + threads.worktree_path AS "worktreePath", + threads.latest_turn_id AS "latestTurnId", + threads.created_at AS "createdAt", + threads.updated_at AS "updatedAt", + threads.archived_at AS "archivedAt", + threads.latest_user_message_at AS "latestUserMessageAt", + threads.pending_approval_count AS "pendingApprovalCount", + threads.pending_user_input_count AS "pendingUserInputCount", + threads.has_actionable_proposed_plan AS "hasActionableProposedPlan", + threads.deleted_at AS "deletedAt" + FROM projection_threads AS threads + LEFT JOIN projection_workspaces AS workspaces + ON workspaces.workspace_id = threads.workspace_id + WHERE threads.thread_id = ${threadId} + AND threads.deleted_at IS NULL + AND threads.archived_at IS NULL LIMIT 1 `, }); @@ -1175,6 +1223,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { const threads: ReadonlyArray = threadRows.map((row) => ({ id: row.threadId, projectId: row.projectId, + ...mapThreadWorkspaceFields(row), title: row.title, modelSelection: row.modelSelection, runtimeMode: row.runtimeMode, @@ -1373,6 +1422,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { threads.push({ id: row.threadId, projectId: row.projectId, + ...mapThreadWorkspaceFields(row), title: row.title, modelSelection: row.modelSelection, runtimeMode: row.runtimeMode, @@ -1502,6 +1552,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ? Result.succeed({ id: row.threadId, projectId: row.projectId, + ...mapThreadWorkspaceFields(row), title: row.title, modelSelection: row.modelSelection, runtimeMode: row.runtimeMode, @@ -1636,6 +1687,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { (row): OrchestrationThreadShell => ({ id: row.threadId, projectId: row.projectId, + ...mapThreadWorkspaceFields(row), title: row.title, modelSelection: row.modelSelection, runtimeMode: row.runtimeMode, @@ -1876,6 +1928,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { return Option.some({ id: threadRow.value.threadId, projectId: threadRow.value.projectId, + ...mapThreadWorkspaceFields(threadRow.value), title: threadRow.value.title, modelSelection: threadRow.value.modelSelection, runtimeMode: threadRow.value.runtimeMode, @@ -1970,6 +2023,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { const thread = { id: threadRow.value.threadId, projectId: threadRow.value.projectId, + ...mapThreadWorkspaceFields(threadRow.value), title: threadRow.value.title, modelSelection: threadRow.value.modelSelection, runtimeMode: threadRow.value.runtimeMode, diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index 658f8839d3ca..884873c910fe 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -76,6 +76,10 @@ describe("orchestration projector", () => { { id: "thread-1", projectId: "project-1", + workspaceId: "project-1:workspace:local", + workspaceBranch: null, + workspaceWorktreePath: null, + workspaceLocalCheckout: true, title: "demo", modelSelection: { instanceId: "codex", diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 5f782e630a45..b8e3318b7cc8 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -27,7 +27,10 @@ import { ThreadSessionSetPayload, ThreadTurnDiffCompletedPayload, } from "./Schemas.ts"; -import { resolveThreadWorkspaceIdentityPatch } from "./threadWorkspaceIdentity.ts"; +import { + deriveThreadWorkspaceRecord, + resolveThreadWorkspaceRecordForPatch, +} from "./threadWorkspaceIdentity.ts"; type ThreadPatch = Partial>; const MAX_THREAD_MESSAGES = 2_000; @@ -272,11 +275,16 @@ export function projectEvent( event.type, "payload", ); + const workspace = deriveThreadWorkspaceRecord(payload); const thread: OrchestrationThread = yield* decodeForEvent( OrchestrationThread, { id: payload.threadId, projectId: payload.projectId, + workspaceId: workspace.workspaceId, + workspaceBranch: workspace.branch, + workspaceWorktreePath: workspace.worktreePath, + workspaceLocalCheckout: workspace.localCheckout === 1, title: payload.title, modelSelection: payload.modelSelection, runtimeMode: payload.runtimeMode, @@ -352,7 +360,32 @@ export function projectEvent( ...(payload.modelSelection !== undefined ? { modelSelection: payload.modelSelection } : {}), - ...resolveThreadWorkspaceIdentityPatch(thread, payload), + ...(() => { + const threadWorkspaceUpdate = resolveThreadWorkspaceRecordForPatch({ + projectId: thread.projectId, + thread, + patch: payload, + }); + const threadWorkspacePatch = threadWorkspaceUpdate.patch; + if ( + threadWorkspacePatch.branch === undefined && + threadWorkspacePatch.worktreePath === undefined + ) { + return {}; + } + const workspace = threadWorkspaceUpdate.workspace; + return { + ...threadWorkspacePatch, + ...(workspace + ? { + workspaceId: workspace.workspaceId, + workspaceBranch: workspace.branch, + workspaceWorktreePath: workspace.worktreePath, + workspaceLocalCheckout: workspace.localCheckout === 1, + } + : {}), + }; + })(), updatedAt: payload.updatedAt, }; }), diff --git a/apps/server/src/orchestration/threadWorkspaceIdentity.ts b/apps/server/src/orchestration/threadWorkspaceIdentity.ts index c27c819475a8..595fd4297022 100644 --- a/apps/server/src/orchestration/threadWorkspaceIdentity.ts +++ b/apps/server/src/orchestration/threadWorkspaceIdentity.ts @@ -1,4 +1,7 @@ +import { ProjectId, WorkspaceId } from "@t3tools/contracts"; + export interface ThreadWorkspaceIdentity { + readonly workspaceId?: string | null | undefined; readonly branch: string | null; readonly worktreePath: string | null; } @@ -13,6 +16,49 @@ export interface ResolvedThreadWorkspaceIdentityPatch { readonly worktreePath?: string | null; } +export interface ResolvedThreadWorkspaceRecordForPatch { + readonly patch: ResolvedThreadWorkspaceIdentityPatch; + readonly workspace?: ThreadWorkspaceRecord; +} + +export interface ThreadWorkspaceRecord { + readonly workspaceId: WorkspaceId; + readonly projectId: ProjectId; + readonly branch: string | null; + readonly worktreePath: string | null; + readonly localCheckout: 0 | 1; +} + +function normalizeWorkspaceContextValue(value: string | null): string | null { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : null; +} + +export function deriveThreadWorkspaceRecord(input: { + readonly projectId: ProjectId; + readonly workspaceId?: string | null | undefined; + readonly branch: string | null; + readonly worktreePath: string | null; +}): ThreadWorkspaceRecord { + const branch = normalizeWorkspaceContextValue(input.branch); + const worktreePath = normalizeWorkspaceContextValue(input.worktreePath); + const contextKey = worktreePath + ? `worktree:${worktreePath}` + : branch + ? `branch:${branch}` + : "local"; + + return { + workspaceId: WorkspaceId.make( + input.workspaceId ?? `${input.projectId}:workspace:${contextKey}`, + ), + projectId: input.projectId, + branch, + worktreePath, + localCheckout: worktreePath === null ? 1 : 0, + }; +} + export function resolveThreadWorkspaceIdentityPatch( thread: ThreadWorkspaceIdentity, patch: ThreadWorkspaceIdentityPatch, @@ -26,3 +72,37 @@ export function resolveThreadWorkspaceIdentityPatch( ...(patch.worktreePath !== undefined ? { worktreePath: patch.worktreePath } : {}), }; } + +export function resolveThreadWorkspaceRecordForPatch(input: { + readonly projectId: ProjectId; + readonly thread: ThreadWorkspaceIdentity; + readonly patch: ThreadWorkspaceIdentityPatch; +}): ResolvedThreadWorkspaceRecordForPatch { + const resolvedPatch = resolveThreadWorkspaceIdentityPatch(input.thread, input.patch); + if (resolvedPatch.branch === undefined && resolvedPatch.worktreePath === undefined) { + return { patch: resolvedPatch }; + } + + const previousWorktreePath = normalizeWorkspaceContextValue(input.thread.worktreePath); + const nextWorktreePath = normalizeWorkspaceContextValue( + resolvedPatch.worktreePath !== undefined + ? resolvedPatch.worktreePath + : input.thread.worktreePath, + ); + const nextBranch = + resolvedPatch.branch !== undefined ? resolvedPatch.branch : input.thread.branch; + const preserveWorkspaceId = + previousWorktreePath !== null && + nextWorktreePath !== null && + previousWorktreePath === nextWorktreePath; + + return { + patch: resolvedPatch, + workspace: deriveThreadWorkspaceRecord({ + projectId: input.projectId, + ...(preserveWorkspaceId ? { workspaceId: input.thread.workspaceId } : {}), + branch: nextBranch, + worktreePath: nextWorktreePath, + }), + }; +} diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index a2069e62a14c..e057dbc15bb9 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -78,6 +78,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { yield* threads.upsert({ threadId: ThreadId.make("thread-null-options"), projectId: ProjectId.make("project-null-options"), + workspaceId: null, title: "Null options thread", modelSelection: { instanceId: ProviderInstanceId.make("claudeAgent"), diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 1baeb375c152..08f6ebed5e67 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -33,6 +33,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { INSERT INTO projection_threads ( thread_id, project_id, + workspace_id, title, model_selection_json, runtime_mode, @@ -52,6 +53,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { VALUES ( ${row.threadId}, ${row.projectId}, + ${row.workspaceId}, ${row.title}, ${JSON.stringify(row.modelSelection)}, ${row.runtimeMode}, @@ -71,6 +73,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ON CONFLICT (thread_id) DO UPDATE SET project_id = excluded.project_id, + workspace_id = excluded.workspace_id, title = excluded.title, model_selection_json = excluded.model_selection_json, runtime_mode = excluded.runtime_mode, @@ -97,6 +100,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { SELECT thread_id AS "threadId", project_id AS "projectId", + workspace_id AS "workspaceId", title, model_selection_json AS "modelSelection", runtime_mode AS "runtimeMode", @@ -125,6 +129,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { SELECT thread_id AS "threadId", project_id AS "projectId", + workspace_id AS "workspaceId", title, model_selection_json AS "modelSelection", runtime_mode AS "runtimeMode", diff --git a/apps/server/src/persistence/Layers/ProjectionWorkspaces.ts b/apps/server/src/persistence/Layers/ProjectionWorkspaces.ts new file mode 100644 index 000000000000..f1877b3f38b3 --- /dev/null +++ b/apps/server/src/persistence/Layers/ProjectionWorkspaces.ts @@ -0,0 +1,95 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; + +import { toPersistenceSqlError } from "../Errors.ts"; +import { + GetProjectionWorkspaceInput, + ProjectionWorkspace, + ProjectionWorkspaceRepository, + type ProjectionWorkspaceRepositoryShape, +} from "../Services/ProjectionWorkspaces.ts"; + +const makeProjectionWorkspaceRepository = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsertProjectionWorkspaceRow = SqlSchema.void({ + Request: ProjectionWorkspace, + execute: (row) => + sql` + INSERT INTO projection_workspaces ( + workspace_id, + project_id, + branch, + worktree_path, + local_checkout, + created_at, + updated_at, + archived_at, + deleted_at + ) + VALUES ( + ${row.workspaceId}, + ${row.projectId}, + ${row.branch}, + ${row.worktreePath}, + ${row.localCheckout}, + ${row.createdAt}, + ${row.updatedAt}, + ${row.archivedAt}, + ${row.deletedAt} + ) + ON CONFLICT (workspace_id) + DO UPDATE SET + project_id = excluded.project_id, + branch = excluded.branch, + worktree_path = excluded.worktree_path, + local_checkout = excluded.local_checkout, + created_at = MIN(projection_workspaces.created_at, excluded.created_at), + updated_at = excluded.updated_at, + archived_at = excluded.archived_at, + deleted_at = excluded.deleted_at + `, + }); + + const getProjectionWorkspaceRow = SqlSchema.findOneOption({ + Request: GetProjectionWorkspaceInput, + Result: ProjectionWorkspace, + execute: ({ workspaceId }) => + sql` + SELECT + workspace_id AS "workspaceId", + project_id AS "projectId", + branch, + worktree_path AS "worktreePath", + local_checkout AS "localCheckout", + created_at AS "createdAt", + updated_at AS "updatedAt", + archived_at AS "archivedAt", + deleted_at AS "deletedAt" + FROM projection_workspaces + WHERE workspace_id = ${workspaceId} + `, + }); + + const upsert: ProjectionWorkspaceRepositoryShape["upsert"] = (row) => + upsertProjectionWorkspaceRow(row).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionWorkspaceRepository.upsert:query")), + ); + + const getById: ProjectionWorkspaceRepositoryShape["getById"] = (input) => + getProjectionWorkspaceRow(input).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionWorkspaceRepository.getById:query")), + ); + + return { + upsert, + getById, + } satisfies ProjectionWorkspaceRepositoryShape; +}); + +export const ProjectionWorkspaceRepositoryLive = Layer.effect( + ProjectionWorkspaceRepository, + makeProjectionWorkspaceRepository, +); diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index ba1131ee2597..7ae862e8f1f6 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -45,6 +45,8 @@ import Migration0029 from "./Migrations/029_ProjectionThreadDetailOrderingIndexe import Migration0030 from "./Migrations/030_ProjectionThreadShellArchiveIndexes.ts"; import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts"; import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts"; +import Migration0033 from "./Migrations/033_ProjectionWorkspaces.ts"; +import Migration0034 from "./Migrations/034_RebuildProjectionWorkspaces.ts"; /** * Migration loader with all migrations defined inline. @@ -89,6 +91,8 @@ export const migrationEntries = [ [30, "ProjectionThreadShellArchiveIndexes", Migration0030], [31, "AuthAuthorizationScopes", Migration0031], [32, "AuthPairingProofKeyThumbprint", Migration0032], + [33, "ProjectionWorkspaces", Migration0033], + [34, "RebuildProjectionWorkspaces", Migration0034], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/033_ProjectionWorkspaces.ts b/apps/server/src/persistence/Migrations/033_ProjectionWorkspaces.ts new file mode 100644 index 000000000000..d36908446b76 --- /dev/null +++ b/apps/server/src/persistence/Migrations/033_ProjectionWorkspaces.ts @@ -0,0 +1,110 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE IF NOT EXISTS projection_workspaces ( + workspace_id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + branch TEXT, + worktree_path TEXT, + local_checkout INTEGER NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + archived_at TEXT, + deleted_at TEXT + ) + `; + + const threadColumns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + if (!threadColumns.some((column) => column.name === "workspace_id")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN workspace_id TEXT + `; + } + + yield* sql` + INSERT INTO projection_workspaces ( + workspace_id, + project_id, + branch, + worktree_path, + local_checkout, + created_at, + updated_at, + archived_at, + deleted_at + ) + SELECT + workspace_id, + project_id, + branch, + worktree_path, + local_checkout, + MIN(created_at) AS created_at, + MAX(updated_at) AS updated_at, + NULL AS archived_at, + NULL AS deleted_at + FROM ( + SELECT + project_id, + project_id || ':workspace:' || + CASE + WHEN NULLIF(TRIM(worktree_path), '') IS NOT NULL THEN 'worktree:' || TRIM(worktree_path) + WHEN NULLIF(TRIM(branch), '') IS NOT NULL THEN 'branch:' || TRIM(branch) + ELSE 'local' + END AS workspace_id, + CASE + WHEN NULLIF(TRIM(branch), '') IS NOT NULL THEN TRIM(branch) + ELSE NULL + END AS branch, + CASE + WHEN NULLIF(TRIM(worktree_path), '') IS NOT NULL THEN TRIM(worktree_path) + ELSE NULL + END AS worktree_path, + CASE + WHEN NULLIF(TRIM(worktree_path), '') IS NULL THEN 1 + ELSE 0 + END AS local_checkout, + created_at, + updated_at + FROM projection_threads + WHERE deleted_at IS NULL + ) + GROUP BY workspace_id + ON CONFLICT (workspace_id) + DO UPDATE SET + project_id = excluded.project_id, + branch = excluded.branch, + worktree_path = excluded.worktree_path, + local_checkout = excluded.local_checkout, + created_at = MIN(projection_workspaces.created_at, excluded.created_at), + updated_at = MAX(projection_workspaces.updated_at, excluded.updated_at) + `; + + yield* sql` + UPDATE projection_threads + SET workspace_id = project_id || ':workspace:' || + CASE + WHEN NULLIF(TRIM(worktree_path), '') IS NOT NULL THEN 'worktree:' || TRIM(worktree_path) + WHEN NULLIF(TRIM(branch), '') IS NOT NULL THEN 'branch:' || TRIM(branch) + ELSE 'local' + END + WHERE workspace_id IS NULL + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_workspaces_project + ON projection_workspaces(project_id, deleted_at, created_at) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_threads_workspace + ON projection_threads(workspace_id) + `; +}); diff --git a/apps/server/src/persistence/Migrations/034_RebuildProjectionWorkspaces.ts b/apps/server/src/persistence/Migrations/034_RebuildProjectionWorkspaces.ts new file mode 100644 index 000000000000..e16114c22276 --- /dev/null +++ b/apps/server/src/persistence/Migrations/034_RebuildProjectionWorkspaces.ts @@ -0,0 +1,65 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + UPDATE projection_threads + SET workspace_id = project_id || ':workspace:' || + CASE + WHEN NULLIF(TRIM(worktree_path), '') IS NOT NULL THEN 'worktree:' || TRIM(worktree_path) + WHEN NULLIF(TRIM(branch), '') IS NOT NULL THEN 'branch:' || TRIM(branch) + ELSE 'local' + END + `; + + yield* sql`DELETE FROM projection_workspaces`; + + yield* sql` + INSERT INTO projection_workspaces ( + workspace_id, + project_id, + branch, + worktree_path, + local_checkout, + created_at, + updated_at, + archived_at, + deleted_at + ) + SELECT + workspace_id, + project_id, + branch, + worktree_path, + local_checkout, + MIN(created_at) AS created_at, + MAX(updated_at) AS updated_at, + NULL AS archived_at, + NULL AS deleted_at + FROM ( + SELECT + workspace_id, + project_id, + CASE + WHEN NULLIF(TRIM(branch), '') IS NOT NULL THEN TRIM(branch) + ELSE NULL + END AS branch, + CASE + WHEN NULLIF(TRIM(worktree_path), '') IS NOT NULL THEN TRIM(worktree_path) + ELSE NULL + END AS worktree_path, + CASE + WHEN NULLIF(TRIM(worktree_path), '') IS NULL THEN 1 + ELSE 0 + END AS local_checkout, + created_at, + updated_at + FROM projection_threads + WHERE deleted_at IS NULL + AND workspace_id IS NOT NULL + ) + GROUP BY workspace_id + `; +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index 44fdc147a4a2..e0702745a693 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -15,6 +15,7 @@ import { RuntimeMode, ThreadId, TurnId, + WorkspaceId, } from "@t3tools/contracts"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; @@ -26,6 +27,10 @@ import type { ProjectionRepositoryError } from "../Errors.ts"; export const ProjectionThread = Schema.Struct({ threadId: ThreadId, projectId: ProjectId, + workspaceId: Schema.NullOr(WorkspaceId), + workspaceBranch: Schema.optional(Schema.NullOr(Schema.String)), + workspaceWorktreePath: Schema.optional(Schema.NullOr(Schema.String)), + workspaceLocalCheckout: Schema.optional(Schema.NullOr(NonNegativeInt)), title: Schema.String, modelSelection: ModelSelection, runtimeMode: RuntimeMode, diff --git a/apps/server/src/persistence/Services/ProjectionWorkspaces.ts b/apps/server/src/persistence/Services/ProjectionWorkspaces.ts new file mode 100644 index 000000000000..b6aa072dd111 --- /dev/null +++ b/apps/server/src/persistence/Services/ProjectionWorkspaces.ts @@ -0,0 +1,40 @@ +import { IsoDateTime, ProjectId, WorkspaceId, NonNegativeInt } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import type { ProjectionRepositoryError } from "../Errors.ts"; + +export const ProjectionWorkspace = Schema.Struct({ + workspaceId: WorkspaceId, + projectId: ProjectId, + branch: Schema.NullOr(Schema.String), + worktreePath: Schema.NullOr(Schema.String), + localCheckout: NonNegativeInt, + createdAt: IsoDateTime, + updatedAt: IsoDateTime, + archivedAt: Schema.NullOr(IsoDateTime), + deletedAt: Schema.NullOr(IsoDateTime), +}); +export type ProjectionWorkspace = typeof ProjectionWorkspace.Type; + +export const GetProjectionWorkspaceInput = Schema.Struct({ + workspaceId: WorkspaceId, +}); +export type GetProjectionWorkspaceInput = typeof GetProjectionWorkspaceInput.Type; + +export interface ProjectionWorkspaceRepositoryShape { + readonly upsert: ( + workspace: ProjectionWorkspace, + ) => Effect.Effect; + + readonly getById: ( + input: GetProjectionWorkspaceInput, + ) => Effect.Effect, ProjectionRepositoryError>; +} + +export class ProjectionWorkspaceRepository extends Context.Service< + ProjectionWorkspaceRepository, + ProjectionWorkspaceRepositoryShape +>()("t3/persistence/Services/ProjectionWorkspaces/ProjectionWorkspaceRepository") {} diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index e1daf20ed570..319c9524b4ba 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -3233,6 +3233,26 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("allows credentialed auth requests from the desktop dev renderer origin", () => + Effect.gen(function* () { + const desktopDevOrigin = "t3code-dev://app"; + yield* buildAppUnderTest({ + config: { devUrl: new URL("http://127.0.0.1:5173") }, + }); + + const sessionUrl = yield* getHttpServerUrl("/api/auth/session"); + const response = yield* fetchEffect(sessionUrl, { + headers: { origin: desktopDevOrigin }, + }); + + assert.equal(response.status, 200); + assertBrowserApiCorsResponseHeaders(response.headers, { + origin: desktopDevOrigin, + credentials: true, + }); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("includes CORS headers on remote websocket-ticket auth failures", () => Effect.gen(function* () { yield* buildAppUnderTest(); diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 0bc5ec200749..75b05c5838c0 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -12,6 +12,7 @@ import { hasServerAcknowledgedLocalDispatch, reconcileMountedTerminalThreadIds, reconcileRetainedMountedThreadIds, + resolveBranchForNewThreadMetadata, resolveWorkspaceScopedThreadRef, resolveSendEnvMode, shouldWriteThreadErrorToCurrentServerThread, @@ -143,6 +144,38 @@ describe("deriveComposerSendState", () => { }); }); +describe("resolveBranchForNewThreadMetadata", () => { + it("uses the current git branch for existing worktree chats", () => { + expect( + resolveBranchForNewThreadMetadata({ + activeThreadBranch: "feat/enhancements", + activeWorktreePath: "/repo/.t3/worktrees/enhancements", + currentGitBranch: "t3code/handle-greeting", + }), + ).toBe("t3code/handle-greeting"); + }); + + it("falls back to stored thread branch when git status has no branch", () => { + expect( + resolveBranchForNewThreadMetadata({ + activeThreadBranch: "feat/enhancements", + activeWorktreePath: "/repo/.t3/worktrees/enhancements", + currentGitBranch: null, + }), + ).toBe("feat/enhancements"); + }); + + it("keeps selected base branch for new worktree creation", () => { + expect( + resolveBranchForNewThreadMetadata({ + activeThreadBranch: "main", + activeWorktreePath: null, + currentGitBranch: "feature/current", + }), + ).toBe("main"); + }); +}); + describe("buildExpiredTerminalContextToastCopy", () => { it("formats empty and omission guidance", () => { expect(buildExpiredTerminalContextToastCopy(1, "empty")).toEqual({ diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 1d5a01103bed..2d251f06613d 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -55,6 +55,17 @@ export function buildLocalDraftThread( }; } +export function resolveBranchForNewThreadMetadata(input: { + activeThreadBranch: string | null; + activeWorktreePath: string | null; + currentGitBranch: string | null; +}): string | null { + if (input.activeWorktreePath && input.currentGitBranch) { + return input.currentGitBranch; + } + return input.activeThreadBranch; +} + export function shouldWriteThreadErrorToCurrentServerThread(input: { serverThread: | { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 362ef602459d..d40e51318561 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -218,6 +218,7 @@ import { deriveLockedProvider, readFileAsDataUrl, reconcileMountedTerminalThreadIds, + resolveBranchForNewThreadMetadata, resolveWorkspaceScopedThreadRef, resolveSendEnvMode, revokeBlobPreviewUrl, @@ -3261,6 +3262,12 @@ function ChatViewContent(props: ChatViewProps) { canOverrideServerThreadEnvMode && pendingServerThreadBranch !== undefined ? pendingServerThreadBranch : (activeThread?.branch ?? null); + const currentGitBranch = gitStatusQuery.data?.refName ?? null; + const branchForNewThreadMetadata = resolveBranchForNewThreadMetadata({ + activeThreadBranch, + activeWorktreePath, + currentGitBranch, + }); const startFromOrigin = isLocalDraftThread ? (draftThread?.startFromOrigin ?? false) : canOverrideServerThreadEnvMode @@ -3647,14 +3654,14 @@ function ChatViewContent(props: ChatViewProps) { const isFirstMessage = !isServerThread || activeThread.messages.length === 0; const baseBranchForWorktree = isFirstMessage && sendEnvMode === "worktree" && !activeThread.worktreePath - ? activeThreadBranch + ? branchForNewThreadMetadata : null; // In worktree mode, require an explicit base branch so we don't silently // fall back to local execution when branch selection is missing. const shouldCreateWorktree = isFirstMessage && sendEnvMode === "worktree" && !activeThread.worktreePath; - if (shouldCreateWorktree && !activeThreadBranch) { + if (shouldCreateWorktree && !branchForNewThreadMetadata) { setThreadError(threadIdForSend, "Select a base branch before sending in New worktree mode."); return; } @@ -3817,7 +3824,7 @@ function ChatViewContent(props: ChatViewProps) { modelSelection: threadCreateModelSelection, runtimeMode, interactionMode, - branch: activeThreadBranch, + branch: branchForNewThreadMetadata, worktreePath: activeThread.worktreePath, createdAt: activeThread.createdAt, }, @@ -4308,7 +4315,7 @@ function ChatViewContent(props: ChatViewProps) { modelSelection: nextThreadModelSelection, runtimeMode, interactionMode: "default", - branch: activeThreadBranch, + branch: branchForNewThreadMetadata, worktreePath: activeThread.worktreePath, createdAt, }, @@ -4394,8 +4401,8 @@ function ChatViewContent(props: ChatViewProps) { }, [ activeProject, activeProposedPlan, - activeThreadBranch, activeThread, + branchForNewThreadMetadata, beginLocalDispatch, activeEnvironmentUnavailable, createThread, diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index b4ccd0b360dd..b445ede76cde 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -30,6 +30,7 @@ import { ProjectId, ProviderInstanceId, ThreadId, + WorkspaceId, } from "@t3tools/contracts"; import { DEFAULT_INTERACTION_MODE, @@ -915,6 +916,44 @@ describe("buildDefaultWorkspacesForThreads", () => { expect(afterRename[0]?.title).toBe("feature/new-name"); }); + it("uses persisted workspace identity and metadata when available", () => { + const beforeRename = buildDefaultWorkspacesForThreads({ + projectKey: "project:local:project-1", + threads: [ + makeThread({ + id: ThreadId.make("thread-worktree"), + title: "Run checks", + branch: "feature/old-name", + worktreePath: "/repo/.t3/worktrees/checks", + workspaceId: WorkspaceId.make("workspace-checks"), + workspaceBranch: "feature/old-name", + workspaceWorktreePath: "/repo/.t3/worktrees/checks", + }), + ], + getThreadKey: (thread) => `thread:${thread.id}`, + }); + const afterRename = buildDefaultWorkspacesForThreads({ + projectKey: "project:local:project-1", + threads: [ + makeThread({ + id: ThreadId.make("thread-worktree"), + title: "Run checks", + branch: "feature/new-name", + worktreePath: "/repo/.t3/worktrees/checks", + workspaceId: WorkspaceId.make("workspace-checks"), + workspaceBranch: "feature/new-name", + workspaceWorktreePath: "/repo/.t3/worktrees/checks", + }), + ], + getThreadKey: (thread) => `thread:${thread.id}`, + }); + + expect(beforeRename[0]?.id).toBe("project:local:project-1:workspace:workspace-checks"); + expect(afterRename[0]?.id).toBe(beforeRename[0]?.id); + expect(afterRename[0]?.title).toBe("feature/new-name"); + expect(afterRename[0]?.branch).toBe("feature/new-name"); + }); + it("uses a stable local workspace label for legacy threads without branch or worktree context", () => { const workspaces = buildDefaultWorkspacesForThreads({ projectKey: "project:local:project-1", diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index df72018a8a4e..0eb54b833e0a 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -535,6 +535,9 @@ export function buildDefaultWorkspacesForThreads< TThread extends { id: Thread["id"]; title: string; + workspaceId?: string | null | undefined; + workspaceBranch?: string | null | undefined; + workspaceWorktreePath?: string | null | undefined; branch: string | null; worktreePath: string | null; createdAt: string; @@ -549,14 +552,24 @@ export function buildDefaultWorkspacesForThreads< for (const thread of input.threads) { const threadKey = input.getThreadKey(thread); + const persistedWorkspaceId = normalizeWorkspaceContextValue(thread.workspaceId ?? null); + const workspaceBranch = thread.workspaceBranch ?? thread.branch; + const workspaceWorktreePath = thread.workspaceWorktreePath ?? thread.worktreePath; const contextKey = defaultWorkspaceContextKey({ - branch: thread.branch, - worktreePath: thread.worktreePath, + branch: workspaceBranch, + worktreePath: workspaceWorktreePath, }); - const existing = workspaceByContextKey.get(contextKey); + const workspaceKey = persistedWorkspaceId ?? contextKey; + const existing = workspaceByContextKey.get(workspaceKey); if (existing) { - workspaceByContextKey.set(contextKey, { + workspaceByContextKey.set(workspaceKey, { ...existing, + title: resolveDefaultWorkspaceTitle({ + branch: workspaceBranch, + worktreePath: workspaceWorktreePath, + }), + branch: workspaceBranch, + worktreePath: workspaceWorktreePath, threads: [...existing.threads, thread], createdAt: minIsoTimestamp(existing.createdAt, thread.createdAt), updatedAt: maxIsoTimestamp(existing.updatedAt, thread.updatedAt), @@ -564,15 +577,15 @@ export function buildDefaultWorkspacesForThreads< continue; } - workspaceByContextKey.set(contextKey, { - id: `${input.projectKey}:workspace:${contextKey}`, + workspaceByContextKey.set(workspaceKey, { + id: `${input.projectKey}:workspace:${workspaceKey}`, projectKey: input.projectKey, title: resolveDefaultWorkspaceTitle({ - branch: thread.branch, - worktreePath: thread.worktreePath, + branch: workspaceBranch, + worktreePath: workspaceWorktreePath, }), - branch: thread.branch, - worktreePath: thread.worktreePath, + branch: workspaceBranch, + worktreePath: workspaceWorktreePath, lastActiveThreadKey: threadKey, threads: [thread], createdAt: thread.createdAt, diff --git a/docs/project/harness-enhancements.md b/docs/project/harness-enhancements.md index e123a66f62cc..f45c68b0718e 100644 --- a/docs/project/harness-enhancements.md +++ b/docs/project/harness-enhancements.md @@ -1,6 +1,6 @@ # Harness Enhancements Tracker -> Last updated: 2026-06-21 +> Last updated: 2026-06-22 This tracks planned improvements to make T3 Code a stronger harness around coding agents, inspired by the useful parts of Conductor's workspace model: persistent context, injected guidance, action-specific prompts, isolated workspaces, review flow, and merge readiness. @@ -23,7 +23,7 @@ Relevant upstream changes to build on: | -------- | ------------------------------------------------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------- | | P0 | Workspace identity and sidebar hierarchy | Compat complete | Creates the Project -> Workspace -> Chat model that every other harness feature can attach to. | | P0 | Workspace migration and compatibility layer | Compat complete | Lets existing projects, threads, routes, and APIs keep working while workspace ownership rolls out. | -| P0 | Durable workspace persistence model | Not started | Gives workspaces stable IDs and stored ownership so branch/worktree state no longer depends on thread metadata. | +| P0 | Durable workspace persistence model | Complete | Gives workspaces stable IDs and stored ownership so branch/worktree state no longer depends on thread metadata. | | P0 | Dev/prod data isolation and feature flag rollout | Complete | Lets the new workspace layout run in dev without risking the user's deployed/current T3 Code data. | | P0 | Workspace context folder | Not started | Gives each workspace durable memory across turns, restarts, and provider handoffs. | | P0 | Durable task list | Not started | Gives every workspace a trustworthy task state instead of relying on the agent to update a checklist. | @@ -95,10 +95,10 @@ Completed compatibility scope: - Workspace rows expand/collapse independently from the selected center chat. - Terminal drawer state and right-panel visibility follow the active workspace-scoped thread reference, while chat-specific diff and plan data remains attached to the selected chat. - Settings and keybinding documentation describe the branch/worktree behavior for new chat creation. +- Projection-backed workspace IDs and metadata are now consumed by the sidebar when present, with legacy thread metadata retained as a fallback. Still pending: -- Add a durable workspace model instead of synthesizing workspace groups from thread metadata. - Move branch/worktree ownership fully from thread fields to workspace fields after compatibility is proven. - Add workspace-level lifecycle surfaces for changed files, checks, review state, and PR state. @@ -154,14 +154,14 @@ Rollback and safety: Completed compatibility scope: -- There is not yet a durable workspace table. The UI synthesizes compatible workspace groups from existing thread project, branch, worktree, and local-checkout metadata. +- Legacy snapshots still synthesize compatible workspace groups from thread project, branch, worktree, and local-checkout metadata when durable workspace fields are absent. - Projection and client reducer paths preserve an existing worktree identity when stale restored local metadata arrives without a worktree path. - Project-scoped new chat creation clears active branch/worktree context when the selected project differs from the current chat, so the draft appears under the selected project instead of the previously active workspace. +- Durable projection workspace records now backfill and repair thread workspace linkage while old thread routes and thread-owned branch/worktree fields remain readable. Still pending: -- Add persistent workspace IDs and workspace-aware routes/API commands. -- Backfill durable workspace records once the schema exists. +- Add workspace-aware routes/API commands. - Keep old thread routes as aliases during the durable migration. ## P0: Durable Workspace Persistence Model @@ -199,6 +199,16 @@ Initial implementation notes: project-scoped new chat creation. - Only after this model is stable should branch/worktree ownership move fully off thread records. +Completed scope: + +- Added `WorkspaceId`, persisted projection workspace records, and thread workspace linkage. +- Backfilled existing projection threads into workspaces using worktree path first, then branch, then local checkout. +- Updated snapshot queries and sidebar grouping to prefer persisted workspace metadata while preserving legacy fallback behavior. +- Preserved same-worktree workspace identity across branch label changes. +- Fixed branch/local-to-worktree transitions so only the moved chat changes workspace instead of relabeling sibling branch chats. +- Added a corrective projection migration that rebuilds workspace rows from each thread's actual branch/worktree fields. +- Covered workspace persistence, sidebar grouping, branch rename, and branch-to-worktree movement with focused tests. + ## P0: Dev/Prod Data Isolation and Feature Flag Rollout Keep the deployed/current T3 Code experience on the existing layout and data store while the dev build can run the new workspace layout safely. diff --git a/packages/contracts/src/baseSchemas.ts b/packages/contracts/src/baseSchemas.ts index 614ea5131fbc..be9a7d3748ee 100644 --- a/packages/contracts/src/baseSchemas.ts +++ b/packages/contracts/src/baseSchemas.ts @@ -31,6 +31,8 @@ export const ThreadId = makeEntityId("ThreadId"); export type ThreadId = typeof ThreadId.Type; export const ProjectId = makeEntityId("ProjectId"); export type ProjectId = typeof ProjectId.Type; +export const WorkspaceId = makeEntityId("WorkspaceId"); +export type WorkspaceId = typeof WorkspaceId.Type; export const EnvironmentId = makeEntityId("EnvironmentId"); export type EnvironmentId = typeof EnvironmentId.Type; export const CommandId = makeEntityId("CommandId"); diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 623fed0917bf..56b20b754eab 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -19,6 +19,7 @@ import { ThreadId, TrimmedNonEmptyString, TurnId, + WorkspaceId, } from "./baseSchemas.ts"; import { ProviderInstanceId } from "./providerInstance.ts"; @@ -344,6 +345,10 @@ export type OrchestrationLatestTurn = typeof OrchestrationLatestTurn.Type; export const OrchestrationThread = Schema.Struct({ id: ThreadId, projectId: ProjectId, + workspaceId: Schema.optional(Schema.NullOr(WorkspaceId)), + workspaceBranch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + workspaceWorktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + workspaceLocalCheckout: Schema.optional(Schema.Boolean), title: TrimmedNonEmptyString, modelSelection: ModelSelection, runtimeMode: RuntimeMode, @@ -367,9 +372,23 @@ export const OrchestrationThread = Schema.Struct({ }); export type OrchestrationThread = typeof OrchestrationThread.Type; +export const OrchestrationWorkspace = Schema.Struct({ + id: WorkspaceId, + projectId: ProjectId, + branch: Schema.NullOr(TrimmedNonEmptyString), + worktreePath: Schema.NullOr(TrimmedNonEmptyString), + localCheckout: Schema.Boolean, + createdAt: IsoDateTime, + updatedAt: IsoDateTime, + archivedAt: Schema.NullOr(IsoDateTime), + deletedAt: Schema.NullOr(IsoDateTime), +}); +export type OrchestrationWorkspace = typeof OrchestrationWorkspace.Type; + export const OrchestrationReadModel = Schema.Struct({ snapshotSequence: NonNegativeInt, projects: Schema.Array(OrchestrationProject), + workspaces: Schema.optional(Schema.Array(OrchestrationWorkspace)), threads: Schema.Array(OrchestrationThread), updatedAt: IsoDateTime, }); @@ -390,6 +409,10 @@ export type OrchestrationProjectShell = typeof OrchestrationProjectShell.Type; export const OrchestrationThreadShell = Schema.Struct({ id: ThreadId, projectId: ProjectId, + workspaceId: Schema.optional(Schema.NullOr(WorkspaceId)), + workspaceBranch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + workspaceWorktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + workspaceLocalCheckout: Schema.optional(Schema.Boolean), title: TrimmedNonEmptyString, modelSelection: ModelSelection, runtimeMode: RuntimeMode, @@ -413,6 +436,7 @@ export type OrchestrationThreadShell = typeof OrchestrationThreadShell.Type; export const OrchestrationShellSnapshot = Schema.Struct({ snapshotSequence: NonNegativeInt, projects: Schema.Array(OrchestrationProjectShell), + workspaces: Schema.optional(Schema.Array(OrchestrationWorkspace)), threads: Schema.Array(OrchestrationThreadShell), updatedAt: IsoDateTime, }); From 9731579776101ae1b532d028f4d247880ba3f2ad Mon Sep 17 00:00:00 2001 From: h4rzx <0tox@iterelle.tech> Date: Mon, 22 Jun 2026 18:39:36 -0500 Subject: [PATCH 2/2] docs(harness): align workspace row behavior --- docs/project/harness-enhancements.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/project/harness-enhancements.md b/docs/project/harness-enhancements.md index f45c68b0718e..b179a8319c0e 100644 --- a/docs/project/harness-enhancements.md +++ b/docs/project/harness-enhancements.md @@ -74,7 +74,7 @@ Expected behavior: - Workspaces appear under projects. - Chats appear under workspaces only when the workspace is expanded. - Clicking a chat opens that one chat in the center, matching current T3 behavior. -- The workspace row has separate controls: caret toggles expand/collapse, title opens the last active chat, and `+` creates a new chat in that workspace. +- The workspace row has separate controls: caret/title toggle expand/collapse, chat rows open individual chats, and `+` creates a new chat in that workspace. - Collapsed workspaces show only workspace-level status such as name, branch/status, and changed-file count. - Expanded workspaces show their chats, with the active chat highlighted. - The center layout does not change in the first implementation; no Conductor-style center chat tabs.