diff --git a/TERMINOLOGY.md b/TERMINOLOGY.md index 956721779a..caf02a7cf7 100644 --- a/TERMINOLOGY.md +++ b/TERMINOLOGY.md @@ -4,6 +4,8 @@ Canonical words used across Junior's code and documentation. ## Terms +- **Workspace**: a named recipe that selects repositories and setup instructions for a sandbox snapshot. +- **Sandbox**: the live execution environment for a conversation. - **Conversation**: the durable container for visible history and execution state, identified by a globally unique `conversationId`. - **Source**: where an inbound event came from, such as Slack, local CLI, web diff --git a/packages/junior-github/src/plugin.ts b/packages/junior-github/src/plugin.ts index 65575ea31c..ba3d72ce8d 100644 --- a/packages/junior-github/src/plugin.ts +++ b/packages/junior-github/src/plugin.ts @@ -44,6 +44,7 @@ import { configureGit, prepareCommitMsgHook, } from "./git-config.js"; +import { RESERVED_SANDBOX_DIRECTORIES } from "./sandbox-paths.js"; import { CREATE_TOOL_ROUTING_GUIDANCE, GITHUB_APP_ID_ENV, @@ -809,6 +810,54 @@ export function githubPlugin( tools(ctx) { return createGitHubTools(ctx); }, + async workspacePrepare(ctx) { + const repos = ctx.repos.map((entry) => { + const [owner, name, ...rest] = entry.repo.split("/"); + if (!owner || !name || rest.length > 0) { + throw new Error(`Invalid GitHub repository: ${entry.repo}`); + } + if ( + !/^[A-Za-z0-9._-]+$/.test(entry.path) || + entry.path === "." || + entry.path === ".." || + RESERVED_SANDBOX_DIRECTORIES.has(entry.path) + ) { + throw new Error(`Invalid workspace checkout path: ${entry.path}`); + } + return { owner, name, path: entry.path, repo: entry.repo }; + }); + const token = await issueInstallationToken({ + appIdEnv, + privateKeyEnv, + installationIdEnv, + permissions: { contents: "read" }, + repositories: repos.map(({ name }) => name), + }); + for (const { owner, name, path, repo } of repos) { + const result = await ctx.sandbox.run({ + cmd: "git", + args: [ + "clone", + "--quiet", + "--depth=1", + "--", + `https://github.com/${owner}/${name}.git`, + path, + ], + cwd: ctx.sandbox.root, + env: { + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: "http.extraHeader", + GIT_CONFIG_VALUE_0: `Authorization: Bearer ${token.token}`, + }, + }); + if (result.exitCode !== 0) { + throw new Error( + `GitHub workspace clone failed for ${repo}: ${result.stderr.trim() || `exit ${result.exitCode}`}`, + ); + } + } + }, async sandboxPrepare(ctx) { const hooksPath = `${ctx.sandbox.juniorRoot}/git-hooks`; await ctx.sandbox.writeFile({ diff --git a/packages/junior-github/src/sandbox-paths.ts b/packages/junior-github/src/sandbox-paths.ts new file mode 100644 index 0000000000..f950b11a9e --- /dev/null +++ b/packages/junior-github/src/sandbox-paths.ts @@ -0,0 +1,6 @@ +/** Sandbox root directories reserved for Junior runtime material. */ +export const RESERVED_SANDBOX_DIRECTORIES = new Set([ + ".junior", + "data", + "skills", +]); diff --git a/packages/junior-github/src/tools/clone-repository.ts b/packages/junior-github/src/tools/clone-repository.ts index de6683915b..e96d3df910 100644 --- a/packages/junior-github/src/tools/clone-repository.ts +++ b/packages/junior-github/src/tools/clone-repository.ts @@ -6,8 +6,7 @@ import { type ToolRegistrationHookContext, } from "@sentry/junior-plugin-api"; import { z } from "zod"; - -const RESERVED_SANDBOX_DIRECTORIES = new Set([".junior", "data", "skills"]); +import { RESERVED_SANDBOX_DIRECTORIES } from "../sandbox-paths.js"; const inputSchema = z .object({ diff --git a/packages/junior-github/tests/github-plugin.test.ts b/packages/junior-github/tests/github-plugin.test.ts index f51ac6bb38..a00ceb4659 100644 --- a/packages/junior-github/tests/github-plugin.test.ts +++ b/packages/junior-github/tests/github-plugin.test.ts @@ -5,6 +5,7 @@ import { type PluginStoredTokens, type SandboxPrepareHookContext, type ToolRegistrationHookContext, + type WorkspacePrepareHookContext, } from "@sentry/junior-plugin-api"; import { http, HttpResponse } from "msw"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -2749,6 +2750,75 @@ Conversation: \`local:test:old-conversation\` ]); }); + it("preloads workspace repositories with an installation token", async () => { + const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + process.env.GITHUB_APP_ID = "123"; + process.env.GITHUB_INSTALLATION_ID = "456"; + process.env.GITHUB_APP_PRIVATE_KEY = privateKey.export({ + type: "pkcs8", + format: "pem", + }).toString(); + const requests = mockGitHubInstallationApi(); + const runs: Array<{ args?: string[]; env?: Record }> = []; + const ctx = { + db, + log: pluginLog, + plugin: { name: "github" }, + repos: [ + { repo: "getsentry/sentry", path: "sentry" }, + { repo: "getsentry/junior", path: "junior" }, + ], + sandbox: { + juniorRoot: "/vercel/sandbox/.junior", + root: "/vercel/sandbox", + async readFile() { + return null; + }, + async run(input: { args?: string[]; env?: Record }) { + runs.push(input); + return { exitCode: 0, stderr: "", stdout: "" }; + }, + async writeFile() {}, + }, + } as WorkspacePrepareHookContext; + + await githubPlugin().hooks?.workspacePrepare?.(ctx); + + expect(requests[0]?.body).toEqual({ + permissions: { contents: "read" }, + repositories: ["sentry", "junior"], + }); + expect(runs.map((run) => run.args?.at(-1))).toEqual(["sentry", "junior"]); + expect(runs[0]?.env).toMatchObject({ + GIT_CONFIG_VALUE_0: "Authorization: Bearer installation-token", + }); + expect(runs[0]?.args?.join(" ")).not.toContain("installation-token"); + }); + + it("rejects reserved workspace checkout paths", async () => { + const ctx = { + db, + log: pluginLog, + plugin: { name: "github" }, + repos: [{ repo: "getsentry/skills", path: "skills" }], + sandbox: { + juniorRoot: "/vercel/sandbox/.junior", + root: "/vercel/sandbox", + async readFile() { + return null; + }, + async run() { + throw new Error("workspace clone should not start"); + }, + async writeFile() {}, + }, + } as WorkspacePrepareHookContext; + + await expect(githubPlugin().hooks?.workspacePrepare?.(ctx)).rejects.toThrow( + "Invalid workspace checkout path: skills", + ); + }); + it("injects Junior author and committer identity", () => { process.env.GITHUB_APP_BOT_NAME = "sentry-junior[bot]"; process.env.GITHUB_APP_BOT_EMAIL = "bot@example.com"; diff --git a/packages/junior-plugin-api/src/hooks.ts b/packages/junior-plugin-api/src/hooks.ts index 3f7738e3ff..9c6d790d06 100644 --- a/packages/junior-plugin-api/src/hooks.ts +++ b/packages/junior-plugin-api/src/hooks.ts @@ -27,6 +27,7 @@ import type { PluginToolDefinition, SandboxPrepareHookContext, ToolRegistrationHookContext, + WorkspacePrepareHookContext, } from "./tools"; import type { PromptMessage, @@ -82,6 +83,7 @@ export interface PluginHooks { | undefined; routes?(ctx: RouteRegistrationHookContext): PluginRoute[]; sandboxPrepare?(ctx: SandboxPrepareHookContext): Promise | void; + workspacePrepare?(ctx: WorkspacePrepareHookContext): Promise | void; slackConversationLink?( ctx: SlackConversationLinkHookContext, ): SlackConversationLink | undefined; diff --git a/packages/junior-plugin-api/src/tools.ts b/packages/junior-plugin-api/src/tools.ts index 92c8bdd1e9..49c53dda88 100644 --- a/packages/junior-plugin-api/src/tools.ts +++ b/packages/junior-plugin-api/src/tools.ts @@ -140,6 +140,14 @@ export interface PluginMcp { prepare(): Promise<"authorization_pending" | "ready">; } +export interface WorkspacePrepareHookContext extends PluginContext { + repos: Array<{ + path: string; + repo: string; + }>; + sandbox: PluginSandbox; +} + export interface SandboxPrepareHookContext extends PluginContext { actor?: Actor; sandbox: PluginSandbox; diff --git a/packages/junior/migrations/0028_flippant_sunfire.sql b/packages/junior/migrations/0028_flippant_sunfire.sql new file mode 100644 index 0000000000..cedf0dc0ee --- /dev/null +++ b/packages/junior/migrations/0028_flippant_sunfire.sql @@ -0,0 +1,21 @@ +CREATE TABLE "junior_workspace_repos" ( + "workspace_id" text NOT NULL, + "provider" text NOT NULL, + "repo" text NOT NULL, + "checkout_path" text NOT NULL, + "is_primary" boolean DEFAULT false NOT NULL, + CONSTRAINT "junior_workspace_repos_workspace_id_provider_repo_pk" PRIMARY KEY("workspace_id","provider","repo") +); +--> statement-breakpoint +CREATE TABLE "junior_workspaces" ( + "id" text PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "setup_script" text DEFAULT '' NOT NULL, + "created_at" timestamp with time zone NOT NULL, + "updated_at" timestamp with time zone NOT NULL +); +--> statement-breakpoint +ALTER TABLE "junior_workspace_repos" ADD CONSTRAINT "junior_workspace_repos_workspace_id_junior_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."junior_workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "junior_workspace_repos_checkout_path_idx" ON "junior_workspace_repos" USING btree ("workspace_id","checkout_path");--> statement-breakpoint +CREATE UNIQUE INDEX "junior_workspace_repos_primary_idx" ON "junior_workspace_repos" USING btree ("workspace_id") WHERE "junior_workspace_repos"."is_primary";--> statement-breakpoint +CREATE UNIQUE INDEX "junior_workspaces_name_idx" ON "junior_workspaces" USING btree ("name"); \ No newline at end of file diff --git a/packages/junior/migrations/meta/0028_snapshot.json b/packages/junior/migrations/meta/0028_snapshot.json new file mode 100644 index 0000000000..c192fb6ee7 --- /dev/null +++ b/packages/junior/migrations/meta/0028_snapshot.json @@ -0,0 +1,2719 @@ +{ + "id": "b9ebaaca-b4a0-4b33-b4f4-26182d553038", + "prevId": "0027_attachment_storage", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.junior_agent_bindings": { + "name": "junior_agent_bindings", + "schema": "", + "columns": { + "parent_conversation_id": { + "name": "parent_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_conversation_id": { + "name": "child_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_agent_bindings_child_idx": { + "name": "junior_agent_bindings_child_idx", + "columns": [ + { + "expression": "child_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_agent_bindings_parent_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_bindings_parent_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_bindings", + "tableTo": "junior_conversations", + "columnsFrom": [ + "parent_conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_agent_bindings_child_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_bindings_child_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_bindings", + "tableTo": "junior_conversations", + "columnsFrom": [ + "child_conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_agent_bindings_parent_conversation_id_name_pk": { + "name": "junior_agent_bindings_parent_conversation_id_name_pk", + "columns": [ + "parent_conversation_id", + "name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_agent_invocations": { + "name": "junior_agent_invocations", + "schema": "", + "columns": { + "invocation_id": { + "name": "invocation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "parent_conversation_id": { + "name": "parent_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_conversation_id": { + "name": "child_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "input": { + "name": "input", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_json": { + "name": "actor_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "credential_context_json": { + "name": "credential_context_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "source_json": { + "name": "source_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_json": { + "name": "destination_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_visibility": { + "name": "destination_visibility", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reasoning_level": { + "name": "reasoning_level", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mailbox_status": { + "name": "mailbox_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "terminal_at": { + "name": "terminal_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_agent_invocations_child_idx": { + "name": "junior_agent_invocations_child_idx", + "columns": [ + { + "expression": "child_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_agent_invocations_mailbox_idx": { + "name": "junior_agent_invocations_mailbox_idx", + "columns": [ + { + "expression": "mailbox_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_agent_invocations_parent_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_invocations_parent_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_invocations", + "tableTo": "junior_conversations", + "columnsFrom": [ + "parent_conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_agent_invocations_child_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_invocations_child_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_invocations", + "tableTo": "junior_conversations", + "columnsFrom": [ + "child_conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_api_tokens": { + "name": "junior_api_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_email_normalized": { + "name": "owner_email_normalized", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_suffix": { + "name": "token_suffix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_api_tokens_token_hash_uidx": { + "name": "junior_api_tokens_token_hash_uidx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_api_tokens_owner_email_idx": { + "name": "junior_api_tokens_owner_email_idx", + "columns": [ + { + "expression": "owner_email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_attachments": { + "name": "junior_attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "delete_requested_at": { + "name": "delete_requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_attachments_conversation_idx": { + "name": "junior_attachments_conversation_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_attachments_gc_idx": { + "name": "junior_attachments_gc_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "delete_requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_attachments_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_attachments_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_attachments", + "tableTo": "junior_conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_annotations": { + "name": "junior_conversation_annotations", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plugin": { + "name": "plugin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "annotation_json": { + "name": "annotation_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "junior_conversation_annotations_conversation_id_fk": { + "name": "junior_conversation_annotations_conversation_id_fk", + "tableFrom": "junior_conversation_annotations", + "tableTo": "junior_conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_annotations_pk": { + "name": "junior_conversation_annotations_pk", + "columns": [ + "conversation_id", + "plugin", + "kind", + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_bindings": { + "name": "junior_conversation_bindings", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider_destination_id": { + "name": "provider_destination_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_conversation_id": { + "name": "provider_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_conversation_bindings_conversation_idx": { + "name": "junior_conversation_bindings_conversation_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_conversation_bindings_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_bindings_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_bindings", + "tableTo": "junior_conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_bindings_provider_conversation_pk": { + "name": "junior_conversation_bindings_provider_conversation_pk", + "columns": [ + "provider", + "provider_tenant_id", + "provider_destination_id", + "provider_conversation_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_events": { + "name": "junior_conversation_events", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "history_version": { + "name": "history_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "actor_identity_id": { + "name": "actor_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_conversation_events_history_version_idx": { + "name": "junior_conversation_events_history_version_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "history_version", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_events_type_idx": { + "name": "junior_conversation_events_type_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_events_actor_identity_idx": { + "name": "junior_conversation_events_actor_identity_idx", + "columns": [ + { + "expression": "actor_identity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_events_message_search_idx": { + "name": "junior_conversation_events_message_search_idx", + "columns": [ + { + "expression": "to_tsvector('english', \"payload\"->>'text')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_conversation_events\".\"type\" = 'message'", + "concurrently": false, + "method": "gin", + "with": {} + }, + "junior_conversation_events_idempotency_idx": { + "name": "junior_conversation_events_idempotency_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_conversation_events_actor_identity_id_junior_identities_id_fk": { + "name": "junior_conversation_events_actor_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversation_events", + "tableTo": "junior_identities", + "columnsFrom": [ + "actor_identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversation_events_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_events_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_events", + "tableTo": "junior_conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_events_conversation_id_seq_pk": { + "name": "junior_conversation_events_conversation_id_seq_pk", + "columns": [ + "conversation_id", + "seq" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_participants": { + "name": "junior_conversation_participants", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "root_conversation_id": { + "name": "root_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_conversation_participants_user_activity_idx": { + "name": "junior_conversation_participants_user_activity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_message_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_participants_root_idx": { + "name": "junior_conversation_participants_root_idx", + "columns": [ + { + "expression": "root_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_conversation_participants_user_id_junior_users_id_fk": { + "name": "junior_conversation_participants_user_id_junior_users_id_fk", + "tableFrom": "junior_conversation_participants", + "tableTo": "junior_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversation_participants_root_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_participants_root_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_participants", + "tableTo": "junior_conversations", + "columnsFrom": [ + "root_conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_participants_user_root_pk": { + "name": "junior_conversation_participants_user_root_pk", + "columns": [ + "user_id", + "root_conversation_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversations": { + "name": "junior_conversations", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_json": { + "name": "source_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "origin_type": { + "name": "origin_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_id": { + "name": "origin_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_run_id": { + "name": "origin_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destination_id": { + "name": "destination_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destination_json": { + "name": "destination_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "actor_identity_id": { + "name": "actor_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "creator_identity_id": { + "name": "creator_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_subject_identity_id": { + "name": "credential_subject_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_json": { + "name": "actor_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "channel_name": { + "name": "channel_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "execution_updated_at": { + "name": "execution_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "execution_status": { + "name": "execution_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_checkpoint_at": { + "name": "last_checkpoint_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_enqueued_at": { + "name": "last_enqueued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "parent_conversation_id": { + "name": "parent_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "root_conversation_id": { + "name": "root_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transcript_purged_at": { + "name": "transcript_purged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "usage_json": { + "name": "usage_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "execution_duration_ms": { + "name": "execution_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "execution_usage_json": { + "name": "execution_usage_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metric_run_id": { + "name": "metric_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_conversations_last_activity_idx": { + "name": "junior_conversations_last_activity_idx", + "columns": [ + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_active_idx": { + "name": "junior_conversations_active_idx", + "columns": [ + { + "expression": "coalesce(\"execution_updated_at\", \"updated_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_conversations\".\"execution_status\" <> 'idle'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_destination_activity_idx": { + "name": "junior_conversations_destination_activity_idx", + "columns": [ + { + "expression": "destination_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_actor_activity_idx": { + "name": "junior_conversations_actor_activity_idx", + "columns": [ + { + "expression": "actor_identity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_origin_idx": { + "name": "junior_conversations_origin_idx", + "columns": [ + { + "expression": "origin_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_parent_idx": { + "name": "junior_conversations_parent_idx", + "columns": [ + { + "expression": "parent_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_root_idx": { + "name": "junior_conversations_root_idx", + "columns": [ + { + "expression": "root_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_conversations_destination_id_junior_destinations_id_fk": { + "name": "junior_conversations_destination_id_junior_destinations_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_destinations", + "columnsFrom": [ + "destination_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_actor_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_actor_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_identities", + "columnsFrom": [ + "actor_identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_creator_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_creator_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_identities", + "columnsFrom": [ + "creator_identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_credential_subject_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_credential_subject_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_identities", + "columnsFrom": [ + "credential_subject_identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_parent_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversations_parent_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_conversations", + "columnsFrom": [ + "parent_conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_root_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversations_root_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_conversations", + "columnsFrom": [ + "root_conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_destinations": { + "name": "junior_destinations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider_destination_id": { + "name": "provider_destination_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_destination_id": { + "name": "parent_destination_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "metadata_json": { + "name": "metadata_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_destinations_provider_destination_uidx": { + "name": "junior_destinations_provider_destination_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_destination_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_destinations_provider_kind_idx": { + "name": "junior_destinations_provider_kind_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_event_tasks": { + "name": "junior_event_tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "task_json": { + "name": "task_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_event_tasks_team_idx": { + "name": "junior_event_tasks_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_event_tasks_match_idx": { + "name": "junior_event_tasks_match_idx", + "columns": [ + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_identities": { + "name": "junior_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider_subject_id": { + "name": "provider_subject_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata_json": { + "name": "metadata_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_normalized": { + "name": "email_normalized", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "junior_identities_provider_subject_uidx": { + "name": "junior_identities_provider_subject_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_identities_user_idx": { + "name": "junior_identities_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_identities_verified_email_idx": { + "name": "junior_identities_verified_email_idx", + "columns": [ + { + "expression": "email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_identities\".\"email_verified\" = true AND \"junior_identities\".\"email_normalized\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_identities_kind_provider_idx": { + "name": "junior_identities_kind_provider_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_identities_user_id_junior_users_id_fk": { + "name": "junior_identities_user_id_junior_users_id_fk", + "tableFrom": "junior_identities", + "tableTo": "junior_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_location_configurations": { + "name": "junior_location_configurations", + "schema": "", + "columns": { + "location_id": { + "name": "location_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "junior_location_configurations_location_id_junior_destinations_id_fk": { + "name": "junior_location_configurations_location_id_junior_destinations_id_fk", + "tableFrom": "junior_location_configurations", + "tableTo": "junior_destinations", + "columnsFrom": [ + "location_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_location_configurations_location_id_key_pk": { + "name": "junior_location_configurations_location_id_key_pk", + "columns": [ + "location_id", + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_scheduler_runs": { + "name": "junior_scheduler_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scheduled_for_ms": { + "name": "scheduled_for_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "record": { + "name": "record", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_scheduler_runs_task_status_idx": { + "name": "junior_scheduler_runs_task_status_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduled_for_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_scheduler_runs_status_idx": { + "name": "junior_scheduler_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduled_for_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_scheduler_tasks": { + "name": "junior_scheduler_tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "creator_slack_user_id": { + "name": "creator_slack_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "creator_identity_id": { + "name": "creator_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "next_run_at_ms": { + "name": "next_run_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "run_now_at_ms": { + "name": "run_now_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "record": { + "name": "record", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_scheduler_tasks_creator_idx": { + "name": "junior_scheduler_tasks_creator_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "creator_slack_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_scheduler_tasks\".\"status\" <> 'deleted'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_scheduler_tasks_creator_identity_idx": { + "name": "junior_scheduler_tasks_creator_identity_idx", + "columns": [ + { + "expression": "creator_identity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_scheduler_tasks\".\"status\" <> 'deleted' AND \"junior_scheduler_tasks\".\"creator_identity_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_scheduler_tasks_team_status_idx": { + "name": "junior_scheduler_tasks_team_status_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_scheduler_tasks\".\"status\" <> 'deleted'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_scheduler_tasks_run_now_due_idx": { + "name": "junior_scheduler_tasks_run_now_due_idx", + "columns": [ + { + "expression": "run_now_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_scheduler_tasks\".\"status\" = 'active' AND \"junior_scheduler_tasks\".\"run_now_at_ms\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_scheduler_tasks_next_run_due_idx": { + "name": "junior_scheduler_tasks_next_run_due_idx", + "columns": [ + { + "expression": "next_run_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_scheduler_tasks\".\"status\" = 'active' AND \"junior_scheduler_tasks\".\"next_run_at_ms\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_stats": { + "name": "junior_stats", + "schema": "", + "columns": { + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "junior_stats_date_namespace_metric_name_pk": { + "name": "junior_stats_date_namespace_metric_name_pk", + "columns": [ + "date", + "namespace", + "metric", + "name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_task_executions": { + "name": "junior_task_executions", + "schema": "", + "columns": { + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "executed_at_ms": { + "name": "executed_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_task_executions_task_time_idx": { + "name": "junior_task_executions_task_time_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "executed_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_task_executions_time_kind_idx": { + "name": "junior_task_executions_time_kind_idx", + "columns": [ + { + "expression": "executed_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_task_executions_conversation_time_idx": { + "name": "junior_task_executions_conversation_time_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "executed_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_task_executions_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_task_executions_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_task_executions", + "tableTo": "junior_conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_task_executions_kind_namespace_execution_id_pk": { + "name": "junior_task_executions_kind_namespace_execution_id_pk", + "columns": [ + "kind", + "namespace", + "execution_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "junior_task_executions_kind_check": { + "name": "junior_task_executions_kind_check", + "value": "\"junior_task_executions\".\"kind\" in ('scheduled', 'event')" + }, + "junior_task_executions_status_check": { + "name": "junior_task_executions_status_check", + "value": "\"junior_task_executions\".\"status\" in ('blocked', 'completed', 'failed')" + } + }, + "isRLSEnabled": false + }, + "public.junior_users": { + "name": "junior_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "primary_email": { + "name": "primary_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "primary_email_normalized": { + "name": "primary_email_normalized", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_users_primary_email_normalized_uidx": { + "name": "junior_users_primary_email_normalized_uidx", + "columns": [ + { + "expression": "primary_email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_workspace_repos": { + "name": "junior_workspace_repos", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo": { + "name": "repo", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checkout_path": { + "name": "checkout_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "junior_workspace_repos_checkout_path_idx": { + "name": "junior_workspace_repos_checkout_path_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "checkout_path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_workspace_repos_primary_idx": { + "name": "junior_workspace_repos_primary_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"junior_workspace_repos\".\"is_primary\"", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_workspace_repos_workspace_id_junior_workspaces_id_fk": { + "name": "junior_workspace_repos_workspace_id_junior_workspaces_id_fk", + "tableFrom": "junior_workspace_repos", + "tableTo": "junior_workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_workspace_repos_workspace_id_provider_repo_pk": { + "name": "junior_workspace_repos_workspace_id_provider_repo_pk", + "columns": [ + "workspace_id", + "provider", + "repo" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_workspaces": { + "name": "junior_workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "setup_script": { + "name": "setup_script", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_workspaces_name_idx": { + "name": "junior_workspaces_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/junior/migrations/meta/_journal.json b/packages/junior/migrations/meta/_journal.json index 2c40a05950..1b1b888f9d 100644 --- a/packages/junior/migrations/meta/_journal.json +++ b/packages/junior/migrations/meta/_journal.json @@ -197,6 +197,13 @@ "when": 1786555458641, "tag": "0027_attachment_storage", "breakpoints": true + }, + { + "idx": 28, + "version": "7", + "when": 1786569225083, + "tag": "0028_flippant_sunfire", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/packages/junior/src/chat/agent-invocations/work.ts b/packages/junior/src/chat/agent-invocations/work.ts index b19512b66a..71603aa2a4 100644 --- a/packages/junior/src/chat/agent-invocations/work.ts +++ b/packages/junior/src/chat/agent-invocations/work.ts @@ -382,9 +382,9 @@ export function createAgentInvocationWorker(options: { onInputCommitted: acknowledge, shouldYield: context.shouldYield, onSandboxRefChanged: async (nextSandboxRef) => { - sandboxRef = nextSandboxRef; + sandboxRef = nextSandboxRef ?? undefined; await persistThreadStateById(invocation.childConversationId, { - sandboxRef, + sandboxRef: nextSandboxRef, }); }, }, diff --git a/packages/junior/src/chat/agent/sandbox.ts b/packages/junior/src/chat/agent/sandbox.ts index 18bd6a5428..a14e015fec 100644 --- a/packages/junior/src/chat/agent/sandbox.ts +++ b/packages/junior/src/chat/agent/sandbox.ts @@ -23,12 +23,14 @@ import type { RepositoryInstructions } from "@/chat/repository-instructions"; import { createSandbox, type SandboxTools } from "@/chat/sandbox/sandbox"; import type { SandboxWorkspace } from "@/chat/sandbox/workspace"; import type { Skill, SkillMetadata } from "@/chat/skills"; +import type { Workspace } from "@/chat/workspaces/types"; import { writeSandboxGeneratedArtifacts } from "@/chat/tools/sandbox/generated-artifacts"; import type { GeneratedArtifactFileRef } from "@/chat/tools/sandbox/file-uploads"; import { normalizeToolResult } from "@/chat/tool-support/normalize-result"; export interface AgentSandboxOptions { sandboxRef?: SandboxRef; + workspace?: Workspace; skills: SkillMetadata[]; traceContext: LogContext; tracePropagation?: SandboxEgressTracePropagationConfig; @@ -39,8 +41,9 @@ export interface AgentSandboxOptions { configurationValues: Record; getActiveSkill(): Skill | null; prepareSandbox(workspace: SandboxWorkspace): void | Promise; - onSandboxRefChanged(sandboxRef: SandboxRef): void; - persistSandboxRef?(sandboxRef: SandboxRef): void | Promise; + prepareWorkspace?(workspace: SandboxWorkspace, recipe: Workspace): Promise; + onSandboxRefChanged(sandboxRef: SandboxRef | undefined): void; + persistSandboxRef?(sandboxRef: SandboxRef | null): void | Promise; } export interface AgentSandbox { @@ -49,6 +52,7 @@ export interface AgentSandbox { readonly tools: SandboxTools; readonly workspace: SandboxWorkspace; sandboxRef(): SandboxRef | undefined; + switchWorkspace(workspace: Workspace, signal?: AbortSignal): Promise; close(): void; writeGeneratedArtifacts( files: FileUpload[], @@ -139,6 +143,7 @@ function bashCommand(input: unknown): string | undefined { export function createAgentSandbox(options: AgentSandboxOptions): AgentSandbox { const sandbox = createSandbox({ sandboxRef: options.sandboxRef, + workspace: options.workspace, skills: options.skills, referenceFiles: listReferenceFiles(), traceContext: options.traceContext, @@ -146,8 +151,9 @@ export function createAgentSandbox(options: AgentSandboxOptions): AgentSandbox { egressSignals: options.egressSignals, credentialEgress: options.credentialEgress, prepare: options.prepareSandbox, + prepareWorkspace: options.prepareWorkspace, onSandboxRefChanged: async (sandboxRef) => { - options.onSandboxRefChanged(sandboxRef); + options.onSandboxRefChanged(sandboxRef ?? undefined); await options.persistSandboxRef?.(sandboxRef); }, }); @@ -156,6 +162,7 @@ export function createAgentSandbox(options: AgentSandboxOptions): AgentSandbox { captureRepositoryInstructions: sandbox.captureRepositoryInstructions, workspace: sandbox.workspace, sandboxRef: sandbox.sandboxRef, + switchWorkspace: sandbox.switchWorkspace, close: sandbox.close, tools: { supports: sandbox.tools.supports, diff --git a/packages/junior/src/chat/agent/tools.ts b/packages/junior/src/chat/agent/tools.ts index 56a62afc94..52e9b01d04 100644 --- a/packages/junior/src/chat/agent/tools.ts +++ b/packages/junior/src/chat/agent/tools.ts @@ -38,6 +38,8 @@ import { import { createPiAgentTools } from "@/chat/tool-support/pi-tool-adapter"; import { planToolExposure } from "@/chat/tool-exposure"; import type { SandboxRef } from "@/chat/sandbox/ref"; +import { getWorkspace } from "@/chat/workspaces/store"; +import { getDb } from "@/chat/db"; import type { RepositoryInstructions } from "@/chat/repository-instructions"; import { createMcpAuthOrchestration } from "@/chat/services/mcp-auth-orchestration"; import { createPluginAuthOrchestration } from "@/chat/services/plugin-auth-orchestration"; @@ -93,7 +95,7 @@ interface ToolWiringArgs { invokedSkill: SkillMetadata | null; onEvent?: (event: AgentEvent) => void | Promise; onFatalToolError(error: Error): void; - onSandboxRefChanged: (sandboxRef: SandboxRef) => void; + onSandboxRefChanged: (sandboxRef: SandboxRef | undefined) => void; preAgentPromptMessages: () => PiMessage[]; recordConnectedMcpProvider: (provider: string) => Promise; requestHandoff?: ToolRuntimeContext["handoff"]; @@ -221,8 +223,12 @@ export async function wireAgentTools( actor: args.currentActor, actors: args.currentActors, }); + const workspace = args.state.sandboxRef?.workspaceId + ? await getWorkspace(getDb(), args.state.sandboxRef.workspaceId) + : undefined; const agentSandbox = createAgentSandbox({ sandboxRef: args.state.sandboxRef, + workspace, skills: args.availableSkills, traceContext: args.spanContext, tracePropagation: args.run.environment?.sandboxTracePropagation, @@ -233,6 +239,8 @@ export async function wireAgentTools( configurationValues: args.configurationValues, getActiveSkill: () => args.skillSandbox.getActiveSkill(), prepareSandbox: pluginHooks.prepareSandbox, + prepareWorkspace: async (sandbox, recipe) => + await pluginHooks.prepareWorkspace?.(sandbox, recipe.repos), onSandboxRefChanged: args.onSandboxRefChanged, persistSandboxRef: args.durability.onSandboxRefChanged, }); @@ -360,6 +368,10 @@ export async function wireAgentTools( const toolRuntimeContext = { ...commonToolRuntimeContext, ...toolRoute, + workspaces: { + activeWorkspaceId: () => agentSandbox.sandboxRef()?.workspaceId, + switch: agentSandbox.switchWorkspace, + }, attachmentStorage: args.run.environment?.attachmentStorage, } as ToolRuntimeContext; const actionReview = createToolActionReview({ diff --git a/packages/junior/src/chat/agent/types.ts b/packages/junior/src/chat/agent/types.ts index 81cd17b59b..c8418e783b 100644 --- a/packages/junior/src/chat/agent/types.ts +++ b/packages/junior/src/chat/agent/types.ts @@ -157,7 +157,8 @@ export type AgentDurability = { recordPendingAuth?: ( pendingAuth: ConversationPendingAuthState | undefined, ) => void | Promise; - onSandboxRefChanged?: (sandboxRef: SandboxRef) => void | Promise; + /** Persist a replacement sandbox reference; null clears the durable reference. */ + onSandboxRefChanged?: (sandboxRef: SandboxRef | null) => void | Promise; }; /** Best-effort progress events. Failures here never affect the run. */ diff --git a/packages/junior/src/chat/api-turns/work.ts b/packages/junior/src/chat/api-turns/work.ts index c616963b7c..a5699ddca9 100644 --- a/packages/junior/src/chat/api-turns/work.ts +++ b/packages/junior/src/chat/api-turns/work.ts @@ -794,10 +794,10 @@ export function createApiTurnWorker(options: { onInputCommitted: acknowledge, shouldYield: context.shouldYield, onSandboxRefChanged: async (nextSandboxRef) => { - sandboxRef = nextSandboxRef; + sandboxRef = nextSandboxRef ?? undefined; await persistThreadStateById(context.conversationId, { conversation, - sandboxRef, + sandboxRef: nextSandboxRef, }); }, recordPendingAuth: async (pendingAuth) => { diff --git a/packages/junior/src/chat/local/runner.ts b/packages/junior/src/chat/local/runner.ts index 06e6ff7a4d..910c9039be 100644 --- a/packages/junior/src/chat/local/runner.ts +++ b/packages/junior/src/chat/local/runner.ts @@ -374,10 +374,10 @@ async function runLocalAgentTurnInContext( delivery: deliverAssistantMessage, durability: { onSandboxRefChanged: async (nextSandboxRef) => { - sandboxRef = nextSandboxRef; + sandboxRef = nextSandboxRef ?? undefined; await persistThreadStateById(input.conversationId, { conversation, - sandboxRef, + sandboxRef: nextSandboxRef, }); }, recordPendingAuth: async (pendingAuth) => { diff --git a/packages/junior/src/chat/plugins/agent-hooks.ts b/packages/junior/src/chat/plugins/agent-hooks.ts index 2582fe4283..93c26dc66e 100644 --- a/packages/junior/src/chat/plugins/agent-hooks.ts +++ b/packages/junior/src/chat/plugins/agent-hooks.ts @@ -90,6 +90,7 @@ export interface PluginHookRunner { afterMcpTool(input: AfterMcpToolHookInput): Promise; beforeToolExecute(input: ToolHookInput): Promise; prepareSandbox(workspace: SandboxWorkspace): Promise; + prepareWorkspace?(workspace: SandboxWorkspace, repos: Array<{ provider: string; repo: string; checkoutPath: string }>): Promise; } let registeredPlugins: PluginRegistration[] = []; @@ -1309,6 +1310,25 @@ export function createPluginHookRunner( } } }, + async prepareWorkspace(sandbox, repos) { + const sandboxCapability = createSandboxCapability(sandbox); + for (const plugin of loaded) { + const hook = plugin.hooks?.workspacePrepare; + if (!hook) continue; + const selected = repos + .filter((repo) => repo.provider === plugin.manifest.name) + .map((repo) => ({ + path: repo.checkoutPath, + repo: repo.repo, + })); + if (selected.length === 0) continue; + await hook({ + ...basePluginContext(plugin), + repos: selected, + sandbox: sandboxCapability, + }); + } + }, async prepareSandbox(sandbox) { const sandboxCapability = createSandboxCapability(sandbox); for (const plugin of loaded) { diff --git a/packages/junior/src/chat/runtime/thread-state.ts b/packages/junior/src/chat/runtime/thread-state.ts index 021fb3b222..10a81b97f3 100644 --- a/packages/junior/src/chat/runtime/thread-state.ts +++ b/packages/junior/src/chat/runtime/thread-state.ts @@ -35,6 +35,7 @@ function buildThreadStatePayload( payload.app_sandbox_id = patch.sandboxRef?.id ?? ""; payload.app_sandbox_dependency_profile_hash = patch.sandboxRef?.profileHash ?? ""; + payload.app_sandbox_workspace_id = patch.sandboxRef?.workspaceId ?? ""; } return payload; } @@ -74,9 +75,11 @@ export function getPersistedSandboxState( const profileHash = toOptionalString( state.app_sandbox_dependency_profile_hash, ); + const workspaceId = toOptionalString(state.app_sandbox_workspace_id); return { id, ...(profileHash ? { profileHash } : {}), + ...(workspaceId ? { workspaceId } : {}), }; } diff --git a/packages/junior/src/chat/sandbox/README.md b/packages/junior/src/chat/sandbox/README.md index 3f9a52b224..4e26f24e89 100644 --- a/packages/junior/src/chat/sandbox/README.md +++ b/packages/junior/src/chat/sandbox/README.md @@ -8,9 +8,11 @@ traffic through verified host egress. - Sandboxes are ephemeral execution environments associated with a durable conversation or run. -- Runtime state persists only an opaque `SandboxRef` (`id` and dependency - profile hash). The provider adapter maps that reference to Vercel's named - sandbox API; callers do not depend on provider names or VM session ids. +- Runtime state persists only an opaque `SandboxRef` (`id`, dependency profile + hash, and optional workspace id). The provider adapter maps that reference to + Vercel's named sandbox API; callers do not depend on provider names or VM + session ids. If the workspace recipe row is missing, restore still keeps the + durable workspace id and profile hash instead of stripping them. - Each agent run creates lazy sandbox access from the persisted reference. `workspace` serves non-sandbox tools and generated artifacts, while `tools` serves the Pi sandbox tool adapter. The live provider session stays private @@ -45,6 +47,10 @@ traffic through verified host egress. - A deterministic profile hash selects a reusable snapshot. - Snapshot creation installs only the declared dependencies and post-install steps for that profile. +- A workspace profile selects a snapshot that starts from the resolved base + dependency snapshot, then runs repository and setup preparation. +- A workspace snapshot cache key includes the base snapshot id. Rebuilding the + base therefore rebuilds each workspace snapshot on its next use. - Missing or invalid snapshots rebuild through the owning snapshot path; callers do not mutate a cached snapshot in place. - Snapshot state never contains real provider credentials. diff --git a/packages/junior/src/chat/sandbox/ref.ts b/packages/junior/src/chat/sandbox/ref.ts index b04a37a83c..72842163e3 100644 --- a/packages/junior/src/chat/sandbox/ref.ts +++ b/packages/junior/src/chat/sandbox/ref.ts @@ -2,4 +2,5 @@ export interface SandboxRef { id: string; profileHash?: string; + workspaceId?: string; } diff --git a/packages/junior/src/chat/sandbox/sandbox.ts b/packages/junior/src/chat/sandbox/sandbox.ts index 2a3f11a3d3..00b8b446e5 100644 --- a/packages/junior/src/chat/sandbox/sandbox.ts +++ b/packages/junior/src/chat/sandbox/sandbox.ts @@ -47,6 +47,7 @@ import { type SandboxWorkspace, } from "@/chat/sandbox/workspace"; import type { SkillMetadata } from "@/chat/skills"; +import type { Workspace } from "@/chat/workspaces/types"; import { editFile } from "@/chat/tools/sandbox/edit-file"; import { findFiles } from "@/chat/tools/sandbox/find-files"; import { @@ -85,11 +86,13 @@ export interface SandboxAccess { readonly tools: SandboxTools; readonly workspace: SandboxWorkspace; sandboxRef(): SandboxRef | undefined; + switchWorkspace(workspace: Workspace, signal?: AbortSignal): Promise; close(): void; } export interface SandboxOptions { sandboxRef?: SandboxRef; + workspace?: Workspace; skills: SkillMetadata[]; referenceFiles: string[]; timeoutMs?: number; @@ -98,7 +101,8 @@ export interface SandboxOptions { credentialEgress?: CredentialContext; egressSignals?: SandboxEgressSignalTransport; prepare?: (workspace: SandboxWorkspace) => void | Promise; - onSandboxRefChanged?: (sandboxRef: SandboxRef) => void | Promise; + prepareWorkspace?: (workspace: SandboxWorkspace, recipe: Workspace) => Promise; + onSandboxRefChanged?: (sandboxRef: SandboxRef | null) => void | Promise; } interface SandboxToolCallContext { @@ -200,8 +204,10 @@ export function createSandbox(options: SandboxOptions): SandboxAccess { ...(permissionDenied ? { permissionDenied } : {}), }; }; + let activeWorkspace = options.workspace; const runtime = createSandboxRuntime({ sandboxRef: options.sandboxRef, + workspace: options.workspace, skills: options.skills, referenceFiles: options.referenceFiles, timeoutMs: options.timeoutMs, @@ -221,6 +227,7 @@ export function createSandbox(options: SandboxOptions): SandboxAccess { }) : undefined, onSandboxPrepare: options.prepare, + onWorkspacePrepare: options.prepareWorkspace, onSandboxRefChanged: options.onSandboxRefChanged, }); const createToolCallContext = ( @@ -787,7 +794,10 @@ export function createSandbox(options: SandboxOptions): SandboxAccess { return undefined; } const { fs } = await runtime.tools(); - const selected = await findSingleRepositoryDirectory(fs); + const primary = activeWorkspace?.repos.find((repo) => repo.isPrimary); + const selected = primary + ? `${SANDBOX_WORKSPACE_ROOT}/${primary.checkoutPath}` + : await findSingleRepositoryDirectory(fs); if (!selected) return undefined; return await resolveRepositoryInstructions({ cwd: selected, @@ -795,6 +805,10 @@ export function createSandbox(options: SandboxOptions): SandboxAccess { }); }, workspace, + async switchWorkspace(recipe, signal) { + await runtime.switchWorkspace(recipe, signal); + activeWorkspace = recipe; + }, tools: { supports(toolName: string) { return SANDBOX_TOOL_NAMES.has(toolName); diff --git a/packages/junior/src/chat/sandbox/session.ts b/packages/junior/src/chat/sandbox/session.ts index 4fafae6b3a..8579b17e5d 100644 --- a/packages/junior/src/chat/sandbox/session.ts +++ b/packages/junior/src/chat/sandbox/session.ts @@ -17,7 +17,6 @@ import { wrapSandboxSetupError, } from "@/chat/sandbox/errors"; import { buildNonInteractiveShellScript } from "@/chat/sandbox/noninteractive-command"; -import { SANDBOX_WORKSPACE_ROOT } from "@/chat/sandbox/paths"; import { getSandboxResources } from "@/chat/sandbox/resources"; import { hash as profileHash } from "@/chat/sandbox/snapshot/profile"; import { @@ -28,6 +27,7 @@ import { import { syncSkillsToSandbox } from "@/chat/sandbox/skill-sync"; import { createSandboxSession, + stopSession, type SandboxCommandResult, type SandboxFileSystem, type SandboxSession, @@ -35,6 +35,8 @@ import { import { sleep } from "@/chat/sleep"; import type { SkillMetadata } from "@/chat/skills"; import type { SandboxRef } from "@/chat/sandbox/ref"; +import type { Workspace } from "@/chat/workspaces/types"; +import { SANDBOX_WORKSPACE_ROOT } from "@/chat/sandbox/paths"; const DEFAULT_MAX_OUTPUT_LENGTH = 30_000; const DEFAULT_BASH_COMMAND_TIMEOUT_MS = 5 * 60 * 1000; @@ -62,10 +64,7 @@ interface SandboxAcquisition { function sandboxFetchOptions( signal?: AbortSignal, ): { fetch: typeof globalThis.fetch } | Record { - if (!signal) { - return {}; - } - + if (!signal) return {}; return { fetch: (input, init) => { const requestSignal = @@ -103,6 +102,7 @@ interface SandboxToolExecutors { interface SandboxRuntime { sandboxRef(): SandboxRef | undefined; + switchWorkspace(workspace: Workspace, signal?: AbortSignal): Promise; acquire(signal?: AbortSignal): Promise; tools(signal?: AbortSignal): Promise; refreshNetworkPolicy(traceHeaders?: TracePropagationHeaders): Promise; @@ -116,6 +116,7 @@ interface ActiveSandbox { interface SandboxRuntimeOptions { sandboxRef?: SandboxRef; + workspace?: Workspace; skills: SkillMetadata[]; referenceFiles: string[]; timeoutMs?: number; @@ -126,46 +127,34 @@ interface SandboxRuntimeOptions { traceHeaders?: TracePropagationHeaders, ) => NetworkPolicy | undefined; onSandboxPrepare?: (sandbox: SandboxSession) => void | Promise; - onSandboxRefChanged?: (sandboxRef: SandboxRef) => void | Promise; + onWorkspacePrepare?: (sandbox: SandboxSession, workspace: Workspace) => Promise; + onSandboxRefChanged?: (sandboxRef: SandboxRef | null) => void | Promise; } function truncateOutput( output: string, maxLength: number, ): { value: string; truncated: boolean } { - if (output.length <= maxLength) { - return { value: output, truncated: false }; - } - const truncatedLength = output.length - maxLength; + if (output.length <= maxLength) return { value: output, truncated: false }; return { - value: `${output.slice(0, maxLength)}\n\n[output truncated: ${truncatedLength} characters removed]`, + value: `${output.slice(0, maxLength)}\n\n[output truncated: ${output.length - maxLength} characters removed]`, truncated: true, }; } function parseKeepAliveMs(): number { - const parsed = Number.parseInt( - process.env.VERCEL_SANDBOX_KEEPALIVE_MS ?? "0", - 10, - ); + const parsed = Number.parseInt(process.env.VERCEL_SANDBOX_KEEPALIVE_MS ?? "0", 10); return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; } -function getCommandAbortedResult(): { - stdout: string; - stderr: string; - exitCode: number; - stdoutTruncated: boolean; - stderrTruncated: boolean; - aborted: true; -} { +function getCommandAbortedResult() { return { stdout: "", stderr: "Command aborted because the agent turn was cancelled.", exitCode: 130, stdoutTruncated: false, stderrTruncated: false, - aborted: true, + aborted: true as const, }; } @@ -184,7 +173,12 @@ export function createSandboxRuntime( const timeoutMs = options.timeoutMs ?? 1000 * 60 * 30; const traceContext = options.traceContext ?? {}; - const dependencyProfileHash = profileHash(SANDBOX_RUNTIME); + let activeWorkspace = options.workspace; + // Keep the stored workspace profile only when its recipe row is missing. + let dependencyProfileHash = + !activeWorkspace && options.sandboxRef?.workspaceId + ? options.sandboxRef.profileHash + : profileHash(SANDBOX_RUNTIME, activeWorkspace); const resolveCommandEnv = options.commandEnv ?? (async () => ({}) as Record); @@ -195,15 +189,9 @@ export function createSandboxRuntime( callback: () => Promise, ): Promise => withSpan(name, op, traceContext, callback, attributes); - /** Drop unavailable live state while retaining the persisted hint for lazy reacquisition. */ + /** Drop unavailable live state; keep the durable hint for reacquire. */ const invalidateSession = (sessionId?: string): void => { - if ( - sessionId && - activeSandbox && - activeSandbox.session.sessionId !== sessionId - ) { - return; - } + if (sessionId && activeSandbox?.session.sessionId !== sessionId) return; activeSandbox = null; if (keepAliveTimer) { clearTimeout(keepAliveTimer); @@ -218,28 +206,28 @@ export function createSandboxRuntime( onUnavailable: invalidateSession, }); - const createSandboxName = (): string => - `${SANDBOX_NAME_PREFIX}${randomUUID()}`; + const createSandboxName = (): string => `${SANDBOX_NAME_PREFIX}${randomUUID()}`; + // Build once before boot so missing proxy config fails before sandbox work. + // The final route is rebound to the Vercel session id after creation. const preflightNetworkPolicy = ( sandboxName: string, - ): NetworkPolicy | undefined => { - // Build once before boot so missing proxy config fails before sandbox work. - // The final route is rebound to the Vercel session id after creation. - return options.createNetworkPolicy?.(sandboxName); - }; + ): NetworkPolicy | undefined => options.createNetworkPolicy?.(sandboxName); const reportSandboxRef = async ( nextSandbox: SandboxSession, ): Promise => { + const workspaceId = activeWorkspace?.id ?? sandboxRef?.workspaceId; const nextRef: SandboxRef = { id: nextSandbox.sandboxId, ...(dependencyProfileHash ? { profileHash: dependencyProfileHash } : {}), + ...(workspaceId ? { workspaceId } : {}), }; sandboxRef = nextRef; if ( reportedSandboxRef?.id === nextRef.id && - reportedSandboxRef.profileHash === nextRef.profileHash + reportedSandboxRef.profileHash === nextRef.profileHash && + reportedSandboxRef.workspaceId === nextRef.workspaceId ) { return; } @@ -436,12 +424,18 @@ export function createSandboxRuntime( setSpanAttributes({ "app.sandbox.snapshot.rebuild_after_missing": true, }); + const recipe = activeWorkspace; const rebuiltSnapshot = await resolveSnapshot({ runtime, timeoutMs, forceRebuild: true, staleSnapshotId: snapshot.snapshotId, signal, + workspace: recipe, + prepareWorkspace: recipe + ? async (sandbox) => + await prepareWorkspaceSnapshot(sandbox, recipe, signal) + : undefined, }); if (!rebuiltSnapshot.snapshotId) { throw error; @@ -457,6 +451,27 @@ export function createSandboxRuntime( } }; + const prepareWorkspaceSnapshot = async ( + sandbox: SandboxSession, + workspace: Workspace, + signal?: AbortSignal, + ): Promise => { + signal?.throwIfAborted(); + await options.onWorkspacePrepare?.(sandbox, workspace); + if (!workspace.setupScript.trim()) return; + const result = await sandbox.runCommand({ + cmd: "bash", + args: ["-euo", "pipefail", "-c", workspace.setupScript], + cwd: SANDBOX_WORKSPACE_ROOT, + signal, + }); + if (result.exitCode !== 0) { + throw new Error( + `Workspace setup failed: ${result.stderr.trim() || `exit ${result.exitCode}`}`, + ); + } + }; + const createFreshSandbox = async ( signal?: AbortSignal, ): Promise => { @@ -475,10 +490,16 @@ export function createSandboxRuntime( "app.sandbox.runtime": runtime, }, async () => { + const recipe = activeWorkspace; const snapshot = await resolveSnapshot({ runtime, timeoutMs, signal, + workspace: recipe, + prepareWorkspace: recipe + ? async (sandbox) => + await prepareWorkspaceSnapshot(sandbox, recipe, signal) + : undefined, }); signal?.throwIfAborted(); setSnapshotAttributes(snapshot); @@ -502,6 +523,7 @@ export function createSandboxRuntime( networkPolicyKey = await applyNetworkPolicy(createdSandbox); await prepareSandbox(createdSandbox); } catch (error) { + await stopSession(createdSandbox); return failSetup(error); } @@ -509,43 +531,37 @@ export function createSandboxRuntime( }; const discardHintIfProfileChanged = (): void => { + // Missing recipe cannot recompute workspace profile; keep the durable hint. if ( activeSandbox || !sandboxRef || - dependencyProfileHash === sandboxRef.profileHash + dependencyProfileHash === sandboxRef.profileHash || + (!activeWorkspace && sandboxRef.workspaceId) ) { return; } - - setSpanAttributes({ - "app.sandbox.reused": false, - "app.sandbox.recreate.reason": "dependency_profile_mismatch", + const attrs = { ...(sandboxRef.profileHash - ? { - "app.sandbox.previous_profile_hash": sandboxRef.profileHash, - } + ? { "app.sandbox.previous_profile_hash": sandboxRef.profileHash } : {}), ...(dependencyProfileHash ? { "app.sandbox.current_profile_hash": dependencyProfileHash } : {}), + }; + setSpanAttributes({ + "app.sandbox.reused": false, + "app.sandbox.recreate.reason": "dependency_profile_mismatch", + ...attrs, }); logInfo("sandbox.hint.discarded", { "app.decision.reason": "dependency_profile_mismatch", - ...(sandboxRef.profileHash - ? { - "app.sandbox.previous_profile_hash": sandboxRef.profileHash, - } - : {}), - ...(dependencyProfileHash - ? { "app.sandbox.current_profile_hash": dependencyProfileHash } - : {}), + ...attrs, }); sandboxRef = undefined; }; - const tryReuseCachedSandbox = async (): Promise => { - return activeSandbox?.session ?? null; - }; + const tryReuseCachedSandbox = (): SandboxSession | null => + activeSandbox?.session ?? null; const tryRestoreHintedSandbox = async ( signal?: AbortSignal, @@ -594,6 +610,7 @@ export function createSandboxRuntime( await prepareSandbox(hintedSandbox); return rememberSandbox(hintedSandbox, networkPolicyKey); } catch (error) { + // Keep the durable VM alive so a later reacquire can reuse it. if (isSandboxUnavailableError(error)) { throw error; } @@ -721,39 +738,27 @@ export function createSandboxRuntime( }; }; - const extendKeepAlive = async ( - activeSandbox: SandboxSession, - ): Promise => { + const extendKeepAlive = async (session: SandboxSession): Promise => { const keepAliveMs = parseKeepAliveMs(); - if (keepAliveMs === 0) { - return; - } - + if (keepAliveMs === 0) return; try { await withSandboxSpan( "sandbox.keepalive.extend", "sandbox.keepalive", - { - "app.sandbox.keepalive_ms": keepAliveMs, - }, + { "app.sandbox.keepalive_ms": keepAliveMs }, async () => { - await activeSandbox.extendTimeout(keepAliveMs); + await session.extendTimeout(keepAliveMs); }, ); } catch (error) { - if (isSandboxUnavailableError(error)) { - throw error; - } + if (isSandboxUnavailableError(error)) throw error; // Non-lifecycle keepalive failures are best effort. } }; const startKeepAlive = (session: SandboxSession): void => { const keepAliveMs = parseKeepAliveMs(); - if (keepAliveMs === 0 || closed || keepAliveTimer) { - return; - } - + if (keepAliveMs === 0 || closed || keepAliveTimer) return; const intervalMs = Math.max( MIN_KEEPALIVE_INTERVAL_MS, Math.min(MAX_KEEPALIVE_INTERVAL_MS, Math.floor(keepAliveMs / 2)), @@ -761,18 +766,14 @@ export function createSandboxRuntime( const schedule = (): void => { keepAliveTimer = setTimeout(async () => { keepAliveTimer = undefined; - if (closed || activeSandbox?.session !== session) { - return; - } + if (closed || activeSandbox?.session !== session) return; try { await extendKeepAlive(session); } catch { invalidateSession(session.sessionId); return; } - if (closed || activeSandbox?.session !== session) { - return; - } + if (closed || activeSandbox?.session !== session) return; schedule(); }, intervalMs); keepAliveTimer.unref?.(); @@ -864,20 +865,115 @@ export function createSandboxRuntime( const ensureReadySandbox = async ( signal?: AbortSignal, + onAcquired?: (session: SandboxSession) => void, ): Promise => { - const activeSandbox = await getOrAcquireSandbox(signal); + const session = await getOrAcquireSandbox(signal); + onAcquired?.(session); signal?.throwIfAborted(); - await probeSession(activeSandbox); + await probeSession(session); signal?.throwIfAborted(); - await extendKeepAlive(activeSandbox); - startKeepAlive(activeSandbox); - return activeSandbox; + await extendKeepAlive(session); + startKeepAlive(session); + return session; }; return { sandboxRef() { return sandboxRef ? { ...sandboxRef } : undefined; }, + async switchWorkspace(workspace, signal) { + signal?.throwIfAborted(); + const nextProfileHash = profileHash(SANDBOX_RUNTIME, workspace); + // Same recipe is a no-op even when cold. + if ( + activeWorkspace?.id === workspace.id && + dependencyProfileHash === nextProfileHash + ) { + return; + } + + const previous = { + workspace: activeWorkspace, + profileHash: dependencyProfileHash, + sandboxRef, + reportedSandboxRef, + sandbox: activeSandbox, + }; + // Point at the target first so concurrent re-acquire uses it. + activeWorkspace = workspace; + dependencyProfileHash = nextProfileHash; + sandboxRef = undefined; + + const inFlight = acquiringSandbox; + if (inFlight) { + acquiringSandbox = undefined; + inFlight.controller.abort( + signal?.aborted ? signal.reason : new Error("workspace switch"), + ); + try { + await inFlight.promise; + } catch { + // The aborted acquisition is expected to reject. + } + } + + // Detach without stopping previous yet so mid-switch cancel can restore it. + const late = activeSandbox; + invalidateSession(); + if (late && late !== previous.sandbox) { + await stopSession(late.session); + sandboxRef = reportedSandboxRef = undefined; + } + let replacement: SandboxSession | undefined; + try { + await ensureReadySandbox(signal, (s) => { + replacement = s; + }); + } catch (error) { + // Cancelled/failed ready may leave a create finishing in the background. + const leftover = acquiringSandbox; + if (leftover) { + acquiringSandbox = undefined; + leftover.controller.abort(signal?.reason ?? error); + try { + await leftover.promise; + } catch { + // Expected when the replacement boot is aborted. + } + } + const failed = activeSandbox?.session ?? replacement; + const reportedNew = reportedSandboxRef !== previous.reportedSandboxRef; + activeWorkspace = previous.workspace; + dependencyProfileHash = previous.profileHash; + invalidateSession(); + await stopSession(failed); + if (previous.sandbox) { + activeSandbox = previous.sandbox; + sandboxRef = previous.sandboxRef; + reportedSandboxRef = previous.reportedSandboxRef; + startKeepAlive(previous.sandbox.session); + if (reportedNew) { + try { + await options.onSandboxRefChanged?.(previous.sandboxRef ?? null); + } catch { + // Keep the original switch error. + } + } + } else if (late || failed || reportedNew) { + sandboxRef = reportedSandboxRef = undefined; + try { + await options.onSandboxRefChanged?.(null); + } catch { + // Keep the original switch error. + } + } else { + sandboxRef = previous.sandboxRef; + reportedSandboxRef = previous.reportedSandboxRef; + } + throw error; + } + await stopSession(previous.sandbox?.session); + }, async acquire(signal) { return await getOrAcquireSandbox(signal); }, diff --git a/packages/junior/src/chat/sandbox/snapshot/profile.ts b/packages/junior/src/chat/sandbox/snapshot/profile.ts index 47477ee378..bef7fa5977 100644 --- a/packages/junior/src/chat/sandbox/snapshot/profile.ts +++ b/packages/junior/src/chat/sandbox/snapshot/profile.ts @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import type { Workspace } from "@/chat/workspaces/types"; import { pluginCatalogRuntime } from "@/chat/plugins/catalog-runtime"; import type { PluginRuntimeDependency, @@ -18,6 +19,12 @@ export type Profile = { floating: boolean; dependencies: PluginRuntimeDependency[]; postinstall: PluginRuntimePostinstallCommand[]; + /** + * When set, this profile extends a base dependency snapshot instead of + * installing dependencies itself. The hash includes the base hash so base + * busts also bust workspace snapshots. + */ + baseHash?: string; }; function isExactNpmVersion(version: string): boolean { @@ -77,8 +84,32 @@ function floatingMaxAgeMs(): number { : DEFAULT_FLOATING_MAX_AGE_MS; } -/** Build the dependency profile that selects a reusable sandbox snapshot. */ -export function create(runtime: string): Profile | null { +function workspaceRecipe(workspace: Workspace) { + // Sort repos so profile hashes stay stable when query order differs. + // Omit isPrimary: it only selects AGENTS.md at runtime, not snapshot contents. + const repos = [...workspace.repos] + .map(({ provider, repo, checkoutPath }) => ({ + provider, + repo, + checkoutPath, + })) + .sort((left, right) => { + const provider = left.provider.localeCompare(right.provider); + if (provider !== 0) return provider; + const repo = left.repo.localeCompare(right.repo); + if (repo !== 0) return repo; + return left.checkoutPath.localeCompare(right.checkoutPath); + }); + return { + id: workspace.id, + updatedAt: workspace.updatedAt.toISOString(), + repos, + setupScript: workspace.setupScript, + }; +} + +/** Build the base dependency profile without workspace contents. */ +function createBase(runtime: string): Profile | null { const dependencies = mergeDependencies([ ...GLOBAL_RUNTIME_DEPENDENCIES, ...pluginCatalogRuntime.getRuntimeDependencies(), @@ -116,9 +147,48 @@ export function create(runtime: string): Profile | null { }; } +/** + * Build the dependency profile that selects a reusable sandbox snapshot. + * + * Workspace profiles extend the base dependency profile: their hash includes + * the base hash plus the workspace recipe, and build boots from the base + * snapshot instead of reinstalling dependencies. + */ +export function create(runtime: string, workspace?: Workspace): Profile | null { + const base = createBase(runtime); + if (!workspace) { + return base; + } + + const rebuildEpoch = process.env.SANDBOX_SNAPSHOT_REBUILD_EPOCH?.trim() ?? ""; + const baseHash = base?.hash; + const hash = createHash("sha256") + .update( + JSON.stringify({ + version: VERSION, + kind: "workspace", + runtime, + rebuildEpoch, + baseHash: baseHash ?? null, + workspace: workspaceRecipe(workspace), + }), + ) + .digest("hex"); + + return { + hash, + // Preserve base dependency count for telemetry; install work is skipped. + dependencyCount: base?.dependencyCount ?? 0, + floating: true, + dependencies: [], + postinstall: [], + ...(baseHash ? { baseHash } : {}), + }; +} + /** Return the current dependency profile hash without building its snapshot. */ -export function hash(runtime: string): string | undefined { - return create(runtime)?.hash; +export function hash(runtime: string, workspace?: Workspace): string | undefined { + return create(runtime, workspace)?.hash; } /** Decide whether a cached snapshot has outlived a floating profile. */ diff --git a/packages/junior/src/chat/sandbox/snapshot/resolve.ts b/packages/junior/src/chat/sandbox/snapshot/resolve.ts index 040041ce40..5f37ce820c 100644 --- a/packages/junior/src/chat/sandbox/snapshot/resolve.ts +++ b/packages/junior/src/chat/sandbox/snapshot/resolve.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { Sandbox } from "@vercel/sandbox"; import { z } from "zod"; import { getVercelSandboxCredentials } from "@/chat/sandbox/credentials"; @@ -5,8 +6,9 @@ import { getSandboxResources } from "@/chat/sandbox/resources"; import * as install from "@/chat/sandbox/snapshot/install"; import * as profile from "@/chat/sandbox/snapshot/profile"; import { trace } from "@/chat/sandbox/snapshot/span"; -import { createSandboxSession } from "@/chat/sandbox/workspace"; +import { createSandboxSession, type SandboxSession } from "@/chat/sandbox/workspace"; import { sleep } from "@/chat/sleep"; +import type { Workspace } from "@/chat/workspaces/types"; import { getStateAdapter } from "@/chat/state/adapter"; // Snapshot resolution owns cache and lock coordination. Profile selection and @@ -64,21 +66,42 @@ type LockResult = { source: "cache_hit" | "cache_hit_after_lock_wait" | "built"; }; +type ResolveParams = { + runtime: string; + timeoutMs: number; + forceRebuild?: boolean; + staleSnapshotId?: string; + onProgress?: (phase: ProgressPhase) => void | Promise; + signal?: AbortSignal; + workspace?: Workspace; + prepareWorkspace?: (sandbox: SandboxSession) => Promise; +}; + function profileCacheKey(profileHash: string): string { return `${SNAPSHOT_CACHE_PREFIX}:${profileHash}`; } -function profileLockKey(profileHash: string): string { - return `${SNAPSHOT_LOCK_PREFIX}:${profileHash}`; +function profileLockKey(cacheIdentity: string): string { + return `${SNAPSHOT_LOCK_PREFIX}:${cacheIdentity}`; +} + +function workspaceCacheIdentity( + profileHash: string, + baseSnapshotId: string, +): string { + return createHash("sha256") + .update(JSON.stringify({ profileHash, baseSnapshotId })) + .digest("hex"); } /** Read one cached snapshot pointer; only a missing key is a cache miss. */ async function getCachedSnapshot( - profileHash: string, + cacheIdentity: string, + profileHash = cacheIdentity, ): Promise { const state = getStateAdapter(); await state.connect(); - const raw = await state.get(profileCacheKey(profileHash)); + const raw = await state.get(profileCacheKey(cacheIdentity)); if (typeof raw !== "string") { return null; } @@ -96,21 +119,93 @@ async function getCachedSnapshot( } /** Persist one dependency profile's reusable snapshot pointer. */ -async function setCachedSnapshot(entry: CachedSnapshot): Promise { +async function setCachedSnapshot( + cacheIdentity: string, + entry: CachedSnapshot, +): Promise { const state = getStateAdapter(); await state.connect(); await state.set( - profileCacheKey(entry.profileHash), + profileCacheKey(cacheIdentity), JSON.stringify(entry), SNAPSHOT_CACHE_TTL_MS, ); } -async function build( +async function createBuildSandbox(params: { + runtime: string; + timeoutMs: number; + signal?: AbortSignal; + sourceSnapshotId?: string; +}): Promise { + const sandboxCredentials = getVercelSandboxCredentials(); + const resources = getSandboxResources(); + if (params.sourceSnapshotId) { + return createSandboxSession( + await Sandbox.create({ + timeout: params.timeoutMs, + signal: params.signal, + source: { + type: "snapshot", + snapshotId: params.sourceSnapshotId, + }, + ...(sandboxCredentials ?? {}), + ...(resources ? { resources } : {}), + }), + ); + } + + return createSandboxSession( + await Sandbox.create({ + timeout: params.timeoutMs, + runtime: params.runtime, + signal: params.signal, + ...(sandboxCredentials ?? {}), + ...(resources ? { resources } : {}), + }), + ); +} + +async function captureSnapshot( + sandbox: SandboxSession, + dependencyCount: number, + signal?: AbortSignal, +): Promise { + return await trace( + "sandbox.snapshot.capture", + "sandbox.snapshot.capture", + { + "app.sandbox.snapshot.dependency_count": dependencyCount, + }, + async () => { + const snapshot = await sandbox.snapshot({ signal }); + return snapshot.snapshotId; + }, + ); +} + +async function withBuildSandbox( + sandbox: SandboxSession, + callback: (sandbox: SandboxSession) => Promise, +): Promise { + try { + return await callback(sandbox); + } finally { + try { + await sandbox.stop(); + } catch { + // Snapshot creation may already finalize the sandbox; cleanup stays best-effort. + } + } +} + +/** Install dependencies into a fresh runtime sandbox and capture a snapshot. */ +async function buildBase( value: profile.Profile, runtime: string, timeoutMs: number, signal?: AbortSignal, + prepare?: (sandbox: SandboxSession) => Promise, ): Promise { return await trace( "sandbox.snapshot.build", @@ -118,47 +213,82 @@ async function build( { "app.sandbox.runtime": runtime, "app.sandbox.snapshot.dependency_count": value.dependencyCount, + "app.sandbox.snapshot.build_mode": "base", }, async () => { - const sandboxCredentials = getVercelSandboxCredentials(); - const resources = getSandboxResources(); - const sandbox = createSandboxSession( - await Sandbox.create({ - timeout: timeoutMs, - runtime, - signal, - ...(sandboxCredentials ?? {}), - ...(resources ? { resources } : {}), - }), - ); + const sandbox = await createBuildSandbox({ + runtime, + timeoutMs, + signal, + }); + return await withBuildSandbox(sandbox, async (active) => { + await install.dependencies(active, value.dependencies, signal); + await install.postinstall(active, value.postinstall, signal); + await prepare?.(active); + return await captureSnapshot(active, value.dependencyCount, signal); + }); + }, + ); +} +class MissingBaseSnapshotError extends Error { + constructor( + readonly snapshotId: string, + cause: unknown, + ) { + super(`Base sandbox snapshot not found: ${snapshotId}`, { cause }); + this.name = "MissingBaseSnapshotError"; + } +} + +/** Boot from a base snapshot, run workspace prepare, and capture the result. */ +async function buildWorkspaceFromBase(params: { + value: profile.Profile; + runtime: string; + timeoutMs: number; + baseSnapshotId: string; + signal?: AbortSignal; + prepare?: (sandbox: SandboxSession) => Promise; +}): Promise { + return await trace( + "sandbox.snapshot.build", + "sandbox.snapshot.build", + { + "app.sandbox.runtime": params.runtime, + "app.sandbox.snapshot.dependency_count": params.value.dependencyCount, + "app.sandbox.snapshot.build_mode": "workspace_extend", + "app.sandbox.snapshot.base_hash": params.value.baseHash, + }, + async () => { + let sandbox: SandboxSession; try { - await install.dependencies(sandbox, value.dependencies, signal); - await install.postinstall(sandbox, value.postinstall, signal); - return await trace( - "sandbox.snapshot.capture", - "sandbox.snapshot.capture", - { - "app.sandbox.snapshot.dependency_count": value.dependencyCount, - }, - async () => { - const snapshot = await sandbox.snapshot({ signal }); - return snapshot.snapshotId; - }, - ); - } finally { - try { - await sandbox.stop(); - } catch { - // Snapshot creation may already finalize the sandbox; cleanup stays best-effort. + sandbox = await createBuildSandbox({ + runtime: params.runtime, + timeoutMs: params.timeoutMs, + signal: params.signal, + sourceSnapshotId: params.baseSnapshotId, + }); + } catch (error) { + if (isMissingError(error)) { + throw new MissingBaseSnapshotError(params.baseSnapshotId, error); } + throw error; } + return await withBuildSandbox(sandbox, async (active) => { + await params.prepare?.(active); + return await captureSnapshot( + active, + params.value.dependencyCount, + params.signal, + ); + }); }, ); } /** Run one profile build under a timeout-buffered lock or reuse its result. */ async function withBuildLock( + cacheIdentity: string, profileHash: string, timeoutMs: number, callback: () => Promise<{ @@ -172,7 +302,7 @@ async function withBuildLock( signal?.throwIfAborted(); const state = getStateAdapter(); await state.connect(); - const lockKey = profileLockKey(profileHash); + const lockKey = profileLockKey(cacheIdentity); const lockTtlMs = timeoutMs + SNAPSHOT_BUILD_LOCK_BUFFER_MS; const tryAcquireLock = async () => await state.acquireLock(lockKey, lockTtlMs); @@ -198,7 +328,7 @@ async function withBuildLock( Date.now() + lockTtlMs + SNAPSHOT_WAIT_FOR_LOCK_BUFFER_MS; while (Date.now() < waitUntil) { signal?.throwIfAborted(); - const cached = await getCachedSnapshot(profileHash); + const cached = await getCachedSnapshot(cacheIdentity, profileHash); if (cached?.snapshotId && canUseCachedSnapshot(cached)) { return { snapshotId: cached.snapshotId, @@ -226,7 +356,7 @@ async function withBuildLock( } signal?.throwIfAborted(); - const cached = await getCachedSnapshot(profileHash); + const cached = await getCachedSnapshot(cacheIdentity, profileHash); if (cached?.snapshotId && canUseCachedSnapshot(cached)) { return { snapshotId: cached.snapshotId, @@ -267,26 +397,170 @@ function getRebuildReason(params: { return undefined; } +async function resolveProfile( + params: ResolveParams, + currentProfile: profile.Profile, + options: { + build?: () => Promise; + cacheIdentity?: string; + } = {}, +): Promise { + const cacheIdentity = options.cacheIdentity ?? currentProfile.hash; + const cached = await getCachedSnapshot(cacheIdentity, currentProfile.hash); + const cachedNeedsRebuild = Boolean( + cached?.snapshotId && + profile.isStale(currentProfile, cached.createdAtMs), + ); + + if (!params.forceRebuild && cached?.snapshotId && !cachedNeedsRebuild) { + await params.onProgress?.("cache_hit"); + return { + snapshotId: cached.snapshotId, + profileHash: currentProfile.hash, + dependencyCount: currentProfile.dependencyCount, + cacheHit: true, + resolveOutcome: "cache_hit", + }; + } + + const rebuildReason = getRebuildReason({ + forceRebuild: params.forceRebuild, + staleSnapshotId: params.staleSnapshotId, + cached, + shouldRebuildCached: cachedNeedsRebuild, + }); + + const canUseCachedSnapshot = (candidate: CachedSnapshot): boolean => { + if (params.forceRebuild) { + if (params.staleSnapshotId) { + return candidate.snapshotId !== params.staleSnapshotId; + } + // Force rebuild requests should ignore snapshots that existed before this + // call but can reuse a fresh snapshot produced by a concurrent builder. + return candidate.snapshotId !== cached?.snapshotId; + } + return !profile.isStale(currentProfile, candidate.createdAtMs); + }; + + const lockResult = await withBuildLock( + cacheIdentity, + currentProfile.hash, + params.timeoutMs, + async () => { + const latest = await getCachedSnapshot( + cacheIdentity, + currentProfile.hash, + ); + if (latest?.snapshotId && canUseCachedSnapshot(latest)) { + await params.onProgress?.("cache_hit"); + return { + snapshotId: latest.snapshotId, + source: "cache_hit", + }; + } + + await params.onProgress?.("building_snapshot"); + const nextSnapshotId = options.build + ? await options.build() + : await buildBase( + currentProfile, + params.runtime, + params.timeoutMs, + params.signal, + params.prepareWorkspace, + ); + await setCachedSnapshot(cacheIdentity, { + profileHash: currentProfile.hash, + snapshotId: nextSnapshotId, + runtime: params.runtime, + createdAtMs: Date.now(), + dependencyCount: currentProfile.dependencyCount, + }); + await params.onProgress?.("build_complete"); + return { snapshotId: nextSnapshotId, source: "built" }; + }, + canUseCachedSnapshot, + async () => { + await params.onProgress?.("waiting_for_lock"); + }, + params.signal, + ); + + return { + snapshotId: lockResult.snapshotId, + profileHash: currentProfile.hash, + dependencyCount: currentProfile.dependencyCount, + cacheHit: lockResult.source !== "built", + resolveOutcome: toResolveOutcome( + Boolean(params.forceRebuild), + lockResult.source, + ), + ...(rebuildReason ? { rebuildReason } : {}), + }; +} + +async function resolveWorkspaceProfile( + params: ResolveParams, + currentProfile: profile.Profile, +): Promise { + let staleBaseSnapshotId: string | undefined; + for (let attempt = 0; attempt < 2; attempt += 1) { + const baseSnapshot = await resolve({ + runtime: params.runtime, + timeoutMs: params.timeoutMs, + ...(staleBaseSnapshotId + ? { + forceRebuild: true, + staleSnapshotId: staleBaseSnapshotId, + } + : {}), + signal: params.signal, + onProgress: params.onProgress, + }); + if (!baseSnapshot.snapshotId) { + throw new Error("Workspace profile requires a base sandbox snapshot"); + } + + try { + return await resolveProfile(params, currentProfile, { + cacheIdentity: workspaceCacheIdentity( + currentProfile.hash, + baseSnapshot.snapshotId, + ), + build: async () => + await buildWorkspaceFromBase({ + value: currentProfile, + runtime: params.runtime, + timeoutMs: params.timeoutMs, + baseSnapshotId: baseSnapshot.snapshotId!, + signal: params.signal, + prepare: params.prepareWorkspace, + }), + }); + } catch (error) { + if (attempt > 0 || !(error instanceof MissingBaseSnapshotError)) { + throw error; + } + staleBaseSnapshotId = error.snapshotId; + } + } + throw new Error("Failed to resolve workspace sandbox snapshot"); +} + /** Resolve or build the reusable snapshot for the current dependency profile. */ -export async function resolve(params: { - runtime: string; - timeoutMs: number; - forceRebuild?: boolean; - staleSnapshotId?: string; - onProgress?: (phase: ProgressPhase) => void | Promise; - signal?: AbortSignal; -}): Promise { +export async function resolve(params: ResolveParams): Promise { return await trace( "sandbox.snapshot.resolve", "sandbox.snapshot.resolve", { "app.sandbox.runtime": params.runtime, "app.sandbox.snapshot.force_rebuild": Boolean(params.forceRebuild), + "app.sandbox.snapshot.has_workspace": Boolean(params.workspace), }, async () => { params.signal?.throwIfAborted(); await params.onProgress?.("resolve_start"); - const currentProfile = profile.create(params.runtime); + const currentProfile = profile.create(params.runtime, params.workspace); if (!currentProfile) { return { dependencyCount: 0, @@ -295,90 +569,9 @@ export async function resolve(params: { }; } - const cached = await getCachedSnapshot(currentProfile.hash); - const cachedNeedsRebuild = Boolean( - cached?.snapshotId && - profile.isStale(currentProfile, cached.createdAtMs), - ); - - if (!params.forceRebuild && cached?.snapshotId && !cachedNeedsRebuild) { - await params.onProgress?.("cache_hit"); - return { - snapshotId: cached.snapshotId, - profileHash: currentProfile.hash, - dependencyCount: currentProfile.dependencyCount, - cacheHit: true, - resolveOutcome: "cache_hit", - }; - } - - const rebuildReason = getRebuildReason({ - forceRebuild: params.forceRebuild, - staleSnapshotId: params.staleSnapshotId, - cached, - shouldRebuildCached: cachedNeedsRebuild, - }); - - const canUseCachedSnapshot = (candidate: CachedSnapshot): boolean => { - if (params.forceRebuild) { - if (params.staleSnapshotId) { - return candidate.snapshotId !== params.staleSnapshotId; - } - // Force rebuild requests should ignore snapshots that existed before this - // call but can reuse a fresh snapshot produced by a concurrent builder. - return candidate.snapshotId !== cached?.snapshotId; - } - return !profile.isStale(currentProfile, candidate.createdAtMs); - }; - - const lockResult = await withBuildLock( - currentProfile.hash, - params.timeoutMs, - async () => { - const latest = await getCachedSnapshot(currentProfile.hash); - if (latest?.snapshotId && canUseCachedSnapshot(latest)) { - await params.onProgress?.("cache_hit"); - return { - snapshotId: latest.snapshotId, - source: "cache_hit", - }; - } - - await params.onProgress?.("building_snapshot"); - const nextSnapshotId = await build( - currentProfile, - params.runtime, - params.timeoutMs, - params.signal, - ); - await setCachedSnapshot({ - profileHash: currentProfile.hash, - snapshotId: nextSnapshotId, - runtime: params.runtime, - createdAtMs: Date.now(), - dependencyCount: currentProfile.dependencyCount, - }); - await params.onProgress?.("build_complete"); - return { snapshotId: nextSnapshotId, source: "built" }; - }, - canUseCachedSnapshot, - async () => { - await params.onProgress?.("waiting_for_lock"); - }, - params.signal, - ); - - return { - snapshotId: lockResult.snapshotId, - profileHash: currentProfile.hash, - dependencyCount: currentProfile.dependencyCount, - cacheHit: lockResult.source !== "built", - resolveOutcome: toResolveOutcome( - Boolean(params.forceRebuild), - lockResult.source, - ), - ...(rebuildReason ? { rebuildReason } : {}), - }; + return currentProfile.baseHash + ? await resolveWorkspaceProfile(params, currentProfile) + : await resolveProfile(params, currentProfile); }, ); } diff --git a/packages/junior/src/chat/sandbox/workspace.ts b/packages/junior/src/chat/sandbox/workspace.ts index e26e677c6c..cda0e2a916 100644 --- a/packages/junior/src/chat/sandbox/workspace.ts +++ b/packages/junior/src/chat/sandbox/workspace.ts @@ -176,3 +176,15 @@ export function createSandboxSession( }, }; } + +/** Best-effort stop; ignore secondary stop failures during cleanup. */ +export async function stopSession( + session: { stop: () => Promise } | null | undefined, +): Promise { + if (!session) return; + try { + await session.stop(); + } catch { + // Best-effort stop during cleanup. + } +} diff --git a/packages/junior/src/chat/tools/index.ts b/packages/junior/src/chat/tools/index.ts index 968f00ba15..3309ab3c41 100644 --- a/packages/junior/src/chat/tools/index.ts +++ b/packages/junior/src/chat/tools/index.ts @@ -52,6 +52,7 @@ import { getOAuthAccountProviders } from "@/chat/plugins/credential-hooks"; import { createWebFetchTool } from "@/chat/tools/web/fetch-tool"; import { createWebSearchTool } from "@/chat/tools/web/search"; import { createWriteFileTool } from "@/chat/tools/sandbox/write-file"; +import { createWorkspaceTools } from "@/chat/workspaces/tools"; function createToolState(): ToolState { const operationResultCache = new Map(); @@ -114,6 +115,7 @@ export function createTools( ...createResourceEventTools(context, resourceEventCatalog), ...createEventTaskTools(context, resourceEventCatalog), ...createScheduledTaskTools(context), + ...createWorkspaceTools(context), }; if (context.conversationId) { tools.searchConversationEvents = diff --git a/packages/junior/src/chat/tools/types.ts b/packages/junior/src/chat/tools/types.ts index 57083472dc..e9ffed2818 100644 --- a/packages/junior/src/chat/tools/types.ts +++ b/packages/junior/src/chat/tools/types.ts @@ -21,6 +21,7 @@ import type { SlackActionToken } from "@/chat/slack/action-token"; import type { ModelProfile } from "@/chat/model-profile"; import type { GeneratedArtifactFileRef } from "@/chat/tools/sandbox/file-uploads"; import type { SpawnAgent } from "@/chat/agent/types"; +import type { Workspace } from "@/chat/workspaces/types"; import type { AttachmentStorage } from "@/chat/attachments/storage"; interface HandoffControl { @@ -100,6 +101,10 @@ interface BaseToolRuntimeContext { egress: PluginEgress; mcpToolManager?: McpToolManager; workspace: SandboxWorkspace; + workspaces?: { + activeWorkspaceId(): string | undefined; + switch(workspace: Workspace, signal?: AbortSignal): Promise; + }; /** Report whether the model currently executing the turn accepts images. */ supportsImageInput?: () => boolean; } diff --git a/packages/junior/src/chat/workspaces/store.ts b/packages/junior/src/chat/workspaces/store.ts new file mode 100644 index 0000000000..33207cc26c --- /dev/null +++ b/packages/junior/src/chat/workspaces/store.ts @@ -0,0 +1,92 @@ +import { asc, eq } from "drizzle-orm"; +import type { JuniorDatabase } from "@/db/db"; +import { juniorWorkspaceRepos, juniorWorkspaces } from "@/db/schema"; +import type { Workspace } from "./types"; + +function workspaceFromRows( + row: typeof juniorWorkspaces.$inferSelect, + repos: Array, +): Workspace { + return { + id: row.id, + name: row.name, + setupScript: row.setupScript, + updatedAt: row.updatedAt, + repos: repos.map((repo) => ({ + provider: repo.provider, + repo: repo.repo, + checkoutPath: repo.checkoutPath, + isPrimary: repo.isPrimary, + })), + }; +} + +/** List workspace recipes by stable name. */ +export async function listWorkspaces(db: JuniorDatabase): Promise { + const [workspaces, repos] = await Promise.all([ + db.select().from(juniorWorkspaces).orderBy(asc(juniorWorkspaces.name)), + db + .select() + .from(juniorWorkspaceRepos) + .orderBy( + asc(juniorWorkspaceRepos.workspaceId), + asc(juniorWorkspaceRepos.provider), + asc(juniorWorkspaceRepos.repo), + asc(juniorWorkspaceRepos.checkoutPath), + ), + ]); + return workspaces.map((workspace) => + workspaceFromRows( + workspace, + repos.filter((repo) => repo.workspaceId === workspace.id), + ), + ); +} + +/** Resolve one workspace recipe by name. */ +export async function getWorkspaceByName( + db: JuniorDatabase, + name: string, +): Promise { + const rows = await db + .select() + .from(juniorWorkspaces) + .where(eq(juniorWorkspaces.name, name)) + .limit(1); + const workspace = rows[0]; + if (!workspace) return undefined; + const repos = await db + .select() + .from(juniorWorkspaceRepos) + .where(eq(juniorWorkspaceRepos.workspaceId, workspace.id)) + .orderBy( + asc(juniorWorkspaceRepos.provider), + asc(juniorWorkspaceRepos.repo), + asc(juniorWorkspaceRepos.checkoutPath), + ); + return workspaceFromRows(workspace, repos); +} + +/** Resolve one workspace recipe by id. */ +export async function getWorkspace( + db: JuniorDatabase, + id: string, +): Promise { + const rows = await db + .select() + .from(juniorWorkspaces) + .where(eq(juniorWorkspaces.id, id)) + .limit(1); + const workspace = rows[0]; + if (!workspace) return undefined; + const repos = await db + .select() + .from(juniorWorkspaceRepos) + .where(eq(juniorWorkspaceRepos.workspaceId, workspace.id)) + .orderBy( + asc(juniorWorkspaceRepos.provider), + asc(juniorWorkspaceRepos.repo), + asc(juniorWorkspaceRepos.checkoutPath), + ); + return workspaceFromRows(workspace, repos); +} diff --git a/packages/junior/src/chat/workspaces/tools.ts b/packages/junior/src/chat/workspaces/tools.ts new file mode 100644 index 0000000000..17ca1511bf --- /dev/null +++ b/packages/junior/src/chat/workspaces/tools.ts @@ -0,0 +1,85 @@ +import { z } from "zod"; +import { getDb } from "@/chat/db"; +import { juniorToolOutputSchema } from "@/chat/tool-support/structured-result"; +import { zodTool } from "@/chat/tool-support/zod-tool"; +import { ToolInputError } from "@/chat/tools/execution/tool-input-error"; +import type { ToolRegistry } from "@/chat/tools/definition"; +import type { ToolRuntimeContext } from "@/chat/tools/types"; +import { getWorkspaceByName, listWorkspaces } from "./store"; + +const repoSchema = z.object({ + provider: z.string(), + repo: z.string(), + checkout_path: z.string(), + is_primary: z.boolean(), +}); +const workspaceSchema = z.object({ + id: z.string(), + name: z.string(), + repos: z.array(repoSchema), +}); + +function view(workspace: Awaited>[number]) { + return { + id: workspace.id, + name: workspace.name, + repos: workspace.repos.map((repo) => ({ + provider: repo.provider, + repo: repo.repo, + checkout_path: repo.checkoutPath, + is_primary: repo.isPrimary, + })), + }; +} + +/** Build tools for listing and selecting registered workspaces. */ +export function createWorkspaceTools(context: ToolRuntimeContext): ToolRegistry { + if (!context.workspaces) return {}; + return { + listWorkspaces: zodTool({ + annotations: { + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + readOnlyHint: true, + }, + description: + "List named repository workspaces that can replace the current sandbox.", + inputSchema: z.object({}).strict(), + outputSchema: juniorToolOutputSchema.extend({ + active_workspace_id: z.string().nullable(), + workspaces: z.array(workspaceSchema), + }), + async execute() { + return { + active_workspace_id: context.workspaces!.activeWorkspaceId() ?? null, + workspaces: (await listWorkspaces(getDb())).map(view), + }; + }, + }), + switchWorkspace: zodTool({ + annotations: { + destructiveHint: true, + idempotentHint: true, + openWorldHint: true, + readOnlyHint: false, + }, + description: + "Replace the current sandbox with a named preconfigured repository workspace. Files in the prior sandbox do not carry over.", + inputSchema: z + .object({ + name: z.string().trim().min(1).describe("Exact workspace name."), + }) + .strict(), + outputSchema: juniorToolOutputSchema.extend({ + workspace: workspaceSchema, + }), + async execute({ name }, options) { + const workspace = await getWorkspaceByName(getDb(), name); + if (!workspace) throw new ToolInputError(`Workspace not found: ${name}`); + await context.workspaces!.switch(workspace, options.signal); + return { workspace: view(workspace) }; + }, + }), + }; +} diff --git a/packages/junior/src/chat/workspaces/types.ts b/packages/junior/src/chat/workspaces/types.ts new file mode 100644 index 0000000000..3460aa7562 --- /dev/null +++ b/packages/junior/src/chat/workspaces/types.ts @@ -0,0 +1,15 @@ +export interface WorkspaceRepo { + provider: string; + repo: string; + checkoutPath: string; + isPrimary: boolean; +} + +/** Named recipe used to prepare reusable sandbox contents. */ +export interface Workspace { + id: string; + name: string; + setupScript: string; + updatedAt: Date; + repos: WorkspaceRepo[]; +} diff --git a/packages/junior/src/db/schema.ts b/packages/junior/src/db/schema.ts index 0f21d223f7..05aa101cd3 100644 --- a/packages/junior/src/db/schema.ts +++ b/packages/junior/src/db/schema.ts @@ -20,6 +20,7 @@ import { juniorSchedulerTasks, } from "./schema/scheduled-tasks"; import { juniorUsers } from "./schema/users"; +import { juniorWorkspaceRepos, juniorWorkspaces } from "./schema/workspaces"; export { juniorAttachments, @@ -40,6 +41,8 @@ export { juniorSchedulerRuns, juniorSchedulerTasks, juniorUsers, + juniorWorkspaceRepos, + juniorWorkspaces, }; export const juniorSqlSchema = { @@ -61,4 +64,6 @@ export const juniorSqlSchema = { juniorSchedulerRuns, juniorSchedulerTasks, juniorUsers, + juniorWorkspaceRepos, + juniorWorkspaces, }; diff --git a/packages/junior/src/db/schema/workspaces.ts b/packages/junior/src/db/schema/workspaces.ts new file mode 100644 index 0000000000..8d4b731e75 --- /dev/null +++ b/packages/junior/src/db/schema/workspaces.ts @@ -0,0 +1,46 @@ +import { sql } from "drizzle-orm"; +import { + boolean, + pgTable, + primaryKey, + text, + uniqueIndex, +} from "drizzle-orm/pg-core"; +import { timestamptz } from "./timestamps"; + +/** Named recipe used to prepare a reusable sandbox. */ +export const juniorWorkspaces = pgTable( + "junior_workspaces", + { + id: text("id").primaryKey(), + name: text("name").notNull(), + setupScript: text("setup_script").notNull().default(""), + createdAt: timestamptz("created_at").notNull(), + updatedAt: timestamptz("updated_at").notNull(), + }, + (table) => [uniqueIndex("junior_workspaces_name_idx").on(table.name)], +); + +/** Repository included in one workspace recipe. */ +export const juniorWorkspaceRepos = pgTable( + "junior_workspace_repos", + { + workspaceId: text("workspace_id") + .notNull() + .references(() => juniorWorkspaces.id, { onDelete: "cascade" }), + provider: text("provider").notNull(), + repo: text("repo").notNull(), + checkoutPath: text("checkout_path").notNull(), + isPrimary: boolean("is_primary").notNull().default(false), + }, + (table) => [ + primaryKey({ columns: [table.workspaceId, table.provider, table.repo] }), + uniqueIndex("junior_workspace_repos_checkout_path_idx").on( + table.workspaceId, + table.checkoutPath, + ), + uniqueIndex("junior_workspace_repos_primary_idx") + .on(table.workspaceId) + .where(sql`${table.isPrimary}`), + ], +); diff --git a/packages/junior/tests/component/misc/sandbox-executor.test.ts b/packages/junior/tests/component/misc/sandbox-executor.test.ts index f14735cdfd..dbc9772a25 100644 --- a/packages/junior/tests/component/misc/sandbox-executor.test.ts +++ b/packages/junior/tests/component/misc/sandbox-executor.test.ts @@ -194,6 +194,7 @@ function createTestSandboxRuntime(options: SandboxFixtureOptions = {}) { createNetworkPolicy: options.createNetworkPolicy, onSandboxPrepare: options.onSandboxPrepare, onSandboxRefChanged: async (ref) => { + if (!ref) return; await options.onSandboxAcquired?.({ sandboxId: ref.id, ...(ref.profileHash @@ -247,6 +248,7 @@ function createTestSandbox(options: SandboxFixtureOptions = {}) { await options.agentHooks?.prepareSandbox(workspace) : undefined, onSandboxRefChanged: async (ref) => { + if (!ref) return; await options.onSandboxAcquired?.({ sandboxId: ref.id, ...(ref.profileHash @@ -1006,6 +1008,479 @@ describe("createTestSandbox", () => { expect(sandboxCreateMock).toHaveBeenCalledTimes(1); }); + it("replaces a live workspace when the same recipe id has a new profile", async () => { + const initialSandbox = makeSandbox("sbx_workspace_initial"); + const refreshedSandbox = makeSandbox("sbx_workspace_refreshed"); + sandboxCreateMock + .mockResolvedValueOnce(initialSandbox) + .mockResolvedValueOnce(refreshedSandbox); + hashMock + .mockReturnValueOnce("profile-initial") + .mockReturnValueOnce("profile-refreshed"); + const workspace = { + id: "workspace-1", + name: "sentry", + setupScript: "", + updatedAt: new Date("2026-08-11T00:00:00.000Z"), + repos: [], + }; + const runtime = createSandboxRuntime({ + workspace, + skills: [], + referenceFiles: [], + }); + + await runtime.acquire(); + await runtime.switchWorkspace({ + ...workspace, + updatedAt: new Date("2026-08-12T00:00:00.000Z"), + }); + + expect(sandboxCreateMock).toHaveBeenCalledTimes(2); + expect(initialSandbox.stop).toHaveBeenCalledTimes(1); + expect(runtime.sandboxRef()?.id).toBe("sbx_workspace_refreshed"); + }); + + it("keeps the live sandbox when workspace switch is already cancelled", async () => { + const initialSandbox = makeSandbox("sbx_workspace_initial"); + sandboxCreateMock.mockResolvedValueOnce(initialSandbox); + hashMock + .mockReturnValueOnce("profile-initial") + .mockReturnValueOnce("profile-next"); + const initialWorkspace = { + id: "workspace-initial", + name: "initial", + setupScript: "", + updatedAt: new Date("2026-08-11T00:00:00.000Z"), + repos: [], + }; + const runtime = createSandboxRuntime({ + workspace: initialWorkspace, + skills: [], + referenceFiles: [], + }); + await runtime.acquire(); + const controller = new AbortController(); + const reason = new Error("switch cancelled"); + controller.abort(reason); + + await expect( + runtime.switchWorkspace( + { + ...initialWorkspace, + id: "workspace-next", + name: "next", + }, + controller.signal, + ), + ).rejects.toBe(reason); + + expect(sandboxCreateMock).toHaveBeenCalledTimes(1); + expect(initialSandbox.stop).not.toHaveBeenCalled(); + expect(runtime.sandboxRef()?.id).toBe("sbx_workspace_initial"); + }); + + it("restores the live sandbox when workspace switch is cancelled mid-boot", async () => { + const initialSandbox = makeSandbox("sbx_workspace_initial"); + const nextSandbox = makeSandbox("sbx_workspace_next"); + let releaseCreate: (() => void) | undefined; + const createGate = new Promise((resolve) => { + releaseCreate = resolve; + }); + sandboxCreateMock + .mockResolvedValueOnce(initialSandbox) + .mockImplementationOnce(async () => { + await createGate; + return nextSandbox; + }); + hashMock + .mockReturnValueOnce("profile-initial") + .mockReturnValueOnce("profile-next"); + const initialWorkspace = { + id: "workspace-initial", + name: "initial", + setupScript: "", + updatedAt: new Date("2026-08-11T00:00:00.000Z"), + repos: [], + }; + const runtime = createSandboxRuntime({ + workspace: initialWorkspace, + skills: [], + referenceFiles: [], + }); + await runtime.acquire(); + const controller = new AbortController(); + const reason = new Error("switch cancelled mid-boot"); + + const switchPromise = runtime.switchWorkspace( + { + ...initialWorkspace, + id: "workspace-next", + name: "next", + }, + controller.signal, + ); + await vi.waitFor(() => expect(sandboxCreateMock).toHaveBeenCalledTimes(2)); + controller.abort(reason); + releaseCreate?.(); + + await expect(switchPromise).rejects.toBe(reason); + expect(initialSandbox.stop).not.toHaveBeenCalled(); + expect(nextSandbox.stop).toHaveBeenCalledTimes(1); + expect(runtime.sandboxRef()?.id).toBe("sbx_workspace_initial"); + }); + + it("keeps a durable same-recipe sandbox when switch is repeated cold", async () => { + hashMock.mockReturnValue("profile-same"); + const workspace = { + id: "workspace-1", + name: "sentry", + setupScript: "", + updatedAt: new Date("2026-08-11T00:00:00.000Z"), + repos: [], + }; + const runtime = createSandboxRuntime({ + sandboxRef: { + id: "sbx_workspace_same", + profileHash: "profile-same", + workspaceId: "workspace-1", + }, + workspace, + skills: [], + referenceFiles: [], + }); + + await runtime.switchWorkspace(workspace); + + expect(sandboxCreateMock).not.toHaveBeenCalled(); + expect(sandboxGetMock).not.toHaveBeenCalled(); + expect(runtime.sandboxRef()).toEqual({ + id: "sbx_workspace_same", + profileHash: "profile-same", + workspaceId: "workspace-1", + }); + }); + + it("keeps durable workspaceId when the recipe row is missing", async () => { + // Base-only hash would previously discard the workspace hint and strip workspaceId. + hashMock.mockReturnValue("profile-base"); + const restored = makeSandbox("sbx_missing_recipe"); + sandboxGetMock.mockResolvedValueOnce(restored); + const refs: Array<{ id: string; workspaceId?: string; profileHash?: string } | null> = + []; + const runtime = createSandboxRuntime({ + sandboxRef: { + id: "sbx_missing_recipe", + profileHash: "profile-workspace", + workspaceId: "workspace-deleted", + }, + skills: [], + referenceFiles: [], + onSandboxRefChanged: (ref) => { + refs.push(ref); + }, + }); + + await runtime.acquire(); + + expect(sandboxGetMock).toHaveBeenCalled(); + expect(sandboxCreateMock).not.toHaveBeenCalled(); + expect(runtime.sandboxRef()).toEqual({ + id: "sbx_missing_recipe", + profileHash: "profile-workspace", + workspaceId: "workspace-deleted", + }); + // Same durable identity is not rewritten. + expect(refs).toEqual([]); + }); + + it("forwards abort signal into workspace setup scripts", async () => { + const buildSandbox = makeSandbox("sbx_workspace_setup_signal"); + const controller = new AbortController(); + let releaseSetup: (() => void) | undefined; + const setupStarted = new Promise((resolve) => { + buildSandbox.runCommand.mockImplementationOnce(async (input: any) => { + resolve(); + await new Promise((settle) => { + releaseSetup = settle; + input.signal?.addEventListener("abort", () => settle(), { once: true }); + }); + if (input.signal?.aborted) { + const error = new Error("aborted"); + error.name = "AbortError"; + throw error; + } + return { exitCode: 0, stdout: async () => "", stderr: async () => "" }; + }); + }); + resolveMock.mockImplementationOnce(async (params: any) => { + await params.prepareWorkspace?.(buildSandbox); + return { + snapshotId: "snap_workspace_setup", + profileHash: "profile-workspace-setup", + dependencyCount: 0, + cacheHit: false, + resolveOutcome: "built", + }; + }); + hashMock.mockReturnValue("profile-workspace-setup"); + const runtime = createSandboxRuntime({ + workspace: { + id: "workspace-setup", + name: "setup", + setupScript: "echo ready", + updatedAt: new Date("2026-08-12T00:00:00.000Z"), + repos: [], + }, + skills: [], + referenceFiles: [], + }); + + const acquirePromise = runtime.acquire(controller.signal); + await setupStarted; + const setupCommand = buildSandbox.runCommand.mock.calls[0]?.[0] as { + cmd?: string; + signal?: AbortSignal; + }; + expect(setupCommand.cmd).toBe("bash"); + expect(setupCommand.signal).toBeInstanceOf(AbortSignal); + expect(setupCommand.signal?.aborted).toBe(false); + + controller.abort("cancel setup"); + await expect(acquirePromise).rejects.toBe("cancel setup"); + expect(setupCommand.signal?.aborted).toBe(true); + releaseSetup?.(); + }); + + it("starts keepalive after a successful workspace switch", async () => { + vi.useFakeTimers(); + process.env.VERCEL_SANDBOX_KEEPALIVE_MS = "5000"; + const initialSandbox = makeSandbox("sbx_workspace_keepalive_initial"); + const nextSandbox = makeSandbox("sbx_workspace_keepalive_next"); + sandboxCreateMock + .mockResolvedValueOnce(initialSandbox) + .mockResolvedValueOnce(nextSandbox); + hashMock + .mockReturnValueOnce("profile-initial") + .mockReturnValueOnce("profile-next"); + const initialWorkspace = { + id: "workspace-initial", + name: "initial", + setupScript: "", + updatedAt: new Date("2026-08-11T00:00:00.000Z"), + repos: [], + }; + const nextWorkspace = { + ...initialWorkspace, + id: "workspace-next", + name: "next", + }; + const runtime = createSandboxRuntime({ + workspace: initialWorkspace, + skills: [], + referenceFiles: [], + }); + + await runtime.acquire(); + expect(initialSandbox.extendTimeout).not.toHaveBeenCalled(); + + await runtime.switchWorkspace(nextWorkspace); + + expect(nextSandbox.extendTimeout).toHaveBeenCalledTimes(1); + expect(nextSandbox.extendTimeout).toHaveBeenCalledWith(5000); + await vi.advanceTimersByTimeAsync(2500); + expect(nextSandbox.extendTimeout).toHaveBeenCalledTimes(2); + + runtime.close(); + }); + + it("clears a durably reported workspace when its switch fails", async () => { + const initialSandbox = makeSandbox("sbx_workspace_initial"); + const failedSandbox = makeSandbox("sbx_workspace_failed"); + sandboxCreateMock + .mockResolvedValueOnce(initialSandbox) + .mockResolvedValueOnce(failedSandbox); + let prepareCount = 0; + const refs: Array<{ id: string; workspaceId?: string } | null> = []; + const initialWorkspace = { + id: "workspace-initial", + name: "initial", + setupScript: "", + updatedAt: new Date("2026-08-11T00:00:00.000Z"), + repos: [], + }; + const nextWorkspace = { + ...initialWorkspace, + id: "workspace-next", + name: "next", + }; + const runtime = createSandboxRuntime({ + workspace: initialWorkspace, + skills: [], + referenceFiles: [], + onSandboxPrepare: () => { + prepareCount += 1; + if (prepareCount === 2) { + throw new Error("prepare failed"); + } + }, + onSandboxRefChanged: async (ref) => { + refs.push(ref); + if (ref === null) { + throw new Error("persistence failed"); + } + }, + }); + + await runtime.acquire(); + await expect(runtime.switchWorkspace(nextWorkspace)).rejects.toThrow( + "sandbox setup failed", + ); + + // Failed replacement is stopped; prior live sandbox is restored and re-reported. + expect(refs).toEqual([ + { id: "sbx_workspace_initial", workspaceId: "workspace-initial" }, + { id: "sbx_workspace_failed", workspaceId: "workspace-next" }, + { id: "sbx_workspace_initial", workspaceId: "workspace-initial" }, + ]); + expect(runtime.sandboxRef()?.id).toBe("sbx_workspace_initial"); + expect(failedSandbox.stop).toHaveBeenCalledTimes(1); + expect(initialSandbox.stop).not.toHaveBeenCalled(); + }); + + it("stops a remembered replacement when switch fails after acquire", async () => { + process.env.VERCEL_SANDBOX_KEEPALIVE_MS = "5000"; + const initialSandbox = makeSandbox("sbx_workspace_initial"); + const failedSandbox = makeSandbox("sbx_workspace_keepalive_failed"); + // Fail after createFreshSandbox remembers the session, during ensureReady. + failedSandbox.extendTimeout.mockRejectedValueOnce( + createApiError(410, "Gone", "sandbox_stopped", "sandbox is gone"), + ); + sandboxCreateMock + .mockResolvedValueOnce(initialSandbox) + .mockResolvedValueOnce(failedSandbox); + hashMock + .mockReturnValueOnce("profile-initial") + .mockReturnValueOnce("profile-next"); + const initialWorkspace = { + id: "workspace-initial", + name: "initial", + setupScript: "", + updatedAt: new Date("2026-08-11T00:00:00.000Z"), + repos: [], + }; + const nextWorkspace = { + ...initialWorkspace, + id: "workspace-next", + name: "next", + }; + const runtime = createSandboxRuntime({ + workspace: initialWorkspace, + skills: [], + referenceFiles: [], + }); + + await runtime.acquire(); + await expect(runtime.switchWorkspace(nextWorkspace)).rejects.toThrow( + "Status code 410 is not ok", + ); + + expect(failedSandbox.stop).toHaveBeenCalledTimes(1); + expect(initialSandbox.stop).not.toHaveBeenCalled(); + expect(runtime.sandboxRef()?.id).toBe("sbx_workspace_initial"); + }); + + it("stops a late in-flight sandbox remembered during workspace switch", async () => { + const lateSandbox = makeSandbox("sbx_workspace_late_inflight"); + const nextSandbox = makeSandbox("sbx_workspace_switch_target"); + let releaseCreate: (() => void) | undefined; + const createGate = new Promise((resolve) => { + releaseCreate = resolve; + }); + sandboxCreateMock + .mockImplementationOnce(async () => { + await createGate; + return lateSandbox; + }) + .mockResolvedValueOnce(nextSandbox); + hashMock + .mockReturnValueOnce("profile-initial") + .mockReturnValueOnce("profile-next"); + const initialWorkspace = { + id: "workspace-initial", + name: "initial", + setupScript: "", + updatedAt: new Date("2026-08-11T00:00:00.000Z"), + repos: [], + }; + const nextWorkspace = { + ...initialWorkspace, + id: "workspace-next", + name: "next", + }; + const runtime = createSandboxRuntime({ + workspace: initialWorkspace, + skills: [], + referenceFiles: [], + }); + + // Cold acquire is still in flight when switch aborts it. + const pendingAcquire = runtime.acquire(); + await vi.waitFor(() => expect(sandboxCreateMock).toHaveBeenCalledTimes(1)); + + const switchPromise = runtime.switchWorkspace(nextWorkspace); + // Finish the aborted create so it can remember a session after previous was captured. + releaseCreate?.(); + await pendingAcquire; + await switchPromise; + + expect(lateSandbox.stop).toHaveBeenCalledTimes(1); + expect(nextSandbox.stop).not.toHaveBeenCalled(); + // Late acquire rewrote the durable hint; stopping it must clear that hint so + // the switch boots fresh instead of restoring the stopped sandbox. + expect(sandboxGetMock).not.toHaveBeenCalled(); + expect(runtime.sandboxRef()?.id).toBe("sbx_workspace_switch_target"); + }); + + it("restores a durable sandbox hint when cold switch fails before acquire", async () => { + hashMock + .mockReturnValueOnce("profile-initial") + .mockReturnValueOnce("profile-next"); + sandboxCreateMock.mockRejectedValueOnce(new Error("boot failed")); + const initialWorkspace = { + id: "workspace-initial", + name: "initial", + setupScript: "", + updatedAt: new Date("2026-08-11T00:00:00.000Z"), + repos: [], + }; + const nextWorkspace = { + ...initialWorkspace, + id: "workspace-next", + name: "next", + }; + const runtime = createSandboxRuntime({ + sandboxRef: { + id: "sbx_cold_hint", + profileHash: "profile-initial", + workspaceId: "workspace-initial", + }, + workspace: initialWorkspace, + skills: [], + referenceFiles: [], + }); + + await expect(runtime.switchWorkspace(nextWorkspace)).rejects.toThrow( + "sandbox setup failed", + ); + + expect(runtime.sandboxRef()).toEqual({ + id: "sbx_cold_hint", + profileHash: "profile-initial", + workspaceId: "workspace-initial", + }); + }); + it("surfaces a generic sandbox setup failure for non-recoverable sync errors", async () => { const forbiddenSandbox = makeSandbox("sbx_forbidden", { mkDirError: createApiError( @@ -1047,6 +1522,30 @@ describe("createTestSandbox", () => { expect(sandboxCreateMock).not.toHaveBeenCalled(); }); + it("keeps a restored sandbox alive when prepare fails", async () => { + const restoredSandbox = makeSandbox("sbx_restore_prepare"); + sandboxGetMock.mockResolvedValueOnce(restoredSandbox); + + const executor = createTestSandbox({ + sandboxId: "sbx_restore_prepare", + agentHooks: { + beforeToolExecute: vi.fn(), + prepareSandbox: vi.fn(async () => { + throw new Error("prepare failed"); + }), + }, + }); + executor.configureSkills([]); + + await expect(executor.createSandbox()).rejects.toThrow( + "sandbox setup failed", + ); + + expect(restoredSandbox.stop).not.toHaveBeenCalled(); + expect(sandboxCreateMock).not.toHaveBeenCalled(); + expect(executor.getSandboxId()).toBe("sbx_restore_prepare"); + }); + it.each([ createApiError(404, "Not Found", "not_found", "Sandbox was not found"), createApiError( diff --git a/packages/junior/tests/component/sandbox/snapshot/resolve.test.ts b/packages/junior/tests/component/sandbox/snapshot/resolve.test.ts index 6338a0b39f..9de17e8491 100644 --- a/packages/junior/tests/component/sandbox/snapshot/resolve.test.ts +++ b/packages/junior/tests/component/sandbox/snapshot/resolve.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const { sandboxCreateMock, @@ -42,7 +42,7 @@ vi.mock("@/chat/logging", () => ({ })); const store = new Map(); -let lockHeld = false; +const heldLocks = new Set(); let getError: Error | undefined; const acquiredLockTtls: number[] = []; @@ -58,16 +58,16 @@ vi.mock("@/chat/state/adapter", () => ({ set: vi.fn(async (key: string, value: string) => { store.set(key, value); }), - acquireLock: vi.fn(async (_key: string, ttlMs: number) => { + acquireLock: vi.fn(async (key: string, ttlMs: number) => { acquiredLockTtls.push(ttlMs); - if (lockHeld) { + if (heldLocks.has(key)) { return null; } - lockHeld = true; - return { key: "lock" }; + heldLocks.add(key); + return { key }; }), - releaseLock: vi.fn(async () => { - lockHeld = false; + releaseLock: vi.fn(async (lock: { key: string }) => { + heldLocks.delete(lock.key); }), }), })); @@ -99,7 +99,7 @@ function makeSandbox(snapshotId: string) { describe("snapshot resolution", () => { beforeEach(() => { store.clear(); - lockHeld = false; + heldLocks.clear(); getError = undefined; acquiredLockTtls.length = 0; sandboxCreateMock.mockReset(); @@ -125,6 +125,11 @@ describe("snapshot resolution", () => { vi.setSystemTime(new Date("2026-03-01T00:00:00.000Z")); }); + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + it("reuses cached rebuilt snapshot during force rebuild when stale id differs", async () => { getRuntimeDependenciesMock.mockReturnValue([ { type: "npm", package: "sentry", version: "latest" }, @@ -224,7 +229,8 @@ describe("snapshot resolution", () => { getRuntimeDependenciesMock.mockReturnValue([ { type: "npm", package: "sentry", version: "latest" }, ]); - lockHeld = true; + // Force every acquire attempt to miss so the waiter path runs. + vi.spyOn(heldLocks, "has").mockReturnValue(true); const controller = new AbortController(); const reason = new Error("turn ended"); @@ -290,9 +296,10 @@ describe("snapshot resolution", () => { expect(first.cacheHit).toBe(false); expect(first.resolveOutcome).toBe("rebuilt"); - lockHeld = true; + const lockKey = `junior:sandbox_snapshot_lock:${first.profileHash}`; + heldLocks.add(lockKey); setTimeout(() => { - lockHeld = false; + heldLocks.delete(lockKey); }, 50); const second = await resolveSnapshot({ @@ -357,8 +364,9 @@ describe("snapshot resolution", () => { snapshotId: string; createdAtMs: number; }; + const lockKey = `junior:sandbox_snapshot_lock:${first.profileHash}`; - lockHeld = true; + heldLocks.add(lockKey); setTimeout(() => { store.set( cacheKey, @@ -369,7 +377,7 @@ describe("snapshot resolution", () => { ); }, 100); setTimeout(() => { - lockHeld = false; + heldLocks.delete(lockKey); }, 1_100); const concurrent = resolveSnapshot({ @@ -402,4 +410,186 @@ describe("snapshot resolution", () => { }); expect(sandboxCreateMock).not.toHaveBeenCalled(); }); + + it("builds workspace snapshots by extending the cached base snapshot", async () => { + getRuntimeDependenciesMock.mockReturnValue([ + { type: "npm", package: "sentry", version: "latest" }, + ]); + const baseSandbox = makeSandbox("snap_base"); + const workspaceSandbox = makeSandbox("snap_workspace"); + sandboxCreateMock + .mockResolvedValueOnce(baseSandbox) + .mockResolvedValueOnce(workspaceSandbox); + const prepareWorkspace = vi.fn(async () => {}); + const workspace = { + id: "workspace-1", + name: "sentry", + setupScript: "pnpm install", + updatedAt: new Date("2026-03-01T00:00:00.000Z"), + repos: [ + { + provider: "github", + repo: "getsentry/sentry", + checkoutPath: "sentry", + isPrimary: true, + }, + ], + }; + + const snapshot = await resolveSnapshot({ + runtime: "node22", + timeoutMs: 60_000, + workspace, + prepareWorkspace, + }); + + expect(snapshot.snapshotId).toBe("snap_workspace"); + expect(snapshot.cacheHit).toBe(false); + expect(snapshot.resolveOutcome).toBe("rebuilt"); + expect(sandboxCreateMock).toHaveBeenCalledTimes(2); + expect(sandboxCreateMock).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ runtime: "node22" }), + ); + expect(sandboxCreateMock).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + source: { type: "snapshot", snapshotId: "snap_base" }, + }), + ); + expect(prepareWorkspace).toHaveBeenCalledTimes(1); + expect(baseSandbox.runCommand).toHaveBeenCalled(); + expect(workspaceSandbox.runCommand).not.toHaveBeenCalled(); + + const reused = await resolveSnapshot({ + runtime: "node22", + timeoutMs: 60_000, + workspace, + prepareWorkspace, + }); + expect(reused.snapshotId).toBe("snap_workspace"); + expect(reused.cacheHit).toBe(true); + expect(sandboxCreateMock).toHaveBeenCalledTimes(2); + expect(prepareWorkspace).toHaveBeenCalledTimes(1); + }); + + it("rebuilds a workspace snapshot when its base snapshot changes", async () => { + getRuntimeDependenciesMock.mockReturnValue([ + { type: "npm", package: "sentry", version: "latest" }, + ]); + sandboxCreateMock + .mockResolvedValueOnce(makeSandbox("snap_base")) + .mockResolvedValueOnce(makeSandbox("snap_workspace")) + .mockResolvedValueOnce(makeSandbox("snap_base_rebuilt")) + .mockResolvedValueOnce(makeSandbox("snap_workspace_rebuilt")); + const workspace = { + id: "workspace-1", + name: "sentry", + setupScript: "pnpm install", + updatedAt: new Date("2026-03-01T00:00:00.000Z"), + repos: [ + { + provider: "github", + repo: "getsentry/sentry", + checkoutPath: "sentry", + isPrimary: true, + }, + ], + }; + + const first = await resolveSnapshot({ + runtime: "node22", + timeoutMs: 60_000, + workspace, + prepareWorkspace: async () => {}, + }); + const rebuiltBase = await resolveSnapshot({ + runtime: "node22", + timeoutMs: 60_000, + forceRebuild: true, + staleSnapshotId: "snap_base", + }); + const rebuiltWorkspace = await resolveSnapshot({ + runtime: "node22", + timeoutMs: 60_000, + workspace, + prepareWorkspace: async () => {}, + }); + + expect(first.snapshotId).toBe("snap_workspace"); + expect(rebuiltBase.snapshotId).toBe("snap_base_rebuilt"); + expect(rebuiltWorkspace.snapshotId).toBe("snap_workspace_rebuilt"); + expect(rebuiltWorkspace.cacheHit).toBe(false); + expect(sandboxCreateMock).toHaveBeenNthCalledWith( + 4, + expect.objectContaining({ + source: { type: "snapshot", snapshotId: "snap_base_rebuilt" }, + }), + ); + }); + + it("rebuilds the base snapshot when workspace extend finds it missing", async () => { + getRuntimeDependenciesMock.mockReturnValue([ + { type: "npm", package: "sentry", version: "latest" }, + ]); + const baseSandbox = makeSandbox("snap_base"); + const rebuiltBaseSandbox = makeSandbox("snap_base_rebuilt"); + const workspaceSandbox = makeSandbox("snap_workspace"); + sandboxCreateMock + .mockResolvedValueOnce(baseSandbox) + .mockRejectedValueOnce(new Error("snapshot not found")) + .mockResolvedValueOnce(rebuiltBaseSandbox) + .mockResolvedValueOnce(workspaceSandbox); + + const prepareWorkspace = vi.fn(async () => {}); + const workspace = { + id: "workspace-1", + name: "sentry", + setupScript: "pnpm install", + updatedAt: new Date("2026-03-01T00:00:00.000Z"), + repos: [ + { + provider: "github", + repo: "getsentry/sentry", + checkoutPath: "sentry", + isPrimary: true, + }, + ], + }; + + // Seed a base cache entry first. + await resolveSnapshot({ + runtime: "node22", + timeoutMs: 60_000, + }); + + // Drop the base provider snapshot while keeping the cache pointer so the + // workspace build hits the missing-parent retry path. + const snapshot = await resolveSnapshot({ + runtime: "node22", + timeoutMs: 60_000, + workspace, + prepareWorkspace, + }); + + expect(snapshot.snapshotId).toBe("snap_workspace"); + expect(sandboxCreateMock).toHaveBeenCalledTimes(4); + expect(sandboxCreateMock).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + source: { type: "snapshot", snapshotId: "snap_base" }, + }), + ); + expect(sandboxCreateMock).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ runtime: "node22" }), + ); + expect(sandboxCreateMock).toHaveBeenNthCalledWith( + 4, + expect.objectContaining({ + source: { type: "snapshot", snapshotId: "snap_base_rebuilt" }, + }), + ); + expect(prepareWorkspace).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/junior/tests/component/scheduled-tasks-sql.test.ts b/packages/junior/tests/component/scheduled-tasks-sql.test.ts index 5e8d5cdbd2..a8b38845dd 100644 --- a/packages/junior/tests/component/scheduled-tasks-sql.test.ts +++ b/packages/junior/tests/component/scheduled-tasks-sql.test.ts @@ -175,7 +175,7 @@ describe("scheduled-task SQL storage", () => { await expect(migrateSchema(fixture.sql)).resolves.toMatchObject({ existing: 16, - migrated: 12, + migrated: 13, }); const [migrated] = await fixture.sql.query<{ creatorIdentityId: string | null; diff --git a/packages/junior/tests/unit/sandbox/snapshot/profile.test.ts b/packages/junior/tests/unit/sandbox/snapshot/profile.test.ts index b7110e3143..9e39bcccec 100644 --- a/packages/junior/tests/unit/sandbox/snapshot/profile.test.ts +++ b/packages/junior/tests/unit/sandbox/snapshot/profile.test.ts @@ -77,6 +77,186 @@ describe("snapshot dependency profile", () => { expect(profile?.floating).toBe(true); }); + it("includes workspace contents in the profile hash", () => { + const workspace = { + id: "workspace-1", + name: "sentry", + setupScript: "pnpm install", + updatedAt: new Date("2026-03-10T00:00:00.000Z"), + repos: [ + { + provider: "github", + repo: "getsentry/sentry", + checkoutPath: "sentry", + isPrimary: true, + }, + ], + }; + + const first = create("node22", workspace); + const changed = create("node22", { + ...workspace, + setupScript: "pnpm install --frozen-lockfile", + }); + + expect(first).not.toBeNull(); + expect(first?.hash).not.toBe(changed?.hash); + }); + + it("keeps workspace profile hashes stable across repo order", () => { + const updatedAt = new Date("2026-03-10T00:00:00.000Z"); + const first = create("node22", { + id: "workspace-1", + name: "sentry", + setupScript: "pnpm install", + updatedAt, + repos: [ + { + provider: "github", + repo: "getsentry/sentry", + checkoutPath: "sentry", + isPrimary: true, + }, + { + provider: "github", + repo: "getsentry/relay", + checkoutPath: "relay", + isPrimary: false, + }, + ], + }); + const second = create("node22", { + id: "workspace-1", + name: "sentry", + setupScript: "pnpm install", + updatedAt, + repos: [ + { + provider: "github", + repo: "getsentry/relay", + checkoutPath: "relay", + isPrimary: false, + }, + { + provider: "github", + repo: "getsentry/sentry", + checkoutPath: "sentry", + isPrimary: true, + }, + ], + }); + + expect(first?.hash).toBe(second?.hash); + }); + + it("ignores isPrimary when hashing workspace profiles", () => { + const updatedAt = new Date("2026-03-10T00:00:00.000Z"); + const first = create("node22", { + id: "workspace-1", + name: "sentry", + setupScript: "pnpm install", + updatedAt, + repos: [ + { + provider: "github", + repo: "getsentry/sentry", + checkoutPath: "sentry", + isPrimary: true, + }, + { + provider: "github", + repo: "getsentry/relay", + checkoutPath: "relay", + isPrimary: false, + }, + ], + }); + const second = create("node22", { + id: "workspace-1", + name: "sentry", + setupScript: "pnpm install", + updatedAt, + repos: [ + { + provider: "github", + repo: "getsentry/sentry", + checkoutPath: "sentry", + isPrimary: false, + }, + { + provider: "github", + repo: "getsentry/relay", + checkoutPath: "relay", + isPrimary: true, + }, + ], + }); + + expect(first?.hash).toBe(second?.hash); + }); + + it("layers workspace profiles on the base hash without reinstall deps", () => { + dependenciesMock.mockReturnValue([ + { type: "npm", package: "example", version: "1.2.3" }, + ]); + const workspace = { + id: "workspace-1", + name: "sentry", + setupScript: "pnpm install", + updatedAt: new Date("2026-03-10T00:00:00.000Z"), + repos: [ + { + provider: "github", + repo: "getsentry/sentry", + checkoutPath: "sentry", + isPrimary: true, + }, + ], + }; + + const base = create("node22"); + const layered = create("node22", workspace); + + expect(base).not.toBeNull(); + expect(layered).not.toBeNull(); + expect(layered?.baseHash).toBe(base?.hash); + expect(layered?.hash).not.toBe(base?.hash); + expect(layered?.dependencies).toEqual([]); + expect(layered?.postinstall).toEqual([]); + expect(layered?.floating).toBe(true); + expect(layered?.dependencyCount).toBe(base?.dependencyCount); + }); + + it("busts workspace hashes when the base dependency profile changes", () => { + const workspace = { + id: "workspace-1", + name: "sentry", + setupScript: "pnpm install", + updatedAt: new Date("2026-03-10T00:00:00.000Z"), + repos: [ + { + provider: "github", + repo: "getsentry/sentry", + checkoutPath: "sentry", + isPrimary: true, + }, + ], + }; + + dependenciesMock.mockReturnValue([ + { type: "npm", package: "example", version: "1.2.3" }, + ]); + const first = create("node22", workspace); + + dependenciesMock.mockReturnValue([ + { type: "npm", package: "example", version: "2.0.0" }, + ]); + const second = create("node22", workspace); + + expect(first?.baseHash).not.toBe(second?.baseHash); + expect(first?.hash).not.toBe(second?.hash); + }); + it("changes the hash when the rebuild epoch changes", () => { dependenciesMock.mockReturnValue([ { type: "npm", package: "example", version: "1.2.3" }, diff --git a/packages/junior/tests/unit/tools/workspaces.test.ts b/packages/junior/tests/unit/tools/workspaces.test.ts new file mode 100644 index 0000000000..4e0584e256 --- /dev/null +++ b/packages/junior/tests/unit/tools/workspaces.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from "vitest"; +import { createWorkspaceTools } from "@/chat/workspaces/tools"; + +const { getDbMock, listWorkspacesMock, getWorkspaceByNameMock } = vi.hoisted( + () => ({ + getDbMock: vi.fn(() => ({})), + listWorkspacesMock: vi.fn(), + getWorkspaceByNameMock: vi.fn(), + }), +); + +vi.mock("@/chat/db", () => ({ getDb: getDbMock })); +vi.mock("@/chat/workspaces/store", () => ({ + listWorkspaces: listWorkspacesMock, + getWorkspaceByName: getWorkspaceByNameMock, +})); + +const workspace = { + id: "workspace-1", + name: "sentry", + setupScript: "pnpm install", + updatedAt: new Date("2026-03-10T00:00:00.000Z"), + repos: [ + { + provider: "github", + repo: "getsentry/sentry", + checkoutPath: "sentry", + isPrimary: true, + }, + ], +}; + +describe("workspace tools", () => { + it("lists and switches registered workspaces", async () => { + listWorkspacesMock.mockResolvedValue([workspace]); + getWorkspaceByNameMock.mockResolvedValue(workspace); + const switchWorkspace = vi.fn(); + const tools = createWorkspaceTools({ + workspaces: { + activeWorkspaceId: () => undefined, + switch: switchWorkspace, + }, + } as never); + + const listed = await tools.listWorkspaces!.execute!({}, {}); + expect(listed).toMatchObject({ + active_workspace_id: null, + workspaces: [{ id: "workspace-1", name: "sentry" }], + }); + + const switched = await tools.switchWorkspace!.execute!({ name: "sentry" }, {}); + expect(switchWorkspace).toHaveBeenCalledWith(workspace, undefined); + expect(switched).toMatchObject({ workspace: { name: "sentry" } }); + }); +});