diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 020d5e46f1..78825be2bf 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -78,9 +78,15 @@ jobs: with: bun-version: "1.3.5" + - name: Install ripgrep + run: sudo apt-get update && sudo apt-get install -y ripgrep + - name: Install dependencies run: bun install + - name: Build daemon bundle + run: bun run --cwd=packages/sandbox build + - name: Start NATS with JetStream run: | docker run -d --name nats -p 4222:4222 nats:2.10 -js @@ -132,3 +138,51 @@ jobs: - name: Run knip run: bun run knip + + docker-smoke: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.5" + + - name: Install dependencies + run: bun install + + - name: Build daemon bundle + run: bun run --cwd=packages/sandbox build + + - name: Build sandbox image + run: | + docker build \ + -t mesh-sandbox:ci \ + -f packages/sandbox/image/Dockerfile \ + packages/sandbox + + - name: Smoke test + run: | + docker run -d --name sandbox-smoke -p 19999:9000 \ + -e DAEMON_TOKEN="$(printf 't%.0s' {1..32})" \ + -e DAEMON_BOOT_ID="ci-smoke" \ + -e APP_ROOT=/app \ + -e PROXY_PORT=9000 \ + -e DAEMON_NO_AUTOSTART=1 \ + mesh-sandbox:ci + for i in $(seq 1 30); do + if curl -fsS http://localhost:19999/health | grep -q '"bootId":"ci-smoke"'; then + echo "ok" + exit 0 + fi + sleep 1 + done + echo "smoke test failed — daemon did not return /health with ci-smoke bootId" + docker logs sandbox-smoke + exit 1 + + - name: Tear down + if: always() + run: docker rm -f sandbox-smoke || true diff --git a/apps/mesh/package.json b/apps/mesh/package.json index f0a81c85c9..478cd74e2e 100644 --- a/apps/mesh/package.json +++ b/apps/mesh/package.json @@ -145,7 +145,7 @@ "kysely-pglite": "^0.6.1", "lucide-react": "^0.468.0", "marked": "^15.0.6", - "mesh-plugin-user-sandbox": "workspace:*", + "@decocms/sandbox": "workspace:*", "mesh-plugin-workflows": "workspace:*", "nanoid": "^5.1.6", "pg": "^8.16.3", diff --git a/apps/mesh/spec/monitoring-share-plugin.md b/apps/mesh/spec/monitoring-share-plugin.md index 786e1e6afa..a99159f98d 100644 --- a/apps/mesh/spec/monitoring-share-plugin.md +++ b/apps/mesh/spec/monitoring-share-plugin.md @@ -269,7 +269,7 @@ setup: (ctx) => { |------|--------| | `packages/bindings/src/core/plugins.ts` | Add `rootRoute` and `registerPublicRoutes` to context | | `apps/mesh/src/web/index.tsx` | Pass new context props, collect and mount public routes | -| `packages/mesh-plugin-user-sandbox/client/index.ts` | Migrate connect route registration | +| `packages/@decocms/sandbox/client/index.ts` | Migrate connect route registration | | `apps/mesh/src/web/routes/connect.tsx` | Remove (or keep as fallback) | ### Phase 2 & 3 (Plugin) diff --git a/apps/mesh/src/api/app.ts b/apps/mesh/src/api/app.ts index 230befbe22..cc1fd882bf 100644 --- a/apps/mesh/src/api/app.ts +++ b/apps/mesh/src/api/app.ts @@ -45,7 +45,6 @@ import oauthProxyRoutes, { } from "./routes/oauth-proxy"; import openaiCompatRoutes from "./routes/openai-compat"; import proxyRoutes from "./routes/proxy"; -import { createSandboxDaemonRoutes } from "./routes/sandbox-daemon"; import { createKVRoutes } from "./routes/kv"; import { createTriggerCallbackRoutes } from "./routes/trigger-callback"; import publicConfigRoutes from "./routes/public-config"; @@ -1361,10 +1360,6 @@ export async function createApp(options: CreateAppOptions = {}) { }); app.route("/api", decopilotRoutes); - // Daemon control-plane passthrough only — dev-server traffic bypasses - // mesh and hits pods' public URLs directly. - app.route("/", createSandboxDaemonRoutes()); - // Stable file redirect endpoint (resolves mesh-storage: URIs to presigned URLs) app.route("/api", filesRoutes); @@ -1591,7 +1586,7 @@ export async function createApp(options: CreateAppOptions = {}) { const dockerRunner = asDockerRunner(getSharedRunnerIfInit()); if (dockerRunner) { const { sweepDockerOrphansOnShutdown } = await import( - "mesh-plugin-user-sandbox/runner" + "@decocms/sandbox/runner" ); await sweepDockerOrphansOnShutdown(dockerRunner); } diff --git a/apps/mesh/src/api/routes/decopilot/built-in-tools/index.ts b/apps/mesh/src/api/routes/decopilot/built-in-tools/index.ts index 460e50edb9..93b5f53124 100644 --- a/apps/mesh/src/api/routes/decopilot/built-in-tools/index.ts +++ b/apps/mesh/src/api/routes/decopilot/built-in-tools/index.ts @@ -36,7 +36,7 @@ import { createReadResourceTool } from "./resources"; import { createSandboxTool, type VirtualClient } from "./sandbox"; import { createVmTools } from "./vm-tools"; import { getRunnerByKind } from "@/sandbox/lifecycle"; -import type { RunnerKind } from "mesh-plugin-user-sandbox/runner"; +import type { RunnerKind } from "@decocms/sandbox/runner"; import { createSubtaskTool } from "./subtask"; import { userAskTool } from "./user-ask"; import { proposePlanTool } from "./propose-plan"; diff --git a/apps/mesh/src/api/routes/decopilot/built-in-tools/vm-tools/index.ts b/apps/mesh/src/api/routes/decopilot/built-in-tools/vm-tools/index.ts index 6e50379356..dc38497264 100644 --- a/apps/mesh/src/api/routes/decopilot/built-in-tools/vm-tools/index.ts +++ b/apps/mesh/src/api/routes/decopilot/built-in-tools/vm-tools/index.ts @@ -2,13 +2,13 @@ * VM File Tools — runner-agnostic. * * Registers the six LLM-visible tools (read/write/edit/grep/glob/bash) on - * top of any `SandboxRunner.proxyDaemonRequest`. Path scheme is Docker's - * canonical `/_daemon/fs/` + `/_daemon/bash`; non-Docker runners - * translate inside `proxyDaemonRequest` (see Freestyle's `translateDaemonPath`). + * top of any `SandboxRunner.proxyDaemonRequest`. All runners speak the + * unified `/_decopilot_vm/*` surface with base64-wrapped JSON bodies + * (Cloudflare WAF bypass; harmless 33% overhead on non-CF paths). */ import { tool, zodSchema } from "ai"; -import type { SandboxRunner } from "mesh-plugin-user-sandbox/runner"; +import type { SandboxRunner } from "@decocms/sandbox/runner"; import { maybeTruncate } from "./common"; import { BASH_DESCRIPTION, @@ -37,10 +37,13 @@ async function daemonRequest( ): Promise { let res: Response; try { + const b64Body = Buffer.from(JSON.stringify(body), "utf-8").toString( + "base64", + ); res = await runner.proxyDaemonRequest(handle, path, { method: "POST", headers: new Headers({ "content-type": "application/json" }), - body: JSON.stringify(body), + body: b64Body, }); } catch { throw new Error( @@ -98,7 +101,7 @@ export function createVmTools(params: VmToolsParams) { description: READ_DESCRIPTION, inputSchema: zodSchema(ReadInputSchema), execute: async (input) => { - const result = await call("/_daemon/fs/read", input); + const result = await call("/_decopilot_vm/read", input); return maybeTruncate(result, toolOutputMap); }, }); @@ -107,14 +110,14 @@ export function createVmTools(params: VmToolsParams) { needsApproval: approvalFor(TOOL_APPROVAL.write), description: WRITE_DESCRIPTION, inputSchema: zodSchema(WriteInputSchema), - execute: async (input) => call("/_daemon/fs/write", input), + execute: async (input) => call("/_decopilot_vm/write", input), }); const edit = tool({ needsApproval: approvalFor(TOOL_APPROVAL.edit), description: EDIT_DESCRIPTION, inputSchema: zodSchema(EditInputSchema), - execute: async (input) => call("/_daemon/fs/edit", input), + execute: async (input) => call("/_decopilot_vm/edit", input), }); const grep = tool({ @@ -122,7 +125,7 @@ export function createVmTools(params: VmToolsParams) { description: GREP_DESCRIPTION, inputSchema: zodSchema(GrepInputSchema), execute: async (input) => { - const result = await call("/_daemon/fs/grep", input); + const result = await call("/_decopilot_vm/grep", input); return maybeTruncate(result, toolOutputMap); }, }); @@ -132,7 +135,7 @@ export function createVmTools(params: VmToolsParams) { description: GLOB_DESCRIPTION, inputSchema: zodSchema(GlobInputSchema), execute: async (input) => { - const result = await call("/_daemon/fs/glob", input); + const result = await call("/_decopilot_vm/glob", input); return maybeTruncate(result, toolOutputMap); }, }); @@ -142,7 +145,7 @@ export function createVmTools(params: VmToolsParams) { description: BASH_DESCRIPTION, inputSchema: zodSchema(BashInputSchema), execute: async (input) => { - const result = await call("/_daemon/bash", input); + const result = await call("/_decopilot_vm/bash", input); return maybeTruncate(result, toolOutputMap); }, }); diff --git a/apps/mesh/src/api/routes/decopilot/built-in-tools/vm-tools/types.ts b/apps/mesh/src/api/routes/decopilot/built-in-tools/vm-tools/types.ts index 14df34e823..23bc650b3a 100644 --- a/apps/mesh/src/api/routes/decopilot/built-in-tools/vm-tools/types.ts +++ b/apps/mesh/src/api/routes/decopilot/built-in-tools/vm-tools/types.ts @@ -1,4 +1,4 @@ -import type { SandboxRunner } from "mesh-plugin-user-sandbox/runner"; +import type { SandboxRunner } from "@decocms/sandbox/runner"; export interface VmToolsParams { readonly runner: SandboxRunner; diff --git a/apps/mesh/src/api/routes/decopilot/memory.test.ts b/apps/mesh/src/api/routes/decopilot/memory.test.ts new file mode 100644 index 0000000000..2d75b7fba8 --- /dev/null +++ b/apps/mesh/src/api/routes/decopilot/memory.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect, beforeAll, afterAll } from "bun:test"; +import { createMemory } from "./memory"; +import { + buildThreadTestContext, + type ThreadTestEnv, +} from "../../../tools/thread/test-helpers"; + +describe("createMemory", () => { + let env: ThreadTestEnv; + + beforeAll(async () => { + env = await buildThreadTestContext(); + }); + afterAll(async () => { + await env.close(); + }); + + it("returns Memory when thread exists", async () => { + const thread = await env.ctx.storage.threads.create({ + id: "thrd_existing", + organization_id: env.orgId, + title: "ok", + created_by: env.userId, + virtual_mcp_id: "vmcp_x", + }); + + const memory = await createMemory(env.ctx.storage.threads, { + thread_id: thread.id, + organization_id: env.orgId, + userId: env.userId, + }); + + expect(memory.thread.id).toBe("thrd_existing"); + }); + + it("throws when thread_id is provided but thread does not exist", async () => { + await expect( + createMemory(env.ctx.storage.threads, { + thread_id: "thrd_does_not_exist", + organization_id: env.orgId, + userId: env.userId, + }), + ).rejects.toThrow(/thread.*not.*found/i); + }); +}); diff --git a/apps/mesh/src/api/routes/decopilot/memory.ts b/apps/mesh/src/api/routes/decopilot/memory.ts index e33cad226a..463b4b0084 100644 --- a/apps/mesh/src/api/routes/decopilot/memory.ts +++ b/apps/mesh/src/api/routes/decopilot/memory.ts @@ -7,15 +7,13 @@ import type { OrgScopedThreadStorage } from "@/storage/threads"; import type { Thread, ThreadMessage } from "@/storage/types"; -import { posthog } from "@/posthog"; -import { generatePrefixedId } from "@/shared/utils/generate-id"; /** * Configuration for creating a Memory instance */ export interface MemoryConfig { - /** Thread ID (creates new if not found) */ - thread_id?: string | null; + /** Thread ID (required — thread must exist) */ + thread_id: string; /** Organization scope */ organization_id: string; @@ -25,19 +23,6 @@ export interface MemoryConfig { /** Default window size for pruning */ defaultWindowSize?: number; - - /** Optional trigger ID for automation-created threads */ - triggerId?: string; - - /** Virtual MCP ID to associate with the thread */ - virtualMcpId?: string; - - /** - * Git branch to pin this thread to. Only meaningful for GitHub-linked - * virtualmcps. When set on a brand-new thread, it's persisted on the - * thread row and propagates to VM_START. - */ - branch?: string | null; } /** @@ -89,77 +74,23 @@ export class Memory { } /** - * Create or get a thread, returning a Memory instance + * Get an existing thread by id, returning a Memory instance. + * Throws if the thread does not exist — the route loader is responsible for + * creating threads up-front via COLLECTION_THREADS_CREATE. */ export async function createMemory( storage: OrgScopedThreadStorage, config: MemoryConfig, ): Promise { - const { - thread_id, - organization_id, - userId, - defaultWindowSize, - triggerId, - virtualMcpId, - branch, - } = config; - - let thread: Thread; + const { thread_id, defaultWindowSize } = config; if (!thread_id) { - // Create new thread - thread = await storage.create({ - id: generatePrefixedId("thrd"), - organization_id, - created_by: userId, - trigger_id: triggerId ?? null, - virtual_mcp_id: virtualMcpId ?? "", - branch: branch ?? null, - }); - posthog.capture({ - distinctId: userId, - event: "chat_started", - groups: { organization: organization_id }, - properties: { - organization_id, - thread_id: thread.id, - created_via: triggerId ? "automation" : "stream_auto", - trigger_id: triggerId ?? null, - virtual_mcp_id: virtualMcpId || null, - }, - }); - } else { - // Try to get existing thread scoped to this org - const existing = await storage.get(thread_id); + throw new Error("createMemory: thread_id is required"); + } - if (existing) { - thread = existing; - } else { - // Thread not found — create using the client-provided ID so the - // frontend and server stay in sync (avoids a thread-ID switch in - // onFinish which causes a full re-render cascade). - thread = await storage.create({ - id: thread_id, - organization_id, - created_by: userId, - trigger_id: triggerId ?? null, - virtual_mcp_id: virtualMcpId ?? "", - branch: branch ?? null, - }); - posthog.capture({ - distinctId: userId, - event: "chat_started", - groups: { organization: organization_id }, - properties: { - organization_id, - thread_id: thread.id, - created_via: triggerId ? "automation" : "stream_client_id", - trigger_id: triggerId ?? null, - virtual_mcp_id: virtualMcpId || null, - }, - }); - } + const thread = await storage.get(thread_id); + if (!thread) { + throw new Error(`Thread not found: ${thread_id}`); } return new Memory({ diff --git a/apps/mesh/src/api/routes/decopilot/run-registry.test.ts b/apps/mesh/src/api/routes/decopilot/run-registry.test.ts index 0bcc08f241..a1ee9b7f30 100644 --- a/apps/mesh/src/api/routes/decopilot/run-registry.test.ts +++ b/apps/mesh/src/api/routes/decopilot/run-registry.test.ts @@ -421,7 +421,7 @@ describe("RunRegistry", () => { created_at: "", updated_at: "", created_by: "u1", - updated_by: null, + updated_by: undefined, hidden: false, context_start_message_id: null, run_owner_pod: null, @@ -455,7 +455,7 @@ describe("RunRegistry", () => { created_at: "", updated_at: "", created_by: "u1", - updated_by: null, + updated_by: undefined, hidden: false, context_start_message_id: null, run_owner_pod: null, @@ -489,7 +489,7 @@ describe("RunRegistry", () => { created_at: "", updated_at: "", created_by: "u1", - updated_by: null, + updated_by: undefined, hidden: false, context_start_message_id: null, run_owner_pod: null, @@ -523,7 +523,7 @@ describe("RunRegistry", () => { created_at: "", updated_at: "", created_by: "u1", - updated_by: null, + updated_by: undefined, hidden: false, context_start_message_id: null, run_owner_pod: null, @@ -637,7 +637,7 @@ describe("RunRegistry", () => { created_at: "", updated_at: "", created_by: "u1", - updated_by: null, + updated_by: undefined, hidden: false, context_start_message_id: null, run_owner_pod: "dead-pod", diff --git a/apps/mesh/src/api/routes/decopilot/stream-core.ts b/apps/mesh/src/api/routes/decopilot/stream-core.ts index 3a50b7d19f..99f9ca1e1e 100644 --- a/apps/mesh/src/api/routes/decopilot/stream-core.ts +++ b/apps/mesh/src/api/routes/decopilot/stream-core.ts @@ -236,6 +236,10 @@ async function streamCoreInner( const windowSize = input.windowSize ?? DEFAULT_WINDOW_SIZE; + if (!input.taskId) { + throw new Error("streamCore: taskId is required"); + } + // 2. Load entities and create/load memory in parallel const [virtualMcp, provider, mem] = await Promise.all([ ctx.storage.virtualMcps.findById(input.agent.id, input.organizationId), @@ -250,9 +254,6 @@ async function streamCoreInner( thread_id: input.taskId, userId: input.userId, defaultWindowSize: windowSize, - triggerId: input.triggerId, - virtualMcpId: input.agent.id, - branch: input.branch ?? null, }), ]); diff --git a/apps/mesh/src/api/routes/sandbox-daemon.test.ts b/apps/mesh/src/api/routes/sandbox-daemon.test.ts deleted file mode 100644 index 820ecd2668..0000000000 --- a/apps/mesh/src/api/routes/sandbox-daemon.test.ts +++ /dev/null @@ -1,185 +0,0 @@ -/** - * Cross-tenant auth test. The daemon proxy is the only surface through - * which a browser reaches the runner — any leak lets one user reach - * another's container. - */ - -import { beforeEach, describe, expect, it, mock } from "bun:test"; -import { Hono } from "hono"; -import type { MeshContext } from "../../core/mesh-context"; - -const proxyDaemonRequest = mock( - async (_handle: string, _path: string, _init: unknown) => - new Response("proxied", { status: 200 }), -); - -const lastRequestedKind: { value: string | null } = { value: null }; - -function makeMockRunner(kind: "docker" | "freestyle") { - return { - kind, - ensure: async () => ({ - handle: "h", - workdir: "/app", - previewUrl: null, - }), - exec: async () => ({ - stdout: "", - stderr: "", - exitCode: 0, - timedOut: false, - }), - delete: async () => {}, - alive: async () => true, - getPreviewUrl: async () => null, - proxyDaemonRequest, - }; -} - -mock.module("@/sandbox/lifecycle", () => ({ - getRunnerByKind: (_ctx: unknown, kind: "docker" | "freestyle") => { - lastRequestedKind.value = kind; - return makeMockRunner(kind); - }, -})); - -const { createSandboxDaemonRoutes } = await import("./sandbox-daemon"); - -type RunnerStateRow = { user_id: string; runner_kind: string }; - -function makeCtxWithRow( - userId: string | null, - row: RunnerStateRow | null, -): MeshContext { - return { - auth: userId - ? { - user: { - id: userId, - email: "t@example.com", - name: "t", - role: "user", - }, - } - : null, - db: { - selectFrom: (_table: string) => ({ - select: (_cols: unknown) => ({ - where: (_col: string, _op: string, _val: string) => ({ - executeTakeFirst: async () => row ?? undefined, - }), - }), - }), - }, - } as unknown as MeshContext; -} - -function mountWithCtx(ctx: MeshContext) { - const app = new Hono<{ Variables: { meshContext: MeshContext } }>(); - app.use("*", async (c, next) => { - c.set("meshContext", ctx); - await next(); - }); - app.route("/", createSandboxDaemonRoutes()); - return app; -} - -describe("sandbox daemon passthrough authorization", () => { - beforeEach(() => { - proxyDaemonRequest.mockClear(); - lastRequestedKind.value = null; - }); - - it("returns 401 when the session has no user", async () => { - const app = mountWithCtx(makeCtxWithRow(null, null)); - const res = await app.request("/api/sandbox/handle_abc/_daemon/fs/read", { - method: "POST", - }); - expect(res.status).toBe(401); - expect(proxyDaemonRequest).not.toHaveBeenCalled(); - }); - - it("returns 404 when the handle belongs to a different user", async () => { - const app = mountWithCtx( - makeCtxWithRow("user_attacker", { - user_id: "user_victim", - runner_kind: "docker", - }), - ); - const res = await app.request( - "/api/sandbox/handle_victim/_daemon/fs/read", - { method: "POST" }, - ); - expect(res.status).toBe(404); - // Must never forward on ownership mismatch — a leak here lets one user - // reach another's container by knowing the handle. - expect(proxyDaemonRequest).not.toHaveBeenCalled(); - }); - - it("returns 404 when no row exists for the handle", async () => { - const app = mountWithCtx(makeCtxWithRow("user_1", null)); - const res = await app.request( - "/api/sandbox/handle_missing/_daemon/events", - { - method: "GET", - }, - ); - expect(res.status).toBe(404); - expect(proxyDaemonRequest).not.toHaveBeenCalled(); - }); - - it("forwards to the runner when the caller owns the handle", async () => { - const app = mountWithCtx( - makeCtxWithRow("user_1", { user_id: "user_1", runner_kind: "docker" }), - ); - const res = await app.request("/api/sandbox/handle_owned/_daemon/fs/read", { - method: "POST", - }); - expect(res.status).toBe(200); - expect(proxyDaemonRequest).toHaveBeenCalledTimes(1); - const [handle, path] = proxyDaemonRequest.mock.calls[0]! as [ - string, - string, - unknown, - ]; - expect(handle).toBe("handle_owned"); - expect(path).toBe("/_daemon/fs/read"); - }); - - it("rejects unsupported runner kinds with 400", async () => { - const app = mountWithCtx( - makeCtxWithRow("user_1", { user_id: "user_1", runner_kind: "k8s" }), - ); - const res = await app.request("/api/sandbox/handle_k8s/_daemon/fs/read", { - method: "POST", - }); - expect(res.status).toBe(400); - expect(proxyDaemonRequest).not.toHaveBeenCalled(); - }); - - // Regression guard for the invariant called out in sandbox-daemon.ts:1–5: - // a pod that flipped MESH_SANDBOX_RUNNER after the sandbox row was written - // must still proxy to the kind of runner that owns the container. - it("dispatches on the row's runner_kind even when MESH_SANDBOX_RUNNER env disagrees", async () => { - const original = process.env.MESH_SANDBOX_RUNNER; - process.env.MESH_SANDBOX_RUNNER = "freestyle"; - try { - const app = mountWithCtx( - makeCtxWithRow("user_1", { - user_id: "user_1", - runner_kind: "docker", - }), - ); - const res = await app.request( - "/api/sandbox/handle_owned/_daemon/fs/read", - { method: "POST" }, - ); - expect(res.status).toBe(200); - expect(lastRequestedKind.value).toBe("docker"); - expect(proxyDaemonRequest).toHaveBeenCalledTimes(1); - } finally { - if (original === undefined) delete process.env.MESH_SANDBOX_RUNNER; - else process.env.MESH_SANDBOX_RUNNER = original; - } - }); -}); diff --git a/apps/mesh/src/api/routes/sandbox-daemon.ts b/apps/mesh/src/api/routes/sandbox-daemon.ts deleted file mode 100644 index 6a59a8c61c..0000000000 --- a/apps/mesh/src/api/routes/sandbox-daemon.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * Daemon passthrough — auth happens here (session must own the handle). - * Dev-server traffic bypasses this route (pods expose dev directly). - * Runner is dispatched on the row's `runner_kind`, not current env. - */ - -import { Hono } from "hono"; -import type { Context } from "hono"; -import type { - RunnerKind, - SandboxRunner, -} from "mesh-plugin-user-sandbox/runner"; -import type { MeshContext } from "@/core/mesh-context"; -import { getRunnerByKind } from "@/sandbox/lifecycle"; - -const SUPPORTED_KINDS: ReadonlySet = new Set([ - "docker", - "freestyle", -]); - -async function authorizeSandbox( - c: Context<{ Variables: { meshContext: MeshContext } }>, -): Promise<{ handle: string; runner: SandboxRunner } | Response> { - const ctx = c.get("meshContext"); - const userId = ctx.auth?.user?.id; - if (!userId) return c.json({ error: "Unauthorized" }, 401); - - const handle = c.req.param("handle"); - if (!handle) return c.json({ error: "Invalid sandbox handle" }, 400); - - const row = await ctx.db - .selectFrom("sandbox_runner_state") - .select(["user_id", "runner_kind"]) - .where("handle", "=", handle) - .executeTakeFirst(); - if (!row || row.user_id !== userId) { - return c.json({ error: "Sandbox not found" }, 404); - } - const kind = row.runner_kind as RunnerKind; - if (!SUPPORTED_KINDS.has(kind)) { - return c.json( - { error: `Daemon passthrough unsupported for runner ${row.runner_kind}` }, - 400, - ); - } - return { handle, runner: await getRunnerByKind(ctx, kind) }; -} - -export function createSandboxDaemonRoutes() { - const app = new Hono<{ Variables: { meshContext: MeshContext } }>(); - - const forward = async ( - c: Context<{ Variables: { meshContext: MeshContext } }>, - ) => { - const auth = await authorizeSandbox(c); - if (auth instanceof Response) return auth; - - const prefix = `/api/sandbox/${auth.handle}`; - const url = new URL(c.req.url); - const tail = url.pathname.startsWith(prefix) - ? url.pathname.slice(prefix.length) - : ""; - // tail already starts with `/_daemon/...`, so passthrough as-is. - const upstream = await auth.runner.proxyDaemonRequest( - auth.handle, - `${tail}${url.search}`, - { - method: c.req.method, - headers: c.req.raw.headers, - body: c.req.raw.body, - signal: c.req.raw.signal, - }, - ); - return new Response(upstream.body, { - status: upstream.status, - statusText: upstream.statusText, - headers: upstream.headers, - }); - }; - - app.all("/api/sandbox/:handle/_daemon/*", forward); - app.all("/api/sandbox/:handle/_daemon", forward); - - return app; -} diff --git a/apps/mesh/src/cli/sandbox-image.ts b/apps/mesh/src/cli/sandbox-image.ts index 8d59d018df..59850b32ac 100644 --- a/apps/mesh/src/cli/sandbox-image.ts +++ b/apps/mesh/src/cli/sandbox-image.ts @@ -16,7 +16,7 @@ export async function kickoffSandboxImageBuild(opts: { if (process.env.MESH_SANDBOX_IMAGE) return; const { tryResolveRunnerKindFromEnv, ensureSandboxImage } = await import( - "mesh-plugin-user-sandbox/runner" + "@decocms/sandbox/runner" ); if (tryResolveRunnerKindFromEnv() !== "docker") return; diff --git a/apps/mesh/src/index.ts b/apps/mesh/src/index.ts index 86d0a45e53..5dbbb47081 100644 --- a/apps/mesh/src/index.ts +++ b/apps/mesh/src/index.ts @@ -81,12 +81,10 @@ let ingressServers: import("node:net").Server[] = []; // Docker-only boot/dev wiring. Both hooks (boot sweep + local ingress) are // intimate with Docker-specific primitives (labels, host-port mappings); // other runners manage their own VM/ingress lifecycle. -const { tryResolveRunnerKindFromEnv } = await import( - "mesh-plugin-user-sandbox/runner" -); +const { tryResolveRunnerKindFromEnv } = await import("@decocms/sandbox/runner"); if (tryResolveRunnerKindFromEnv() === "docker") { const { sweepDockerOrphansOnBoot, startLocalSandboxIngress } = await import( - "mesh-plugin-user-sandbox/runner" + "@decocms/sandbox/runner" ); const { asDockerRunner, getSharedRunnerIfInit } = await import( "./sandbox/lifecycle" diff --git a/apps/mesh/src/sandbox/lifecycle.test.ts b/apps/mesh/src/sandbox/lifecycle.test.ts index 257df64a0d..4d47a19883 100644 --- a/apps/mesh/src/sandbox/lifecycle.test.ts +++ b/apps/mesh/src/sandbox/lifecycle.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; -import { DockerSandboxRunner } from "mesh-plugin-user-sandbox/runner"; +import { DockerSandboxRunner } from "@decocms/sandbox/runner"; import type { MeshContext } from "@/core/mesh-context"; import { asDockerRunner, getRunnerByKind } from "./lifecycle"; diff --git a/apps/mesh/src/sandbox/lifecycle.ts b/apps/mesh/src/sandbox/lifecycle.ts index 95ebaf65d2..7c4138458a 100644 --- a/apps/mesh/src/sandbox/lifecycle.ts +++ b/apps/mesh/src/sandbox/lifecycle.ts @@ -13,7 +13,7 @@ import { tryResolveRunnerKindFromEnv, type RunnerKind, type SandboxRunner, -} from "mesh-plugin-user-sandbox/runner"; +} from "@decocms/sandbox/runner"; import { KyselySandboxRunnerStateStore } from "@/storage/sandbox-runner-state"; const runners: Partial> = {}; @@ -30,7 +30,7 @@ async function instantiate( // Dynamic import — freestyle SDK is an optionalDependency so // docker-only deploys don't need it installed. const { FreestyleSandboxRunner } = await import( - "mesh-plugin-user-sandbox/runner/freestyle" + "@decocms/sandbox/runner/freestyle" ); return new FreestyleSandboxRunner({ stateStore }); } diff --git a/apps/mesh/src/storage/sandbox-runner-state.test.ts b/apps/mesh/src/storage/sandbox-runner-state.test.ts index 84fbc61f98..ba2116a226 100644 --- a/apps/mesh/src/storage/sandbox-runner-state.test.ts +++ b/apps/mesh/src/storage/sandbox-runner-state.test.ts @@ -1,5 +1,5 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; -import type { SandboxId } from "mesh-plugin-user-sandbox/runner"; +import type { SandboxId } from "@decocms/sandbox/runner"; import { closeTestDatabase, createTestDatabase, diff --git a/apps/mesh/src/storage/sandbox-runner-state.ts b/apps/mesh/src/storage/sandbox-runner-state.ts index 79be100ea1..df1961825e 100644 --- a/apps/mesh/src/storage/sandbox-runner-state.ts +++ b/apps/mesh/src/storage/sandbox-runner-state.ts @@ -1,7 +1,7 @@ /** * Kysely-backed RunnerStateStore. `state` jsonb is opaque — each runner * serialises its own fields. See - * packages/mesh-plugin-user-sandbox/server/runner/. + * packages/@decocms/sandbox/server/runner/. * * Method implementations take an explicit executor (db or trx) so the scoped * store handed to `withLock` callbacks can reuse the lock's connection. If @@ -19,7 +19,7 @@ import type { RunnerStateStore, RunnerStateStoreOps, SandboxId, -} from "mesh-plugin-user-sandbox/runner"; +} from "@decocms/sandbox/runner"; import type { Database } from "./types"; type Executor = Kysely; diff --git a/apps/mesh/src/storage/threads.ts b/apps/mesh/src/storage/threads.ts index d3c5cb8459..30d3ffe488 100644 --- a/apps/mesh/src/storage/threads.ts +++ b/apps/mesh/src/storage/threads.ts @@ -155,13 +155,26 @@ export class SqlThreadStorage implements ThreadStoragePort { : {}), }; - const result = await this.db + const inserted = await this.db .insertInto("threads") .values(row) + .onConflict((oc) => oc.column("id").doNothing()) .returningAll() + .executeTakeFirst(); + + if (inserted) { + return this.threadFromDbRow(inserted); + } + + // Conflict — another caller already inserted this id. Return the row that won. + const existing = await this.db + .selectFrom("threads") + .selectAll() + .where("id", "=", id) + .where("organization_id", "=", data.organization_id) .executeTakeFirstOrThrow(); - return this.threadFromDbRow(result); + return this.threadFromDbRow(existing); } async get(id: string, organizationId: string): Promise { @@ -707,7 +720,7 @@ export class SqlThreadStorage implements ThreadStoragePort { created_at: toIsoString(row.created_at), updated_at: toIsoString(row.updated_at), created_by: row.created_by, - updated_by: row.updated_by, + updated_by: row.updated_by ?? undefined, hidden: !!row.hidden, }; } diff --git a/apps/mesh/src/storage/types.ts b/apps/mesh/src/storage/types.ts index dacd5b5c18..b3a83da9b7 100644 --- a/apps/mesh/src/storage/types.ts +++ b/apps/mesh/src/storage/types.ts @@ -775,7 +775,7 @@ export interface Thread { created_at: string; updated_at: string; created_by: string; - updated_by: string | null; + updated_by: string | undefined; hidden: boolean | null; status: ThreadStatus; trigger_id: string | null; diff --git a/apps/mesh/src/shared/branch-name.test.ts b/apps/mesh/src/tools/thread/branch-name.test.ts similarity index 100% rename from apps/mesh/src/shared/branch-name.test.ts rename to apps/mesh/src/tools/thread/branch-name.test.ts diff --git a/apps/mesh/src/shared/branch-name.ts b/apps/mesh/src/tools/thread/branch-name.ts similarity index 100% rename from apps/mesh/src/shared/branch-name.ts rename to apps/mesh/src/tools/thread/branch-name.ts diff --git a/apps/mesh/src/tools/thread/create.test.ts b/apps/mesh/src/tools/thread/create.test.ts new file mode 100644 index 0000000000..0ea8bb410f --- /dev/null +++ b/apps/mesh/src/tools/thread/create.test.ts @@ -0,0 +1,184 @@ +import { describe, it, expect, beforeAll, afterAll } from "bun:test"; +import { COLLECTION_THREADS_CREATE } from "./create"; +import { buildThreadTestContext, type ThreadTestEnv } from "./test-helpers"; + +describe("COLLECTION_THREADS_CREATE", () => { + let env: ThreadTestEnv; + + beforeAll(async () => { + env = await buildThreadTestContext(); + }); + afterAll(async () => { + await env.close(); + }); + + it("assigns a generated branch when the vMCP has a github repo", async () => { + const vmcp = await env.ctx.storage.virtualMcps.create( + env.orgId, + env.userId, + { + title: "gh-vmcp", + connections: [], + status: "active", + pinned: false, + metadata: { + githubRepo: { + owner: "acme", + name: "repo", + url: "https://github.com/acme/repo", + installationId: 1, + connectionId: "conn_x", + }, + }, + }, + ); + + const result = await COLLECTION_THREADS_CREATE.handler( + { data: { virtual_mcp_id: vmcp.id, title: "t" } }, + env.ctx, + ); + + expect(result.item.branch).toMatch(/^deco\/[a-z]+-[a-z]+$/); + expect(result.item.virtual_mcp_id).toBe(vmcp.id); + }); + + it("leaves branch null when the vMCP has no github repo", async () => { + const vmcp = await env.ctx.storage.virtualMcps.create( + env.orgId, + env.userId, + { title: "no-gh", connections: [], status: "active", pinned: false }, + ); + + const result = await COLLECTION_THREADS_CREATE.handler( + { data: { virtual_mcp_id: vmcp.id, title: "t" } }, + env.ctx, + ); + + expect(result.item.branch).toBeNull(); + }); + + it("uses the input branch when the vMCP has a github repo", async () => { + const vmcp = await env.ctx.storage.virtualMcps.create( + env.orgId, + env.userId, + { + title: "gh-vmcp-explicit", + connections: [], + status: "active", + pinned: false, + metadata: { + githubRepo: { + owner: "acme", + name: "repo", + url: "https://github.com/acme/repo", + installationId: 1, + connectionId: "conn_x", + }, + }, + }, + ); + + const result = await COLLECTION_THREADS_CREATE.handler( + { + data: { + virtual_mcp_id: vmcp.id, + title: "t", + branch: "deco/custom-branch", + }, + }, + env.ctx, + ); + + expect(result.item.branch).toBe("deco/custom-branch"); + }); + + it("ignores the input branch when the vMCP has no github repo", async () => { + const vmcp = await env.ctx.storage.virtualMcps.create( + env.orgId, + env.userId, + { + title: "no-gh-with-input-branch", + connections: [], + status: "active", + pinned: false, + }, + ); + + const result = await COLLECTION_THREADS_CREATE.handler( + { + data: { + virtual_mcp_id: vmcp.id, + title: "t", + branch: "deco/should-be-ignored", + }, + }, + env.ctx, + ); + + expect(result.item.branch).toBeNull(); + }); + + it("picks the most-recently-touched vmMap branch when no input branch + github vMCP", async () => { + const vmcp = await env.ctx.storage.virtualMcps.create( + env.orgId, + env.userId, + { + title: "gh-vmcp-with-vmmap", + connections: [], + status: "active", + pinned: false, + metadata: { + githubRepo: { + owner: "acme", + name: "repo", + url: "https://github.com/acme/repo", + installationId: 1, + connectionId: "conn_x", + }, + vmMap: { + [env.userId]: { + "deco/old-branch": { + vmId: "vm_old", + previewUrl: null, + createdAt: 1000, + }, + "deco/new-branch": { + vmId: "vm_new", + previewUrl: null, + createdAt: 2000, + }, + }, + }, + }, + }, + ); + + const result = await COLLECTION_THREADS_CREATE.handler( + { data: { virtual_mcp_id: vmcp.id, title: "t" } }, + env.ctx, + ); + + expect(result.item.branch).toBe("deco/new-branch"); + }); + + it("is idempotent: creating with the same id twice returns the same row", async () => { + const vmcp = await env.ctx.storage.virtualMcps.create( + env.orgId, + env.userId, + { title: "x", connections: [], status: "active", pinned: false }, + ); + + const id = "thrd_test_idempotent"; + const first = await COLLECTION_THREADS_CREATE.handler( + { data: { id, virtual_mcp_id: vmcp.id, title: "first" } }, + env.ctx, + ); + const second = await COLLECTION_THREADS_CREATE.handler( + { data: { id, virtual_mcp_id: vmcp.id, title: "second" } }, + env.ctx, + ); + + expect(second.item.id).toBe(first.item.id); + expect(second.item.title).toBe("first"); // existing row, not overwritten + }); +}); diff --git a/apps/mesh/src/tools/thread/create.ts b/apps/mesh/src/tools/thread/create.ts index fb24cf736b..84c807c418 100644 --- a/apps/mesh/src/tools/thread/create.ts +++ b/apps/mesh/src/tools/thread/create.ts @@ -1,7 +1,18 @@ /** * COLLECTION_THREADS_CREATE Tool * - * Create a new thread (organization-scoped) with collection binding compliance. + * Create a new thread for a virtual MCP. + * + * Branch resolution (only meaningful when the vMCP has a githubRepo): + * 1. Honor `data.branch` when provided. + * 2. Otherwise pick the most-recently-touched branch from the user's + * `vmMap[userId]` so a new task lands on a warm sandbox. + * 3. Fall back to a freshly generated `deco/-` name when the + * user has no vmMap entries for this vMCP. + * + * Threads created on a vMCP without a githubRepo always get `branch = null`. + * + * Idempotent on `id` collisions (storage uses INSERT … ON CONFLICT DO NOTHING). */ import { z } from "zod"; @@ -14,10 +25,8 @@ import { } from "../../core/mesh-context"; import { ThreadCreateDataSchema, ThreadEntitySchema } from "./schema"; import { generatePrefixedId } from "@/shared/utils/generate-id"; +import { generateBranchName } from "./branch-name"; -/** - * Input schema for creating threads (wrapped in data field for collection compliance) - */ const CreateInputSchema = z.object({ data: ThreadCreateDataSchema.describe( "Data for the new thread (id is auto-generated if not provided)", @@ -26,13 +35,38 @@ const CreateInputSchema = z.object({ export type CreateThreadInput = z.infer; -/** - * Output schema for created thread - */ const CreateOutputSchema = z.object({ item: ThreadEntitySchema.describe("The created thread entity"), }); +type GithubRepoMeta = { + githubRepo?: { + owner: string; + name: string; + connectionId?: string; + } | null; +}; + +type VmMapMeta = { + vmMap?: Record>; +}; + +/** + * Pick the user's most-recently-touched branch from vmMap. Returns undefined + * when the user has no entries (caller falls back to generateBranchName). + */ +function pickWarmBranchFromVmMap( + vmMap: VmMapMeta["vmMap"], + userId: string, +): string | undefined { + const entries = vmMap?.[userId]; + if (!entries) return undefined; + const sorted = Object.entries(entries).sort( + ([, a], [, b]) => (b.createdAt ?? 0) - (a.createdAt ?? 0), + ); + return sorted[0]?.[0]; +} + export const COLLECTION_THREADS_CREATE = defineTool({ name: "COLLECTION_THREADS_CREATE", description: "Create a new thread for organizing messages and conversations.", @@ -40,7 +74,7 @@ export const COLLECTION_THREADS_CREATE = defineTool({ title: "Create Thread", readOnlyHint: false, destructiveHint: true, - idempotentHint: false, + idempotentHint: true, openWorldHint: false, }, inputSchema: CreateInputSchema, @@ -49,7 +83,6 @@ export const COLLECTION_THREADS_CREATE = defineTool({ handler: async (input, ctx) => { requireAuth(ctx); const organization = requireOrganization(ctx); - await ctx.access.check(); const userId = getUserId(ctx); @@ -57,14 +90,37 @@ export const COLLECTION_THREADS_CREATE = defineTool({ throw new Error("User ID required to create thread"); } - const taskId = input.data.id ?? generatePrefixedId("thrd"); + const { data } = input; + const taskId = data.id ?? generatePrefixedId("thrd"); + + const vmcp = await ctx.storage.virtualMcps.findById( + data.virtual_mcp_id, + organization.id, + ); + if (!vmcp) { + throw new Error(`Virtual MCP not found: ${data.virtual_mcp_id}`); + } + + const metadata = vmcp.metadata as + | (GithubRepoMeta & VmMapMeta) + | null + | undefined; + const githubRepo = metadata?.githubRepo; + let branch: string | null = null; + if (githubRepo) { + branch = + data.branch ?? + pickWarmBranchFromVmMap(metadata?.vmMap, userId) ?? + generateBranchName(); + } const result = await ctx.storage.threads.create({ id: taskId, organization_id: organization.id, - title: input.data.title, - description: input.data.description, - branch: input.data.branch ?? null, + title: data.title, + description: data.description, + virtual_mcp_id: data.virtual_mcp_id, + branch, created_by: userId, }); diff --git a/apps/mesh/src/tools/thread/helpers.test.ts b/apps/mesh/src/tools/thread/helpers.test.ts index 1b555eab07..da1b005614 100644 --- a/apps/mesh/src/tools/thread/helpers.test.ts +++ b/apps/mesh/src/tools/thread/helpers.test.ts @@ -14,7 +14,7 @@ const BASE_THREAD: Thread = { created_at: "2025-01-01T00:00:00.000Z", updated_at: "2025-01-01T00:00:00.000Z", created_by: "user_test", - updated_by: null, + updated_by: undefined, hidden: null, status: "completed", trigger_id: null, diff --git a/apps/mesh/src/tools/thread/schema.ts b/apps/mesh/src/tools/thread/schema.ts index 27c5950030..cf30f15172 100644 --- a/apps/mesh/src/tools/thread/schema.ts +++ b/apps/mesh/src/tools/thread/schema.ts @@ -68,7 +68,7 @@ export const ThreadEntitySchema = z.object({ created_by: z.string().describe("User ID who created the thread"), updated_by: z .string() - .nullable() + .optional() .describe("User ID who last updated the thread"), virtual_mcp_id: z .string() @@ -100,12 +100,18 @@ export type ThreadEntity = z.infer; export const ThreadCreateDataSchema = z.object({ id: z.string().optional().describe("Optional custom ID for the thread"), - title: z.string().describe("Thread title"), + title: z.string().optional().describe("Thread title"), description: z.string().nullish().describe("Thread description"), + virtual_mcp_id: z + .string() + .describe("Virtual MCP (agent) this thread is bound to"), branch: z .string() - .nullish() - .describe("Git branch to pin this thread to (GitHub-linked vms only)"), + .min(1) + .optional() + .describe( + "Preferred branch. Used only when the vMCP has a githubRepo; ignored otherwise. When omitted, the server picks the most-recently-touched branch from the user's vmMap, falling back to a freshly generated name.", + ), }); export type ThreadCreateData = z.infer; diff --git a/apps/mesh/src/tools/thread/test-helpers.ts b/apps/mesh/src/tools/thread/test-helpers.ts new file mode 100644 index 0000000000..cecc236ce0 --- /dev/null +++ b/apps/mesh/src/tools/thread/test-helpers.ts @@ -0,0 +1,144 @@ +/** + * Test scaffolding for thread tool tests. Mirrors the manual context + * construction in `connection/connection-tools.test.ts`, but only wires the + * storage modules the thread tools touch (threads, virtualMcps). + */ + +import { vi } from "bun:test"; +import { + createTestDatabase, + closeTestDatabase, + type TestDatabase, +} from "../../database/test-db"; +import { + createTestSchema, + seedCommonTestFixtures, +} from "../../storage/test-helpers"; +import { CredentialVault } from "../../encryption/credential-vault"; +import { + SqlThreadStorage, + OrgScopedThreadStorage, +} from "../../storage/threads"; +import { VirtualMCPStorage } from "../../storage/virtual"; +import type { BoundAuthClient, MeshContext } from "../../core/mesh-context"; + +const ORG_ID = "org_test"; +const USER_ID = "user_test"; + +export interface ThreadTestEnv { + database: TestDatabase; + ctx: MeshContext; + orgId: string; + userId: string; + close: () => Promise; +} + +const createMockBoundAuth = (): BoundAuthClient => + ({ + hasPermission: vi.fn().mockResolvedValue(true), + organization: { + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + get: vi.fn(), + list: vi.fn(), + addMember: vi.fn(), + removeMember: vi.fn(), + listMembers: vi.fn(), + updateMemberRole: vi.fn(), + }, + apiKey: { + create: vi.fn(), + list: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, + }) as unknown as BoundAuthClient; + +export async function buildThreadTestContext(): Promise { + const database = await createTestDatabase(); + await createTestSchema(database.db); + await seedCommonTestFixtures(database.db); + + const vault = new CredentialVault(CredentialVault.generateKey()); + const sqlThreads = new SqlThreadStorage(database.db); + const threads = new OrgScopedThreadStorage(sqlThreads, ORG_ID); + const virtualMcps = new VirtualMCPStorage(database.db); + + const ctx = { + timings: { + measure: async (_name: string, cb: () => Promise) => await cb(), + }, + auth: { + user: { + id: USER_ID, + email: "[email protected]", + name: "T", + role: "admin", + }, + }, + organization: { id: ORG_ID, slug: "test-org", name: "Test Org" }, + storage: { + threads, + virtualMcps, + // Stub the rest — thread tools don't touch these. + connections: null as never, + organizationSettings: null as never, + monitoring: null as never, + users: null as never, + tags: null as never, + virtualMcpPluginConfigs: null as never, + aiProviderKeys: null as never, + oauthPkceStates: null as never, + automations: null as never, + orgSsoConfig: null as never, + orgSsoSessions: null as never, + triggerCallbackTokens: null as never, + registry: null as never, + brandContext: null as never, + organizationDomains: null as never, + }, + vault, + authInstance: null as never, + boundAuth: createMockBoundAuth(), + access: { + granted: () => true, + check: async () => {}, + grant: () => {}, + setToolName: () => {}, + } as never, + db: database.db, + tracer: { + startActiveSpan: ( + _name: string, + _opts: unknown, + fn: (span: unknown) => unknown, + ) => + fn({ + setStatus: () => {}, + recordException: () => {}, + end: () => {}, + }), + } as never, + meter: { + createHistogram: () => ({ record: () => {} }), + createCounter: () => ({ add: () => {} }), + } as never, + baseUrl: "https://mesh.example.com", + metadata: { requestId: "req_test", timestamp: new Date() }, + eventBus: null as never, + objectStorage: null as never, + aiProviders: null as never, + createMCPProxy: vi.fn().mockResolvedValue({}), + getOrCreateClient: vi.fn().mockResolvedValue({}), + pendingRevalidations: [], + } as unknown as MeshContext; + + return { + database, + ctx, + orgId: ORG_ID, + userId: USER_ID, + close: () => closeTestDatabase(database), + }; +} diff --git a/apps/mesh/src/tools/thread/update.test.ts b/apps/mesh/src/tools/thread/update.test.ts new file mode 100644 index 0000000000..23b740a4d1 --- /dev/null +++ b/apps/mesh/src/tools/thread/update.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, beforeAll, afterAll } from "bun:test"; +import { COLLECTION_THREADS_CREATE } from "./create"; +import { COLLECTION_THREADS_UPDATE } from "./update"; +import { buildThreadTestContext, type ThreadTestEnv } from "./test-helpers"; + +describe("COLLECTION_THREADS_UPDATE", () => { + let env: ThreadTestEnv; + + beforeAll(async () => { + env = await buildThreadTestContext(); + }); + afterAll(async () => { + await env.close(); + }); + + it("rejects branch=null for a github-linked thread", async () => { + const vmcp = await env.ctx.storage.virtualMcps.create( + env.orgId, + env.userId, + { + title: "gh", + connections: [], + status: "active", + pinned: false, + metadata: { + githubRepo: { + owner: "a", + name: "b", + url: "https://github.com/a/b", + installationId: 1, + connectionId: "c", + }, + }, + }, + ); + const created = await COLLECTION_THREADS_CREATE.handler( + { data: { virtual_mcp_id: vmcp.id, title: "t" } }, + env.ctx, + ); + + await expect( + COLLECTION_THREADS_UPDATE.handler( + { id: created.item.id, data: { branch: null } }, + env.ctx, + ), + ).rejects.toThrow(/branch.*null.*github/i); + }); + + it("allows branch=null for non-github threads", async () => { + const vmcp = await env.ctx.storage.virtualMcps.create( + env.orgId, + env.userId, + { title: "no-gh", connections: [], status: "active", pinned: false }, + ); + const created = await COLLECTION_THREADS_CREATE.handler( + { data: { virtual_mcp_id: vmcp.id, title: "t" } }, + env.ctx, + ); + + const updated = await COLLECTION_THREADS_UPDATE.handler( + { id: created.item.id, data: { branch: null } }, + env.ctx, + ); + expect(updated.item.branch).toBeNull(); + }); + + it("allows switching to a different branch on github threads", async () => { + const vmcp = await env.ctx.storage.virtualMcps.create( + env.orgId, + env.userId, + { + title: "gh", + connections: [], + status: "active", + pinned: false, + metadata: { + githubRepo: { + owner: "a", + name: "b", + url: "https://github.com/a/b", + installationId: 1, + connectionId: "c", + }, + }, + }, + ); + const created = await COLLECTION_THREADS_CREATE.handler( + { data: { virtual_mcp_id: vmcp.id, title: "t" } }, + env.ctx, + ); + + const updated = await COLLECTION_THREADS_UPDATE.handler( + { id: created.item.id, data: { branch: "deco/manual-pick" } }, + env.ctx, + ); + expect(updated.item.branch).toBe("deco/manual-pick"); + }); +}); diff --git a/apps/mesh/src/tools/thread/update.ts b/apps/mesh/src/tools/thread/update.ts index ba0a433d9a..33abf138ed 100644 --- a/apps/mesh/src/tools/thread/update.ts +++ b/apps/mesh/src/tools/thread/update.ts @@ -61,6 +61,27 @@ export const COLLECTION_THREADS_UPDATE = defineTool({ throw new Error("Thread not found in organization"); } + if (data.branch === null && existing.virtual_mcp_id) { + const vmcp = await ctx.storage.virtualMcps.findById( + existing.virtual_mcp_id, + requireOrganization(ctx).id, + ); + type GithubRepoMeta = { + githubRepo?: { + owner: string; + name: string; + connectionId?: string; + } | null; + }; + const githubRepo = (vmcp?.metadata as GithubRepoMeta | null | undefined) + ?.githubRepo; + if (githubRepo) { + throw new Error( + "Cannot set branch=null on a github-linked thread (vMCP has githubRepo)", + ); + } + } + const updateData: Parameters[1] = { title: data.title, description: data.description, diff --git a/apps/mesh/src/tools/vm/start.test.ts b/apps/mesh/src/tools/vm/start.test.ts index f8e3d783b2..721abeb877 100644 --- a/apps/mesh/src/tools/vm/start.test.ts +++ b/apps/mesh/src/tools/vm/start.test.ts @@ -6,8 +6,8 @@ import type { Sandbox, SandboxId, SandboxRunner, -} from "mesh-plugin-user-sandbox/runner"; -import { composeSandboxRef } from "mesh-plugin-user-sandbox/runner"; +} from "@decocms/sandbox/runner"; +import { composeSandboxRef } from "@decocms/sandbox/runner"; // Pin runner kind — the dev env flips MESH_SANDBOX_RUNNER and VM_START // reads it at handler time. diff --git a/apps/mesh/src/tools/vm/start.ts b/apps/mesh/src/tools/vm/start.ts index 6bbf815378..19baeacd9e 100644 --- a/apps/mesh/src/tools/vm/start.ts +++ b/apps/mesh/src/tools/vm/start.ts @@ -16,13 +16,13 @@ import { resolveRunnerKindFromEnv, type RunnerKind, type Workload, -} from "mesh-plugin-user-sandbox/runner"; +} from "@decocms/sandbox/runner"; import { defineTool } from "../../core/define-tool"; import type { MeshContext } from "../../core/mesh-context"; import { requireVmEntry, resolveRuntimeConfig } from "./helpers"; import { buildCloneInfo } from "../../shared/github-clone-info"; import { detectRepoRuntime } from "../../shared/github-runtime-detect"; -import { generateBranchName } from "../../shared/branch-name"; +import { generateBranchName } from "../thread/branch-name"; import { PACKAGE_MANAGER_CONFIG } from "../../shared/runtime-defaults"; import { getRunnerByKind, getSharedRunner } from "../../sandbox/lifecycle"; import { setVmMapEntry } from "./vm-map"; diff --git a/apps/mesh/src/tools/vm/stop.test.ts b/apps/mesh/src/tools/vm/stop.test.ts index 0887764884..1543083845 100644 --- a/apps/mesh/src/tools/vm/stop.test.ts +++ b/apps/mesh/src/tools/vm/stop.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, mock, beforeEach } from "bun:test"; import type { VmMap, VmMapEntry } from "@decocms/mesh-sdk"; import type { MeshContext } from "../../core/mesh-context"; -import type { SandboxRunner } from "mesh-plugin-user-sandbox/runner"; +import type { SandboxRunner } from "@decocms/sandbox/runner"; // Mock per-kind runner lookup BEFORE importing VM_DELETE. const mockDelete = mock(async (_handle: string): Promise => {}); @@ -50,7 +50,7 @@ const FREESTYLE_ENTRY: VmMapEntry = { const DOCKER_ENTRY: VmMapEntry = { vmId: "f9e2fadeb813e08eb00eef6f962be2b2", - previewUrl: "http://f9e2.sandboxes.localhost:7070/", + previewUrl: "http://f9e2.localhost:7070/", runnerKind: "docker", }; diff --git a/apps/mesh/src/tools/vm/stop.ts b/apps/mesh/src/tools/vm/stop.ts index 1b4db473d3..1175384e87 100644 --- a/apps/mesh/src/tools/vm/stop.ts +++ b/apps/mesh/src/tools/vm/stop.ts @@ -5,7 +5,7 @@ */ import { z } from "zod"; -import type { RunnerKind } from "mesh-plugin-user-sandbox/runner"; +import type { RunnerKind } from "@decocms/sandbox/runner"; import { defineTool } from "../../core/define-tool"; import { requireVmEntry } from "./helpers"; import { getRunnerByKind } from "../../sandbox/lifecycle"; diff --git a/apps/mesh/src/web/components/chat/chat-context.tsx b/apps/mesh/src/web/components/chat/chat-context.tsx index 9ae9a2df1f..fb5aebb6a7 100644 --- a/apps/mesh/src/web/components/chat/chat-context.tsx +++ b/apps/mesh/src/web/components/chat/chat-context.tsx @@ -59,6 +59,7 @@ import { toMetadataModelInfo } from "../../lib/metadata-model-info"; import { useChatNavigation } from "./hooks/use-chat-navigation"; import { useStreamManager } from "./hooks/use-stream-manager"; +import { useTaskActions } from "../../hooks/use-tasks"; import { useTaskManager, type TaskOwnerFilter } from "./task"; import { useTaskMessages } from "./task/use-task-manager"; import { derivePartsFromTiptapDoc } from "./derive-parts"; @@ -112,14 +113,14 @@ export interface ChatTaskContextValue { hideTask: (taskId: string) => Promise; renameTask: (taskId: string, title: string) => Promise; setTaskStatus: (taskId: string, status: string) => Promise; - /** Derived from thread.branch, falling back to `?branch=` for fresh threads. */ + /** thread.branch — the only source of truth. Null until the user picks one or the server generates one on first send. */ currentBranch: string | null; /** * Immutable once set: switching branches mid-conversation would reroute the * thread's vmMap entry, so users must create a new thread for another branch. */ isBranchLocked: boolean; - /** Persist pinned branch and sync URL so it survives cross-thread navigation. */ + /** Persist pinned branch onto the thread (cache + server). */ setCurrentTaskBranch: (branch: string | null) => void; ownerFilter: TaskOwnerFilter; setOwnerFilter: (filter: TaskOwnerFilter) => void; @@ -156,8 +157,6 @@ export interface ChatPrefsContextValue { setTiptapDoc: (doc: Metadata["tiptapDoc"]) => void; /** @deprecated Use tiptapDoc directly */ tiptapDocRef: { current: Metadata["tiptapDoc"] }; - /** Set ephemeral per-task agent override. Passing null resets to URL agent. */ - setVirtualMcpId: (id: string | null) => void; /** @deprecated No-op */ resetInteraction: () => void; /** Whether Simple Model Mode is enabled for the org */ @@ -298,11 +297,8 @@ export function ChatContextProvider({ // URL state const { taskId: urlTaskId, - virtualMcpOverride, - branch: urlBranch, + virtualMcpId: urlVirtualMcpId, navigateToTask: rawNavigateToTask, - setVirtualMcpOverride, - setBranch, } = useChatNavigation(); // Preferences @@ -435,8 +431,8 @@ export function ChatContextProvider({ // taskId always comes from the URL (seeded by router's validateSearch) const effectiveTaskId = urlTaskId; - // Effective agent: URL override (ephemeral per-task) ?? path param (thread owner) - const effectiveVirtualMcpId = virtualMcpOverride ?? virtualMcpId; + // Effective agent: URL param ?? prop (thread owner) + const effectiveVirtualMcpId = urlVirtualMcpId; // Single-item fetch for the selected virtual MCP (no full list needed) const selectedVirtualMcpData = useVirtualMCP(effectiveVirtualMcpId); @@ -552,46 +548,66 @@ export function ChatContextProvider({ const clearPendingMessage = () => setPendingMessage(null); - // Atomically syncs URL `?branch=` to thread.branch so the preview iframe - // picks the right vmMap entry on first paint (no flicker through unset-branch). - const navigateToTask = ( - taskId: string, - opts?: { virtualMcpOverride?: string; branch?: string | null }, - ) => { + const navigateToTask = (taskId: string, opts?: { virtualMcpId?: string }) => { markTaskRead(taskId); - const task = tasks.find((t) => t.id === taskId); - const resolvedBranch = - opts?.branch !== undefined ? opts.branch : (task?.branch ?? null); rawNavigateToTask(taskId, { - virtualMcpOverride: opts?.virtualMcpOverride, - branch: resolvedBranch, + virtualMcpId: opts?.virtualMcpId, }); }; - // thread.branch is authoritative; URL `?branch=` is only a seed for fresh tasks. const activeTask = tasks.find((t) => t.id === effectiveTaskId); - const currentBranch = activeTask?.branch ?? urlBranch ?? null; + const currentBranch = activeTask?.branch ?? null; const isBranchLocked = !!activeTask?.branch; - // Create task (optimistic + navigate), returns new task ID + // Create task — calls COLLECTION_THREADS_CREATE up-front with the active + // task's branch so the new thread lands on the same warm sandbox. The + // route loader's useEnsureTask will see the row already exists on its + // GET and skip the create-on-404 fallback. + const taskActions = useTaskActions(); const createTask = (): string => { - const newId = taskManager.createTask(); - navigateToTask(newId); + const newId = crypto.randomUUID(); + void taskActions.create + .mutateAsync({ + id: newId, + virtual_mcp_id: virtualMcpId, + ...(currentBranch ? { branch: currentBranch } : {}), + } as Partial) + .then(() => navigateToTask(newId)) + .catch(() => { + // create error toast already fired by useCollectionActions; navigate + // anyway so the user's not stranded — the route loader's ensure + // fallback will retry. + navigateToTask(newId); + }); return newId; }; - // Create task + queue a pending message for ActiveTaskProvider to consume + // Create task + queue a pending message. Propagates currentBranch only + // when the new task is on the same vMCP (different vMCPs have their own + // vmMap, so carrying a branch across them would land on a cold sandbox). const createTaskWithMessage = (params: { message: SendMessageParams; virtualMcpId?: string; }) => { - const newId = taskManager.createTask(); - navigateToTask(newId, { - virtualMcpOverride: - params.virtualMcpId && params.virtualMcpId !== virtualMcpId - ? params.virtualMcpId - : undefined, - }); + const newId = crypto.randomUUID(); + const targetVmcp = params.virtualMcpId ?? virtualMcpId; + const carryBranch = targetVmcp === virtualMcpId ? currentBranch : null; + void taskActions.create + .mutateAsync({ + id: newId, + virtual_mcp_id: targetVmcp, + ...(carryBranch ? { branch: carryBranch } : {}), + } as Partial) + .then(() => + navigateToTask(newId, { + virtualMcpId: params.virtualMcpId, + }), + ) + .catch(() => { + navigateToTask(newId, { + virtualMcpId: params.virtualMcpId, + }); + }); setPendingMessage({ taskId: newId, message: params.message, @@ -627,9 +643,6 @@ export function ChatContextProvider({ currentBranch, isBranchLocked, setCurrentTaskBranch: (branch: string | null) => { - // URL first so the preview panel picks up the new vmMap entry this render; - // thread persistence follows for subsequent navigations back to this thread. - setBranch(branch); if (effectiveTaskId) { taskManager.setTaskBranch(effectiveTaskId, branch); } @@ -674,7 +687,6 @@ export function ChatContextProvider({ tiptapDoc, setTiptapDoc, tiptapDocRef, - setVirtualMcpId: setVirtualMcpOverride, resetInteraction: () => {}, simpleModeEnabled: simpleMode.enabled, simpleModeTier: activeTier, diff --git a/apps/mesh/src/web/components/chat/hooks/use-chat-navigation.ts b/apps/mesh/src/web/components/chat/hooks/use-chat-navigation.ts index 5e4424eaef..3d9190549b 100644 --- a/apps/mesh/src/web/components/chat/hooks/use-chat-navigation.ts +++ b/apps/mesh/src/web/components/chat/hooks/use-chat-navigation.ts @@ -1,116 +1,45 @@ -/** - * useChatNavigation — URL-driven chat state. - * - * Reads taskId from path params and virtualmcpid from search params. - * virtualMcpId is never null — defaults to the well-known decopilot virtual MCP. - * virtualMcpOverride is an optional search param for ephemeral per-task agent switching. - */ - import { useRef } from "react"; import { getWellKnownDecopilotVirtualMCP } from "@decocms/mesh-sdk"; import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; import { useProjectContext } from "@decocms/mesh-sdk"; export interface ChatNavigation { + /** Resolved vMCP for the current chat — either the URL param or the well-known decopilot. */ virtualMcpId: string; - virtualMcpOverride: string | undefined; - /** Always defined — resolved from the `/$org/$taskId` path param. */ + /** Always defined — `/$org/$taskId` path param, or a stable fallback for routes that don't have it. */ taskId: string; - /** - * Git branch for this thread (from `?branch=` URL search param). Undefined - * when not set; the server only persists it on thread creation. - */ - branch: string | undefined; - navigateToTask: ( - taskId: string, - opts?: { virtualMcpOverride?: string; branch?: string | null }, - ) => void; - setVirtualMcpOverride: (id: string | null) => void; - setBranch: (branch: string | null) => void; + /** Navigate to a task. `virtualMcpId` becomes `?virtualmcpid=` — used as bootstrap for the route loader. */ + navigateToTask: (taskId: string, opts?: { virtualMcpId?: string }) => void; } export function useChatNavigation(): ChatNavigation { const navigate = useNavigate(); const { org } = useProjectContext(); - const search = useSearch({ strict: false }) as { - virtualmcpid?: string; - virtualMcpOverride?: string; - branch?: string; - }; - - const routeParams = useParams({ strict: false }) as { - org?: string; - taskId?: string; - }; + const search = useSearch({ strict: false }) as { virtualmcpid?: string }; + const routeParams = useParams({ strict: false }) as { taskId?: string }; const virtualMcpId = search.virtualmcpid ?? getWellKnownDecopilotVirtualMCP(org.id).id; - const navigateToTask = ( - taskId: string, - opts?: { virtualMcpOverride?: string; branch?: string | null }, - ) => { - // Reset panel state — only preserve virtualmcpid + tasks panel visibility. - // This ensures panel layout defaults kick in for the new task. + const navigateToTask = (taskId: string, opts?: { virtualMcpId?: string }) => { navigate({ to: "/$org/$taskId", params: { org: org.slug, taskId }, search: (prev: Record) => { const next: Record = {}; - if (prev.virtualmcpid) next.virtualmcpid = prev.virtualmcpid; + const vmcp = opts?.virtualMcpId ?? prev.virtualmcpid; + if (vmcp) next.virtualmcpid = vmcp; if (prev.tasks) next.tasks = prev.tasks; - if (opts?.virtualMcpOverride) { - next.virtualMcpOverride = opts.virtualMcpOverride; - } - if (opts?.branch) { - next.branch = opts.branch; - } return next; }, }); }; - const setBranch = (branch: string | null) => { - navigate({ - search: (prev: Record) => { - const next = { ...prev }; - if (branch) { - next.branch = branch; - } else { - delete next.branch; - } - return next; - }, - } as never); - }; - - const setVirtualMcpOverride = (id: string | null) => { - navigate({ - search: (prev: Record) => { - const next = { ...prev }; - if (id) { - next.virtualMcpOverride = id; - } else { - delete next.virtualMcpOverride; - } - return next; - }, - } as never); - }; - // On unified chat routes the taskId is a path param. // On other routes (e.g. settings) Chat.Provider still mounts but taskId is // absent — fall back to a stable generated ID so the provider works everywhere. const fallbackRef = useRef(crypto.randomUUID()); const taskId = routeParams.taskId ?? fallbackRef.current; - return { - virtualMcpId, - virtualMcpOverride: search.virtualMcpOverride, - taskId, - branch: search.branch, - navigateToTask, - setVirtualMcpOverride, - setBranch, - }; + return { virtualMcpId, taskId, navigateToTask }; } diff --git a/apps/mesh/src/web/components/chat/side-panel-chat.tsx b/apps/mesh/src/web/components/chat/side-panel-chat.tsx index aadb86c2be..4ad131a318 100644 --- a/apps/mesh/src/web/components/chat/side-panel-chat.tsx +++ b/apps/mesh/src/web/components/chat/side-panel-chat.tsx @@ -16,10 +16,9 @@ import { Suspense, useState } from "react"; import { ErrorBoundary } from "../error-boundary"; import { Chat } from "./index"; -import { useChatStream, useChatPrefs } from "./context"; +import { useChatStream, useChatPrefs, useChatTask } from "./context"; import { ChatContextPanel } from "./context-panel"; import { wasCreditsEmptyDismissed } from "./credits-empty-state"; -import { useChatNavigation } from "./hooks/use-chat-navigation.ts"; import { BranchPicker } from "../thread/github/branch-picker.tsx"; import { useAiProviderKeys } from "@/web/hooks/collections/use-ai-providers"; @@ -206,7 +205,7 @@ function SidebarEmptyState() { const { org } = useProjectContext(); const { selectedVirtualMcp } = useChatPrefs(); const { data: session } = authClient.useSession(); - const { branch, setBranch } = useChatNavigation(); + const { currentBranch, setCurrentTaskBranch } = useChatTask(); const defaultAgent = getWellKnownDecopilotVirtualMCP(org.id); const displayAgent = selectedVirtualMcp ?? defaultAgent; @@ -242,8 +241,8 @@ function SidebarEmptyState() { owner={githubRepo.owner} repo={githubRepo.name} vmMap={fullVm?.metadata?.vmMap} - value={branch} - onChange={setBranch} + value={currentBranch ?? undefined} + onChange={setCurrentTaskBranch} /> )} diff --git a/apps/mesh/src/web/components/chat/task/cache-operations.ts b/apps/mesh/src/web/components/chat/task/cache-operations.ts index 7a9fb61923..4e95d454a0 100644 --- a/apps/mesh/src/web/components/chat/task/cache-operations.ts +++ b/apps/mesh/src/web/components/chat/task/cache-operations.ts @@ -7,13 +7,6 @@ import { KEYS } from "../../../lib/query-keys"; import type { ChatMessage, Task, TasksQueryData } from "./types.ts"; import { TASK_CONSTANTS } from "./types.ts"; -export interface TaskCacheFilters { - owner: "me" | "automation" | "all"; - status: "open" | "archived"; - virtualMcpId?: string; - userId?: string | null; -} - /** * Update task across every cached task list where it appears. * Returns true if the task was found (and updated) in any cache entry. @@ -41,6 +34,7 @@ export function updateTaskInCache( updated_at: updates.updated_at ?? current.updated_at, hidden: updates.hidden ?? current.hidden, status: updates.status ?? current.status, + branch: "branch" in updates ? updates.branch : current.branch, }; const items = [...data.items]; @@ -52,41 +46,6 @@ export function updateTaskInCache( return found; } -/** - * Add task optimistically to the cache - */ -export function addTaskToCache( - queryClient: QueryClient, - locator: string, - task: Task, - filters: TaskCacheFilters, -): void { - const queryKey = KEYS.tasks(locator, filters); - - const currentData = queryClient.getQueryData(queryKey); - - if (!currentData) { - queryClient.setQueryData(queryKey, { - items: [task], - hasMore: false, - totalCount: 1, - }); - return; - } - - // Check if task already exists in cache - const taskExists = currentData.items.some((t) => t.id === task.id); - if (taskExists) { - return; - } - - queryClient.setQueryData(queryKey, { - ...currentData, - items: [task, ...currentData.items], - totalCount: (currentData.totalCount ?? currentData.items.length) + 1, - }); -} - /** * Update messages cache for a task with new messages * Populates the cache directly without refetching from backend diff --git a/apps/mesh/src/web/components/chat/task/helpers.ts b/apps/mesh/src/web/components/chat/task/helpers.ts index 37b8a34d10..818c6e5989 100644 --- a/apps/mesh/src/web/components/chat/task/helpers.ts +++ b/apps/mesh/src/web/components/chat/task/helpers.ts @@ -38,23 +38,3 @@ export async function callUpdateTaskTool( }); return payload.item; } - -/** - * Build an optimistic task object for immediate cache insertion - */ -export function buildOptimisticTask( - id: string, - virtualMcpId?: string, - branch?: string | null, -): Task { - const now = new Date().toISOString(); - return { - id, - title: "New chat", - status: "completed", - created_at: now, - updated_at: now, - virtual_mcp_id: virtualMcpId, - branch: branch ?? null, - }; -} diff --git a/apps/mesh/src/web/components/chat/task/use-task-manager.ts b/apps/mesh/src/web/components/chat/task/use-task-manager.ts index dcbe37d80d..5efef4ab2f 100644 --- a/apps/mesh/src/web/components/chat/task/use-task-manager.ts +++ b/apps/mesh/src/web/components/chat/task/use-task-manager.ts @@ -18,17 +18,11 @@ import { import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; import { toast } from "sonner"; import { authClient } from "../../../lib/auth-client"; -import { useCollectionCachePrefill } from "../../../hooks/use-collection-cache-prefill"; import { LOCALSTORAGE_KEYS } from "../../../lib/localstorage-keys"; import { KEYS } from "../../../lib/query-keys"; import { useDecopilotEvents } from "../../../hooks/use-decopilot-events"; -import { - addTaskToCache, - updateMessagesCache, - updateTaskInCache, -} from "./cache-operations.ts"; -import { buildOptimisticTask, callUpdateTaskTool } from "./helpers.ts"; -import { useChatNavigation } from "../hooks/use-chat-navigation.ts"; +import { updateMessagesCache, updateTaskInCache } from "./cache-operations.ts"; +import { callUpdateTaskTool } from "./helpers.ts"; import { useState, useTransition } from "react"; import type { ChatMessage, Task } from "./types.ts"; import { TASK_CONSTANTS } from "./types.ts"; @@ -135,9 +129,6 @@ export function useTaskMessages(taskId: string | null) { export function useTaskManager(virtualMcpId: string) { const { locator, org } = useProjectContext(); const queryClient = useQueryClient(); - const { prefillCollectionCache } = useCollectionCachePrefill(); - - const { branch } = useChatNavigation(); const { data: session } = authClient.useSession(); const userId = session?.user?.id; @@ -197,25 +188,6 @@ export function useTaskManager(virtualMcpId: string) { orgId: org.id, }); - // Create task (optimistic + cache) - const createTask = (): string => { - const newTaskId = crypto.randomUUID(); - const optimisticTask = buildOptimisticTask(newTaskId, virtualMcpId, branch); - addTaskToCache(queryClient, locator, optimisticTask, { - owner: ownerFilter, - status: "open", - virtualMcpId, - userId: ownerFilter === "me" ? (userId ?? null) : null, - }); - if (client) { - prefillCollectionCache(client, "THREAD_MESSAGES", org.id, { - filters: [{ column: "thread_id", value: newTaskId }], - pageSize: TASK_CONSTANTS.TASK_MESSAGES_PAGE_SIZE, - }); - } - return newTaskId; - }; - // Update task in cache (across all matching task lists) const updateTask = (taskId: string, updates: Partial) => { updateTaskInCache(queryClient, locator, taskId, updates); @@ -244,19 +216,16 @@ export function useTaskManager(virtualMcpId: string) { } }; - // thread.branch is source of truth for vmMap[userId][branch] resolution, so - // picker changes must land here + URL. No-ops for cache-only threads — the - // branch gets written on first createMemory call. + // Persist the picked branch on the thread row. Server enforces that + // github-linked threads cannot have branch=null; surface that error. const setTaskBranch = async (taskId: string, branch: string | null) => { - updateTaskInCache(queryClient, locator, taskId, { branch }); try { await callUpdateTaskTool(client, taskId, { branch }); + updateTaskInCache(queryClient, locator, taskId, { branch }); } catch (error) { const err = error as Error; - // Fresh thread may not exist server-side yet; cache update is enough. - if (!/not found/i.test(err.message)) { - console.error("[chat] Failed to persist task branch:", error); - } + toast.error(`Failed to update branch: ${err.message}`); + console.error("[chat] setTaskBranch:", error); } }; @@ -330,7 +299,6 @@ export function useTaskManager(virtualMcpId: string) { ownerFilter, setOwnerFilter, isFilterChangePending, - createTask, updateTask, renameTask, hideTask, diff --git a/apps/mesh/src/web/components/sidebar/agents-section.tsx b/apps/mesh/src/web/components/sidebar/agents-section.tsx index 31aeb07813..e0840f04c5 100644 --- a/apps/mesh/src/web/components/sidebar/agents-section.tsx +++ b/apps/mesh/src/web/components/sidebar/agents-section.tsx @@ -1,6 +1,13 @@ import { Suspense, useState, useRef } from "react"; import { createPortal } from "react-dom"; -import { Link, useNavigate, useRouterState } from "@tanstack/react-router"; +import { + Link, + useNavigate, + useParams, + useRouterState, + useSearch, +} from "@tanstack/react-router"; +import { useQueryClient } from "@tanstack/react-query"; import { DndContext, closestCenter, @@ -57,7 +64,6 @@ import { import type { VirtualMCPEntity } from "@decocms/mesh-sdk/types"; import { usePinnedAgents } from "@/web/hooks/use-pinned-agents"; import { useCreateVirtualMCP } from "@/web/hooks/use-create-virtual-mcp"; -import { useCreateTaskAndNavigate } from "@/web/hooks/use-create-task-and-navigate"; import { track } from "@/web/lib/posthog-client"; import { useNavigateToAgent } from "@/web/hooks/use-navigate-to-agent"; import { AgentAvatar } from "@/web/components/agent-icon"; @@ -69,6 +75,47 @@ import { GitHubRepoPicker } from "@/web/components/github-repo-picker.tsx"; import { SiteDiagnosticsRecruitModal } from "@/web/components/home/site-diagnostics-recruit-modal.tsx"; import { StudioPackRecruitModal } from "@/web/components/home/studio-pack-recruit-modal.tsx"; import { LeanCanvasRecruitModal } from "@/web/components/home/lean-canvas-recruit-modal.tsx"; +import { useTaskActions } from "@/web/hooks/use-tasks"; +import { readCachedTaskBranch } from "@/web/lib/read-cached-task-branch"; + +/** + * Hook for sidebar "spawn task on this vMCP" buttons. When the user clicks + * a vMCP that matches the URL's current virtualmcpid, the active task's + * branch is carried into the new thread so the new task lands on the same + * warm sandbox. When the clicked vMCP differs, no branch is passed and the + * server picks the most-recently-touched vmMap entry for that vMCP. + */ +function useNavigateToNewTaskWithBranchCarry(orgSlug: string) { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const taskActions = useTaskActions(); + const { locator } = useProjectContext(); + const params = useParams({ strict: false }) as { taskId?: string }; + const search = useSearch({ strict: false }) as { virtualmcpid?: string }; + + return async (clickedVirtualMcpId: string) => { + const taskId = crypto.randomUUID(); + const carryBranch = + clickedVirtualMcpId === search.virtualmcpid + ? readCachedTaskBranch(queryClient, locator, params.taskId ?? "") + : null; + try { + await taskActions.create.mutateAsync({ + id: taskId, + virtual_mcp_id: clickedVirtualMcpId, + ...(carryBranch ? { branch: carryBranch } : {}), + }); + } catch { + // Toast already fired; navigate anyway so the route loader's + // ensure-fallback can retry. + } + navigate({ + to: "/$org/$taskId", + params: { org: orgSlug, taskId }, + search: { virtualmcpid: clickedVirtualMcpId }, + }); + }; +} function AgentListItem({ agent, org, @@ -82,7 +129,7 @@ function AgentListItem({ }) { const navigate = useNavigate(); const { isMobile, setOpenMobile } = useSidebar(); - const navigateToNewTask = useCreateTaskAndNavigate(); + const navigateToNewTask = useNavigateToNewTaskWithBranchCarry(org); const pathname = useRouterState({ select: (s) => s.location.pathname }); const isActive = pathname.startsWith(`/${org}/${agent.id}`); const [buttonRect, setButtonRect] = useState(null); @@ -311,7 +358,7 @@ function PinAgentPopoverContent({ }); const [preferences] = usePreferences(); - const navigateToNewTask = useCreateTaskAndNavigate(); + const navigateToNewTask = useNavigateToNewTaskWithBranchCarry(org.slug); const navigateToAgent = useNavigateToAgent(); const lowerSearch = search.toLowerCase(); diff --git a/apps/mesh/src/web/components/thread/github/branch-picker.tsx b/apps/mesh/src/web/components/thread/github/branch-picker.tsx index 74ffc5f4fa..5c1b3b4dd6 100644 --- a/apps/mesh/src/web/components/thread/github/branch-picker.tsx +++ b/apps/mesh/src/web/components/thread/github/branch-picker.tsx @@ -16,7 +16,6 @@ import { PopoverTrigger, } from "@deco/ui/components/popover.tsx"; import { GitBranch01 } from "@untitledui/icons"; -import { generateBranchName } from "@/shared/branch-name"; import { useBranches } from "./use-branches"; interface Props { @@ -80,17 +79,7 @@ export function BranchPicker({ align="start" > -
- - -
+ {isError && (
diff --git a/apps/mesh/src/web/components/thread/github/changes-tab.tsx b/apps/mesh/src/web/components/thread/github/changes-tab.tsx index d30f8a0106..57a1389292 100644 --- a/apps/mesh/src/web/components/thread/github/changes-tab.tsx +++ b/apps/mesh/src/web/components/thread/github/changes-tab.tsx @@ -25,16 +25,12 @@ export function ChangesTab({ pr, connectionId, owner, repo }: Props) { }); if (filesQuery.isLoading) { - return ( -
Loading files…
- ); + return
Loading files…
; } if (filesQuery.isError) { return ( -
- Couldn't load file list. -
+
Couldn't load file list.
); } @@ -42,13 +38,13 @@ export function ChangesTab({ pr, connectionId, owner, repo }: Props) { if (files.length === 0) { return ( -
No files changed.
+
No files changed.
); } return ( -
-
+
+
{files.length} file{files.length === 1 ? "" : "s"} changed ·{" "} Loading checks…
- ); + return
Loading checks…
; } if (checksQuery.isError) { return ( -
- Couldn't load check runs. -
+
Couldn't load check runs.
); } @@ -57,14 +53,14 @@ export function ChecksTab({ pr, connectionId, owner, repo }: Props) { if (checks.length === 0) { return ( -
+
No check runs on the PR head commit.
); } return ( -