From cd6b0e269894507f7b8bc12dedca4763f742e3ca Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:42:10 +0000 Subject: [PATCH 01/34] feat(workspaces): Add switchable repository workspaces Co-Authored-By: David Cramer --- TERMINOLOGY.md | 2 + packages/junior-github/src/plugin.ts | 40 +++++++++ .../junior-github/tests/github-plugin.test.ts | 43 ++++++++++ packages/junior-plugin-api/src/hooks.ts | 2 + packages/junior-plugin-api/src/tools.ts | 5 ++ packages/junior/src/chat/agent/sandbox.ts | 7 ++ packages/junior/src/chat/agent/tools.ts | 12 +++ .../junior/src/chat/plugins/agent-hooks.ts | 17 ++++ .../junior/src/chat/runtime/thread-state.ts | 3 + packages/junior/src/chat/sandbox/ref.ts | 1 + packages/junior/src/chat/sandbox/sandbox.ts | 16 +++- packages/junior/src/chat/sandbox/session.ts | 48 ++++++++++- .../src/chat/sandbox/snapshot/profile.ts | 20 +++-- .../src/chat/sandbox/snapshot/resolve.ts | 10 ++- packages/junior/src/chat/tools/index.ts | 2 + packages/junior/src/chat/tools/types.ts | 5 ++ packages/junior/src/chat/workspaces/store.ts | 81 ++++++++++++++++++ packages/junior/src/chat/workspaces/tools.ts | 83 +++++++++++++++++++ packages/junior/src/chat/workspaces/types.ts | 14 ++++ packages/junior/src/db/schema.ts | 5 ++ packages/junior/src/db/schema/workspaces.ts | 41 +++++++++ .../unit/sandbox/snapshot/profile.test.ts | 21 +++++ .../tests/unit/tools/workspaces.test.ts | 50 +++++++++++ 23 files changed, 517 insertions(+), 11 deletions(-) create mode 100644 packages/junior/src/chat/workspaces/store.ts create mode 100644 packages/junior/src/chat/workspaces/tools.ts create mode 100644 packages/junior/src/chat/workspaces/types.ts create mode 100644 packages/junior/src/db/schema/workspaces.ts create mode 100644 packages/junior/tests/unit/tools/workspaces.test.ts diff --git a/TERMINOLOGY.md b/TERMINOLOGY.md index d5d6f1477b..0133f95c9e 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 e27f78741b..75caabaf62 100644 --- a/packages/junior-github/src/plugin.ts +++ b/packages/junior-github/src/plugin.ts @@ -836,6 +836,46 @@ export function githubPlugin( tools(ctx) { return createGitHubTools(ctx); }, + async workspacePrepare(ctx) { + const repos = ctx.repos.map((repo) => { + const [owner, name, ...rest] = repo.split("/"); + if (!owner || !name || rest.length > 0) { + throw new Error(`Invalid GitHub repository: ${repo}`); + } + return { owner, name, repo }; + }); + const token = await issueInstallationToken({ + appIdEnv, + privateKeyEnv, + installationIdEnv, + permissions: { contents: "read" }, + repositories: repos.map(({ name }) => name), + }); + for (const { owner, name, repo } of repos) { + const result = await ctx.sandbox.run({ + cmd: "git", + args: [ + "clone", + "--quiet", + "--depth=1", + "--", + `https://github.com/${owner}/${name}.git`, + name, + ], + 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/tests/github-plugin.test.ts b/packages/junior-github/tests/github-plugin.test.ts index 7e67dd9892..7a82e203ff 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"; @@ -2826,6 +2827,48 @@ 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: ["getsentry/sentry", "getsentry/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("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 0adea1587d..9b84abb47a 100644 --- a/packages/junior-plugin-api/src/hooks.ts +++ b/packages/junior-plugin-api/src/hooks.ts @@ -32,6 +32,7 @@ import type { PluginToolDefinition, SandboxPrepareHookContext, ToolRegistrationHookContext, + WorkspacePrepareHookContext, } from "./tools"; import type { PromptMessage, @@ -105,6 +106,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..c18bd5a979 100644 --- a/packages/junior-plugin-api/src/tools.ts +++ b/packages/junior-plugin-api/src/tools.ts @@ -140,6 +140,11 @@ export interface PluginMcp { prepare(): Promise<"authorization_pending" | "ready">; } +export interface WorkspacePrepareHookContext extends PluginContext { + repos: string[]; + sandbox: PluginSandbox; +} + export interface SandboxPrepareHookContext extends PluginContext { actor?: Actor; sandbox: PluginSandbox; diff --git a/packages/junior/src/chat/agent/sandbox.ts b/packages/junior/src/chat/agent/sandbox.ts index 18bd6a5428..f778834ee9 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,6 +41,7 @@ export interface AgentSandboxOptions { configurationValues: Record; getActiveSkill(): Skill | null; prepareSandbox(workspace: SandboxWorkspace): void | Promise; + prepareWorkspace?(workspace: SandboxWorkspace, recipe: Workspace): Promise; onSandboxRefChanged(sandboxRef: SandboxRef): void; persistSandboxRef?(sandboxRef: SandboxRef): void | Promise; } @@ -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,6 +151,7 @@ export function createAgentSandbox(options: AgentSandboxOptions): AgentSandbox { egressSignals: options.egressSignals, credentialEgress: options.credentialEgress, prepare: options.prepareSandbox, + prepareWorkspace: options.prepareWorkspace, onSandboxRefChanged: async (sandboxRef) => { options.onSandboxRefChanged(sandboxRef); 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..272b585a36 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"; @@ -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, }); @@ -361,6 +369,10 @@ export async function wireAgentTools( ...commonToolRuntimeContext, ...toolRoute, attachmentStorage: args.run.environment?.attachmentStorage, + workspaces: { + activeWorkspaceId: () => agentSandbox.sandboxRef()?.workspaceId, + switch: agentSandbox.switchWorkspace, + }, } as ToolRuntimeContext; const actionReview = createToolActionReview({ context: { diff --git a/packages/junior/src/chat/plugins/agent-hooks.ts b/packages/junior/src/chat/plugins/agent-hooks.ts index a1996e2e94..c9d0e61bba 100644 --- a/packages/junior/src/chat/plugins/agent-hooks.ts +++ b/packages/junior/src/chat/plugins/agent-hooks.ts @@ -91,6 +91,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 }>): Promise; } let registeredPlugins: PluginRegistration[] = []; @@ -1376,6 +1377,22 @@ 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) => 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/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..2729773b51 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,6 +101,7 @@ export interface SandboxOptions { credentialEgress?: CredentialContext; egressSignals?: SandboxEgressSignalTransport; prepare?: (workspace: SandboxWorkspace) => void | Promise; + prepareWorkspace?: (workspace: SandboxWorkspace, recipe: Workspace) => Promise; onSandboxRefChanged?: (sandboxRef: SandboxRef) => void | Promise; } @@ -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.repo.split("/").at(-1)}` + : 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..b0c2b3ed0e 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 { @@ -35,6 +34,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; @@ -103,6 +104,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 +118,7 @@ interface ActiveSandbox { interface SandboxRuntimeOptions { sandboxRef?: SandboxRef; + workspace?: Workspace; skills: SkillMetadata[]; referenceFiles: string[]; timeoutMs?: number; @@ -126,6 +129,7 @@ interface SandboxRuntimeOptions { traceHeaders?: TracePropagationHeaders, ) => NetworkPolicy | undefined; onSandboxPrepare?: (sandbox: SandboxSession) => void | Promise; + onWorkspacePrepare?: (sandbox: SandboxSession, workspace: Workspace) => Promise; onSandboxRefChanged?: (sandboxRef: SandboxRef) => void | Promise; } @@ -184,7 +188,8 @@ export function createSandboxRuntime( const timeoutMs = options.timeoutMs ?? 1000 * 60 * 30; const traceContext = options.traceContext ?? {}; - const dependencyProfileHash = profileHash(SANDBOX_RUNTIME); + let activeWorkspace = options.workspace; + let dependencyProfileHash = profileHash(SANDBOX_RUNTIME, activeWorkspace); const resolveCommandEnv = options.commandEnv ?? (async () => ({}) as Record); @@ -235,11 +240,13 @@ export function createSandboxRuntime( const nextRef: SandboxRef = { id: nextSandbox.sandboxId, ...(dependencyProfileHash ? { profileHash: dependencyProfileHash } : {}), + ...(activeWorkspace ? { workspaceId: activeWorkspace.id } : {}), }; sandboxRef = nextRef; if ( reportedSandboxRef?.id === nextRef.id && - reportedSandboxRef.profileHash === nextRef.profileHash + reportedSandboxRef.profileHash === nextRef.profileHash && + reportedSandboxRef.workspaceId === nextRef.workspaceId ) { return; } @@ -442,6 +449,10 @@ export function createSandboxRuntime( forceRebuild: true, staleSnapshotId: snapshot.snapshotId, signal, + workspace: activeWorkspace, + prepareWorkspace: activeWorkspace + ? async (sandbox) => await prepareWorkspaceSnapshot(sandbox, activeWorkspace!) + : undefined, }); if (!rebuiltSnapshot.snapshotId) { throw error; @@ -457,6 +468,24 @@ export function createSandboxRuntime( } }; + const prepareWorkspaceSnapshot = async ( + sandbox: SandboxSession, + workspace: Workspace, + ): Promise => { + 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, + }); + if (result.exitCode !== 0) { + throw new Error( + `Workspace setup failed: ${result.stderr.trim() || `exit ${result.exitCode}`}`, + ); + } + }; + const createFreshSandbox = async ( signal?: AbortSignal, ): Promise => { @@ -479,6 +508,10 @@ export function createSandboxRuntime( runtime, timeoutMs, signal, + workspace: activeWorkspace, + prepareWorkspace: activeWorkspace + ? async (sandbox) => await prepareWorkspaceSnapshot(sandbox, activeWorkspace!) + : undefined, }); signal?.throwIfAborted(); setSnapshotAttributes(snapshot); @@ -878,6 +911,15 @@ export function createSandboxRuntime( sandboxRef() { return sandboxRef ? { ...sandboxRef } : undefined; }, + async switchWorkspace(workspace, signal) { + if (activeWorkspace?.id === workspace.id && activeSandbox) return; + await activeSandbox?.session.stop(); + activeWorkspace = workspace; + dependencyProfileHash = profileHash(SANDBOX_RUNTIME, workspace); + activeSandbox = null; + sandboxRef = undefined; + await getOrAcquireSandbox(signal); + }, 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..f67ed3430d 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, @@ -78,14 +79,14 @@ function floatingMaxAgeMs(): number { } /** Build the dependency profile that selects a reusable sandbox snapshot. */ -export function create(runtime: string): Profile | null { +export function create(runtime: string, workspace?: Workspace): Profile | null { const dependencies = mergeDependencies([ ...GLOBAL_RUNTIME_DEPENDENCIES, ...pluginCatalogRuntime.getRuntimeDependencies(), ]); const pluginPostinstall = pluginCatalogRuntime.getRuntimePostinstall(); const postinstall = [...GLOBAL_RUNTIME_POSTINSTALL, ...pluginPostinstall]; - if (dependencies.length === 0 && postinstall.length === 0) { + if (dependencies.length === 0 && postinstall.length === 0 && !workspace) { return null; } @@ -94,7 +95,8 @@ export function create(runtime: string): Profile | null { // containing them expire on the same schedule as floating npm selectors. const floating = dependencies.some((dependency) => isFloating(dependency)) || - pluginPostinstall.length > 0; + pluginPostinstall.length > 0 || + Boolean(workspace); const hash = createHash("sha256") .update( JSON.stringify({ @@ -103,6 +105,14 @@ export function create(runtime: string): Profile | null { rebuildEpoch, dependencies, postinstall, + workspace: workspace + ? { + id: workspace.id, + updatedAt: workspace.updatedAt.toISOString(), + repos: workspace.repos, + setupScript: workspace.setupScript, + } + : undefined, }), ) .digest("hex"); @@ -117,8 +127,8 @@ export function create(runtime: string): Profile | null { } /** 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..0270e1aa59 100644 --- a/packages/junior/src/chat/sandbox/snapshot/resolve.ts +++ b/packages/junior/src/chat/sandbox/snapshot/resolve.ts @@ -5,8 +5,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 @@ -111,6 +112,7 @@ async function build( runtime: string, timeoutMs: number, signal?: AbortSignal, + prepare?: (sandbox: SandboxSession) => Promise, ): Promise { return await trace( "sandbox.snapshot.build", @@ -135,6 +137,7 @@ async function build( try { await install.dependencies(sandbox, value.dependencies, signal); await install.postinstall(sandbox, value.postinstall, signal); + await prepare?.(sandbox); return await trace( "sandbox.snapshot.capture", "sandbox.snapshot.capture", @@ -275,6 +278,8 @@ export async function resolve(params: { staleSnapshotId?: string; onProgress?: (phase: ProgressPhase) => void | Promise; signal?: AbortSignal; + workspace?: Workspace; + prepareWorkspace?: (sandbox: SandboxSession) => Promise; }): Promise { return await trace( "sandbox.snapshot.resolve", @@ -286,7 +291,7 @@ export async function resolve(params: { 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, @@ -350,6 +355,7 @@ export async function resolve(params: { params.runtime, params.timeoutMs, params.signal, + params.prepareWorkspace, ); await setCachedSnapshot({ profileHash: currentProfile.hash, diff --git a/packages/junior/src/chat/tools/index.ts b/packages/junior/src/chat/tools/index.ts index 04f154b310..e245a42d36 100644 --- a/packages/junior/src/chat/tools/index.ts +++ b/packages/junior/src/chat/tools/index.ts @@ -54,6 +54,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(); @@ -116,6 +117,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..05c315e759 100644 --- a/packages/junior/src/chat/tools/types.ts +++ b/packages/junior/src/chat/tools/types.ts @@ -22,6 +22,7 @@ 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 { AttachmentStorage } from "@/chat/attachments/storage"; +import type { Workspace } from "@/chat/workspaces/types"; interface HandoffControl { /** Non-empty catalog of configured targets. */ @@ -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..a67e7ee1e6 --- /dev/null +++ b/packages/junior/src/chat/workspaces/store.ts @@ -0,0 +1,81 @@ +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, + 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.repo), + ), + ]); + 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.repo)); + 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.repo)); + 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..8ac11717b0 --- /dev/null +++ b/packages/junior/src/chat/workspaces/tools.ts @@ -0,0 +1,83 @@ +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(), + 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, + 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..2828e534d9 --- /dev/null +++ b/packages/junior/src/chat/workspaces/types.ts @@ -0,0 +1,14 @@ +export interface WorkspaceRepo { + provider: string; + repo: 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 55371b38bf..cf7c33d39d 100644 --- a/packages/junior/src/db/schema.ts +++ b/packages/junior/src/db/schema.ts @@ -21,6 +21,7 @@ import { juniorSchedulerTasks, } from "./schema/scheduled-tasks"; import { juniorUsers } from "./schema/users"; +import { juniorWorkspaceRepos, juniorWorkspaces } from "./schema/workspaces"; export { juniorArtifacts, @@ -42,6 +43,8 @@ export { juniorSchedulerRuns, juniorSchedulerTasks, juniorUsers, + juniorWorkspaceRepos, + juniorWorkspaces, }; export const juniorSqlSchema = { @@ -64,4 +67,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..7a9e10fa48 --- /dev/null +++ b/packages/junior/src/db/schema/workspaces.ts @@ -0,0 +1,41 @@ +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(), + isPrimary: boolean("is_primary").notNull().default(false), + }, + (table) => [ + primaryKey({ columns: [table.workspaceId, table.provider, table.repo] }), + uniqueIndex("junior_workspace_repos_primary_idx") + .on(table.workspaceId) + .where(sql`${table.isPrimary}`), + ], +); diff --git a/packages/junior/tests/unit/sandbox/snapshot/profile.test.ts b/packages/junior/tests/unit/sandbox/snapshot/profile.test.ts index b7110e3143..ea60db0de6 100644 --- a/packages/junior/tests/unit/sandbox/snapshot/profile.test.ts +++ b/packages/junior/tests/unit/sandbox/snapshot/profile.test.ts @@ -77,6 +77,27 @@ 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", 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("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..7c889f4b07 --- /dev/null +++ b/packages/junior/tests/unit/tools/workspaces.test.ts @@ -0,0 +1,50 @@ +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", 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" } }); + }); +}); From cb5caaff73e09577ed26c387b77cd009bdbed1c4 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:59:05 +0000 Subject: [PATCH 02/34] fix(workspaces): Harden switch lifecycle and checkout paths Abort in-flight sandbox acquires on switch, roll recipe state back if the new boot fails, and store explicit checkout paths so multi-repo workspaces cannot collide. --- packages/junior-github/src/plugin.ts | 15 +++--- .../junior-github/tests/github-plugin.test.ts | 5 +- packages/junior-plugin-api/src/tools.ts | 5 +- .../junior/src/chat/plugins/agent-hooks.ts | 7 ++- packages/junior/src/chat/sandbox/sandbox.ts | 2 +- packages/junior/src/chat/sandbox/session.ts | 52 +++++++++++++++++-- packages/junior/src/chat/workspaces/store.ts | 1 + packages/junior/src/chat/workspaces/tools.ts | 2 + packages/junior/src/chat/workspaces/types.ts | 1 + packages/junior/src/db/schema/workspaces.ts | 5 ++ .../unit/sandbox/snapshot/profile.test.ts | 7 ++- .../tests/unit/tools/workspaces.test.ts | 7 ++- 12 files changed, 92 insertions(+), 17 deletions(-) diff --git a/packages/junior-github/src/plugin.ts b/packages/junior-github/src/plugin.ts index 75caabaf62..3e8bcf53c5 100644 --- a/packages/junior-github/src/plugin.ts +++ b/packages/junior-github/src/plugin.ts @@ -837,12 +837,15 @@ export function githubPlugin( return createGitHubTools(ctx); }, async workspacePrepare(ctx) { - const repos = ctx.repos.map((repo) => { - const [owner, name, ...rest] = repo.split("/"); + 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: ${repo}`); + throw new Error(`Invalid GitHub repository: ${entry.repo}`); } - return { owner, name, repo }; + if (!/^[A-Za-z0-9._-]+$/.test(entry.path) || entry.path === "." || 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, @@ -851,7 +854,7 @@ export function githubPlugin( permissions: { contents: "read" }, repositories: repos.map(({ name }) => name), }); - for (const { owner, name, repo } of repos) { + for (const { owner, name, path, repo } of repos) { const result = await ctx.sandbox.run({ cmd: "git", args: [ @@ -860,7 +863,7 @@ export function githubPlugin( "--depth=1", "--", `https://github.com/${owner}/${name}.git`, - name, + path, ], cwd: ctx.sandbox.root, env: { diff --git a/packages/junior-github/tests/github-plugin.test.ts b/packages/junior-github/tests/github-plugin.test.ts index 7a82e203ff..28774a11d9 100644 --- a/packages/junior-github/tests/github-plugin.test.ts +++ b/packages/junior-github/tests/github-plugin.test.ts @@ -2841,7 +2841,10 @@ Conversation: \`local:test:old-conversation\` db, log: pluginLog, plugin: { name: "github" }, - repos: ["getsentry/sentry", "getsentry/junior"], + repos: [ + { repo: "getsentry/sentry", path: "sentry" }, + { repo: "getsentry/junior", path: "junior" }, + ], sandbox: { juniorRoot: "/vercel/sandbox/.junior", root: "/vercel/sandbox", diff --git a/packages/junior-plugin-api/src/tools.ts b/packages/junior-plugin-api/src/tools.ts index c18bd5a979..49c53dda88 100644 --- a/packages/junior-plugin-api/src/tools.ts +++ b/packages/junior-plugin-api/src/tools.ts @@ -141,7 +141,10 @@ export interface PluginMcp { } export interface WorkspacePrepareHookContext extends PluginContext { - repos: string[]; + repos: Array<{ + path: string; + repo: string; + }>; sandbox: PluginSandbox; } diff --git a/packages/junior/src/chat/plugins/agent-hooks.ts b/packages/junior/src/chat/plugins/agent-hooks.ts index c9d0e61bba..6b3f9f2e3b 100644 --- a/packages/junior/src/chat/plugins/agent-hooks.ts +++ b/packages/junior/src/chat/plugins/agent-hooks.ts @@ -91,7 +91,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 }>): Promise; + prepareWorkspace?(workspace: SandboxWorkspace, repos: Array<{ provider: string; repo: string; checkoutPath: string }>): Promise; } let registeredPlugins: PluginRegistration[] = []; @@ -1384,7 +1384,10 @@ export function createPluginHookRunner( if (!hook) continue; const selected = repos .filter((repo) => repo.provider === plugin.manifest.name) - .map((repo) => repo.repo); + .map((repo) => ({ + path: repo.checkoutPath, + repo: repo.repo, + })); if (selected.length === 0) continue; await hook({ ...basePluginContext(plugin), diff --git a/packages/junior/src/chat/sandbox/sandbox.ts b/packages/junior/src/chat/sandbox/sandbox.ts index 2729773b51..3c70fb805d 100644 --- a/packages/junior/src/chat/sandbox/sandbox.ts +++ b/packages/junior/src/chat/sandbox/sandbox.ts @@ -796,7 +796,7 @@ export function createSandbox(options: SandboxOptions): SandboxAccess { const { fs } = await runtime.tools(); const primary = activeWorkspace?.repos.find((repo) => repo.isPrimary); const selected = primary - ? `${SANDBOX_WORKSPACE_ROOT}/${primary.repo.split("/").at(-1)}` + ? `${SANDBOX_WORKSPACE_ROOT}/${primary.checkoutPath}` : await findSingleRepositoryDirectory(fs); if (!selected) return undefined; return await resolveRepositoryInstructions({ diff --git a/packages/junior/src/chat/sandbox/session.ts b/packages/junior/src/chat/sandbox/session.ts index b0c2b3ed0e..5382d103a2 100644 --- a/packages/junior/src/chat/sandbox/session.ts +++ b/packages/junior/src/chat/sandbox/session.ts @@ -912,13 +912,57 @@ export function createSandboxRuntime( return sandboxRef ? { ...sandboxRef } : undefined; }, async switchWorkspace(workspace, signal) { - if (activeWorkspace?.id === workspace.id && activeSandbox) return; - await activeSandbox?.session.stop(); + if (activeWorkspace?.id === workspace.id && activeSandbox) { + return; + } + + const previousWorkspace = activeWorkspace; + const previousProfileHash = dependencyProfileHash; + + // Point the recipe at the target first so any concurrent re-acquire after + // an aborted boot uses the new workspace instead of the old one. activeWorkspace = workspace; dependencyProfileHash = profileHash(SANDBOX_RUNTIME, workspace); - activeSandbox = null; sandboxRef = undefined; - await getOrAcquireSandbox(signal); + + 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. + } + } + + const previousSandbox = activeSandbox; + activeSandbox = null; + if (keepAliveTimer) { + clearTimeout(keepAliveTimer); + keepAliveTimer = undefined; + } + if (previousSandbox) { + try { + await previousSandbox.session.stop(); + } catch { + // Best-effort stop of the sandbox being replaced. + } + } + + try { + await getOrAcquireSandbox(signal); + } catch (error) { + // Roll back recipe identity so AGENTS.md selection and the next boot + // stay aligned. The previous live sandbox is gone, so drop its id hint. + activeWorkspace = previousWorkspace; + dependencyProfileHash = previousProfileHash; + activeSandbox = null; + sandboxRef = undefined; + throw error; + } }, async acquire(signal) { return await getOrAcquireSandbox(signal); diff --git a/packages/junior/src/chat/workspaces/store.ts b/packages/junior/src/chat/workspaces/store.ts index a67e7ee1e6..8af74c9190 100644 --- a/packages/junior/src/chat/workspaces/store.ts +++ b/packages/junior/src/chat/workspaces/store.ts @@ -15,6 +15,7 @@ function workspaceFromRows( repos: repos.map((repo) => ({ provider: repo.provider, repo: repo.repo, + checkoutPath: repo.checkoutPath, isPrimary: repo.isPrimary, })), }; diff --git a/packages/junior/src/chat/workspaces/tools.ts b/packages/junior/src/chat/workspaces/tools.ts index 8ac11717b0..17ca1511bf 100644 --- a/packages/junior/src/chat/workspaces/tools.ts +++ b/packages/junior/src/chat/workspaces/tools.ts @@ -10,6 +10,7 @@ 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({ @@ -25,6 +26,7 @@ function view(workspace: Awaited>[number]) { repos: workspace.repos.map((repo) => ({ provider: repo.provider, repo: repo.repo, + checkout_path: repo.checkoutPath, is_primary: repo.isPrimary, })), }; diff --git a/packages/junior/src/chat/workspaces/types.ts b/packages/junior/src/chat/workspaces/types.ts index 2828e534d9..3460aa7562 100644 --- a/packages/junior/src/chat/workspaces/types.ts +++ b/packages/junior/src/chat/workspaces/types.ts @@ -1,6 +1,7 @@ export interface WorkspaceRepo { provider: string; repo: string; + checkoutPath: string; isPrimary: boolean; } diff --git a/packages/junior/src/db/schema/workspaces.ts b/packages/junior/src/db/schema/workspaces.ts index 7a9e10fa48..8d4b731e75 100644 --- a/packages/junior/src/db/schema/workspaces.ts +++ b/packages/junior/src/db/schema/workspaces.ts @@ -30,10 +30,15 @@ export const juniorWorkspaceRepos = pgTable( .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/unit/sandbox/snapshot/profile.test.ts b/packages/junior/tests/unit/sandbox/snapshot/profile.test.ts index ea60db0de6..214a932774 100644 --- a/packages/junior/tests/unit/sandbox/snapshot/profile.test.ts +++ b/packages/junior/tests/unit/sandbox/snapshot/profile.test.ts @@ -84,7 +84,12 @@ describe("snapshot dependency profile", () => { setupScript: "pnpm install", updatedAt: new Date("2026-03-10T00:00:00.000Z"), repos: [ - { provider: "github", repo: "getsentry/sentry", isPrimary: true }, + { + provider: "github", + repo: "getsentry/sentry", + checkoutPath: "sentry", + isPrimary: true, + }, ], }; diff --git a/packages/junior/tests/unit/tools/workspaces.test.ts b/packages/junior/tests/unit/tools/workspaces.test.ts index 7c889f4b07..4e0584e256 100644 --- a/packages/junior/tests/unit/tools/workspaces.test.ts +++ b/packages/junior/tests/unit/tools/workspaces.test.ts @@ -21,7 +21,12 @@ const workspace = { setupScript: "pnpm install", updatedAt: new Date("2026-03-10T00:00:00.000Z"), repos: [ - { provider: "github", repo: "getsentry/sentry", isPrimary: true }, + { + provider: "github", + repo: "getsentry/sentry", + checkoutPath: "sentry", + isPrimary: true, + }, ], }; From 76bb2b27507efbfc5acd6e6d852040b3c099f84b Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:08:15 +0000 Subject: [PATCH 03/34] fix(workspaces): Clear failed switch state --- .../junior/src/chat/agent-invocations/work.ts | 2 +- packages/junior/src/chat/agent/sandbox.ts | 6 +-- packages/junior/src/chat/agent/tools.ts | 2 +- packages/junior/src/chat/agent/types.ts | 2 +- packages/junior/src/chat/api-turns/work.ts | 2 +- packages/junior/src/chat/local/runner.ts | 2 +- packages/junior/src/chat/sandbox/sandbox.ts | 2 +- packages/junior/src/chat/sandbox/session.ts | 4 +- .../component/misc/sandbox-executor.test.ts | 50 +++++++++++++++++++ 9 files changed, 62 insertions(+), 10 deletions(-) diff --git a/packages/junior/src/chat/agent-invocations/work.ts b/packages/junior/src/chat/agent-invocations/work.ts index b19512b66a..1f52894433 100644 --- a/packages/junior/src/chat/agent-invocations/work.ts +++ b/packages/junior/src/chat/agent-invocations/work.ts @@ -382,7 +382,7 @@ export function createAgentInvocationWorker(options: { onInputCommitted: acknowledge, shouldYield: context.shouldYield, onSandboxRefChanged: async (nextSandboxRef) => { - sandboxRef = nextSandboxRef; + sandboxRef = nextSandboxRef ?? undefined; await persistThreadStateById(invocation.childConversationId, { sandboxRef, }); diff --git a/packages/junior/src/chat/agent/sandbox.ts b/packages/junior/src/chat/agent/sandbox.ts index f778834ee9..a14e015fec 100644 --- a/packages/junior/src/chat/agent/sandbox.ts +++ b/packages/junior/src/chat/agent/sandbox.ts @@ -42,8 +42,8 @@ export interface AgentSandboxOptions { getActiveSkill(): Skill | null; prepareSandbox(workspace: SandboxWorkspace): void | Promise; prepareWorkspace?(workspace: SandboxWorkspace, recipe: Workspace): Promise; - onSandboxRefChanged(sandboxRef: SandboxRef): void; - persistSandboxRef?(sandboxRef: SandboxRef): void | Promise; + onSandboxRefChanged(sandboxRef: SandboxRef | undefined): void; + persistSandboxRef?(sandboxRef: SandboxRef | null): void | Promise; } export interface AgentSandbox { @@ -153,7 +153,7 @@ export function createAgentSandbox(options: AgentSandboxOptions): AgentSandbox { prepare: options.prepareSandbox, prepareWorkspace: options.prepareWorkspace, onSandboxRefChanged: async (sandboxRef) => { - options.onSandboxRefChanged(sandboxRef); + options.onSandboxRefChanged(sandboxRef ?? undefined); await options.persistSandboxRef?.(sandboxRef); }, }); diff --git a/packages/junior/src/chat/agent/tools.ts b/packages/junior/src/chat/agent/tools.ts index 272b585a36..6972092c11 100644 --- a/packages/junior/src/chat/agent/tools.ts +++ b/packages/junior/src/chat/agent/tools.ts @@ -95,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"]; diff --git a/packages/junior/src/chat/agent/types.ts b/packages/junior/src/chat/agent/types.ts index 81cd17b59b..ac537073a0 100644 --- a/packages/junior/src/chat/agent/types.ts +++ b/packages/junior/src/chat/agent/types.ts @@ -157,7 +157,7 @@ export type AgentDurability = { recordPendingAuth?: ( pendingAuth: ConversationPendingAuthState | undefined, ) => void | Promise; - onSandboxRefChanged?: (sandboxRef: SandboxRef) => void | Promise; + 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..e341c25022 100644 --- a/packages/junior/src/chat/api-turns/work.ts +++ b/packages/junior/src/chat/api-turns/work.ts @@ -794,7 +794,7 @@ export function createApiTurnWorker(options: { onInputCommitted: acknowledge, shouldYield: context.shouldYield, onSandboxRefChanged: async (nextSandboxRef) => { - sandboxRef = nextSandboxRef; + sandboxRef = nextSandboxRef ?? undefined; await persistThreadStateById(context.conversationId, { conversation, sandboxRef, diff --git a/packages/junior/src/chat/local/runner.ts b/packages/junior/src/chat/local/runner.ts index 5c9ca139a8..cfd792c603 100644 --- a/packages/junior/src/chat/local/runner.ts +++ b/packages/junior/src/chat/local/runner.ts @@ -371,7 +371,7 @@ async function runLocalAgentTurnInContext( delivery: deliverAssistantMessage, durability: { onSandboxRefChanged: async (nextSandboxRef) => { - sandboxRef = nextSandboxRef; + sandboxRef = nextSandboxRef ?? undefined; await persistThreadStateById(input.conversationId, { conversation, sandboxRef, diff --git a/packages/junior/src/chat/sandbox/sandbox.ts b/packages/junior/src/chat/sandbox/sandbox.ts index 3c70fb805d..00b8b446e5 100644 --- a/packages/junior/src/chat/sandbox/sandbox.ts +++ b/packages/junior/src/chat/sandbox/sandbox.ts @@ -102,7 +102,7 @@ export interface SandboxOptions { egressSignals?: SandboxEgressSignalTransport; prepare?: (workspace: SandboxWorkspace) => void | Promise; prepareWorkspace?: (workspace: SandboxWorkspace, recipe: Workspace) => Promise; - onSandboxRefChanged?: (sandboxRef: SandboxRef) => void | Promise; + onSandboxRefChanged?: (sandboxRef: SandboxRef | null) => void | Promise; } interface SandboxToolCallContext { diff --git a/packages/junior/src/chat/sandbox/session.ts b/packages/junior/src/chat/sandbox/session.ts index 5382d103a2..f3d9c16b1a 100644 --- a/packages/junior/src/chat/sandbox/session.ts +++ b/packages/junior/src/chat/sandbox/session.ts @@ -130,7 +130,7 @@ interface SandboxRuntimeOptions { ) => NetworkPolicy | undefined; onSandboxPrepare?: (sandbox: SandboxSession) => void | Promise; onWorkspacePrepare?: (sandbox: SandboxSession, workspace: Workspace) => Promise; - onSandboxRefChanged?: (sandboxRef: SandboxRef) => void | Promise; + onSandboxRefChanged?: (sandboxRef: SandboxRef | null) => void | Promise; } function truncateOutput( @@ -961,6 +961,8 @@ export function createSandboxRuntime( dependencyProfileHash = previousProfileHash; activeSandbox = null; sandboxRef = undefined; + reportedSandboxRef = undefined; + await options.onSandboxRefChanged?.(null); throw error; } }, diff --git a/packages/junior/tests/component/misc/sandbox-executor.test.ts b/packages/junior/tests/component/misc/sandbox-executor.test.ts index f14735cdfd..c72d20cf24 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,54 @@ describe("createTestSandbox", () => { expect(sandboxCreateMock).toHaveBeenCalledTimes(1); }); + 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: (ref) => { + refs.push(ref); + }, + }); + + await runtime.acquire(); + await expect(runtime.switchWorkspace(nextWorkspace)).rejects.toThrow( + "sandbox setup failed", + ); + + expect(refs).toEqual([ + { id: "sbx_workspace_initial", workspaceId: "workspace-initial" }, + { id: "sbx_workspace_failed", workspaceId: "workspace-next" }, + null, + ]); + expect(runtime.sandboxRef()).toBeUndefined(); + }); + it("surfaces a generic sandbox setup failure for non-recoverable sync errors", async () => { const forbiddenSandbox = makeSandbox("sbx_forbidden", { mkDirError: createApiError( From 620dc55126483e9ac20dbec95b12495ccde9b518 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:13:18 +0000 Subject: [PATCH 04/34] fix(workspaces): Persist null sandbox clear on failed switch Durability handlers were coercing null to undefined before thread-state persist, so failed workspace switches left stale sandbox/workspace ids. --- packages/junior/src/chat/agent-invocations/work.ts | 4 +++- packages/junior/src/chat/api-turns/work.ts | 4 +++- packages/junior/src/chat/local/runner.ts | 4 +++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/junior/src/chat/agent-invocations/work.ts b/packages/junior/src/chat/agent-invocations/work.ts index 1f52894433..d4a16676dc 100644 --- a/packages/junior/src/chat/agent-invocations/work.ts +++ b/packages/junior/src/chat/agent-invocations/work.ts @@ -382,9 +382,11 @@ export function createAgentInvocationWorker(options: { onInputCommitted: acknowledge, shouldYield: context.shouldYield, onSandboxRefChanged: async (nextSandboxRef) => { + // Keep the in-memory run hint optional, but pass null through so + // ThreadStatePatch can clear durable sandbox/workspace ids. sandboxRef = nextSandboxRef ?? undefined; await persistThreadStateById(invocation.childConversationId, { - sandboxRef, + sandboxRef: nextSandboxRef, }); }, }, diff --git a/packages/junior/src/chat/api-turns/work.ts b/packages/junior/src/chat/api-turns/work.ts index e341c25022..da840b6c89 100644 --- a/packages/junior/src/chat/api-turns/work.ts +++ b/packages/junior/src/chat/api-turns/work.ts @@ -794,10 +794,12 @@ export function createApiTurnWorker(options: { onInputCommitted: acknowledge, shouldYield: context.shouldYield, onSandboxRefChanged: async (nextSandboxRef) => { + // Keep the in-memory run hint optional, but pass null through so + // ThreadStatePatch can clear durable sandbox/workspace ids. 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 cfd792c603..2d6df6b49c 100644 --- a/packages/junior/src/chat/local/runner.ts +++ b/packages/junior/src/chat/local/runner.ts @@ -371,10 +371,12 @@ async function runLocalAgentTurnInContext( delivery: deliverAssistantMessage, durability: { onSandboxRefChanged: async (nextSandboxRef) => { + // Keep the in-memory run hint optional, but pass null through so + // ThreadStatePatch can clear durable sandbox/workspace ids. sandboxRef = nextSandboxRef ?? undefined; await persistThreadStateById(input.conversationId, { conversation, - sandboxRef, + sandboxRef: nextSandboxRef, }); }, recordPendingAuth: async (pendingAuth) => { From 1bf4250d4508393b96eaf6074c6265551f8d9221 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:26:57 +0000 Subject: [PATCH 05/34] perf(workspaces): Extend base snapshots for workspace builds Workspace profiles now hash baseHash + recipe and build by booting the cached base dependency snapshot, then running prepare. Base busts still invalidate workspace snapshots because the base hash is in the key. Co-Authored-By: immutable dcramer --- .../src/chat/sandbox/snapshot/profile.ts | 71 ++- .../src/chat/sandbox/snapshot/resolve.ts | 417 ++++++++++++------ .../sandbox/snapshot/resolve.test.ts | 163 ++++++- .../unit/sandbox/snapshot/profile.test.ts | 62 +++ 4 files changed, 560 insertions(+), 153 deletions(-) diff --git a/packages/junior/src/chat/sandbox/snapshot/profile.ts b/packages/junior/src/chat/sandbox/snapshot/profile.ts index f67ed3430d..7c70aa1e7c 100644 --- a/packages/junior/src/chat/sandbox/snapshot/profile.ts +++ b/packages/junior/src/chat/sandbox/snapshot/profile.ts @@ -19,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 { @@ -78,15 +84,24 @@ function floatingMaxAgeMs(): number { : DEFAULT_FLOATING_MAX_AGE_MS; } -/** Build the dependency profile that selects a reusable sandbox snapshot. */ -export function create(runtime: string, workspace?: Workspace): Profile | null { +function workspaceRecipe(workspace: Workspace) { + return { + id: workspace.id, + updatedAt: workspace.updatedAt.toISOString(), + repos: workspace.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(), ]); const pluginPostinstall = pluginCatalogRuntime.getRuntimePostinstall(); const postinstall = [...GLOBAL_RUNTIME_POSTINSTALL, ...pluginPostinstall]; - if (dependencies.length === 0 && postinstall.length === 0 && !workspace) { + if (dependencies.length === 0 && postinstall.length === 0) { return null; } @@ -95,8 +110,7 @@ export function create(runtime: string, workspace?: Workspace): Profile | null { // containing them expire on the same schedule as floating npm selectors. const floating = dependencies.some((dependency) => isFloating(dependency)) || - pluginPostinstall.length > 0 || - Boolean(workspace); + pluginPostinstall.length > 0; const hash = createHash("sha256") .update( JSON.stringify({ @@ -105,14 +119,6 @@ export function create(runtime: string, workspace?: Workspace): Profile | null { rebuildEpoch, dependencies, postinstall, - workspace: workspace - ? { - id: workspace.id, - updatedAt: workspace.updatedAt.toISOString(), - repos: workspace.repos, - setupScript: workspace.setupScript, - } - : undefined, }), ) .digest("hex"); @@ -126,6 +132,45 @@ export function create(runtime: string, workspace?: Workspace): 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, workspace?: Workspace): string | undefined { return create(runtime, workspace)?.hash; diff --git a/packages/junior/src/chat/sandbox/snapshot/resolve.ts b/packages/junior/src/chat/sandbox/snapshot/resolve.ts index 0270e1aa59..efc62e898c 100644 --- a/packages/junior/src/chat/sandbox/snapshot/resolve.ts +++ b/packages/junior/src/chat/sandbox/snapshot/resolve.ts @@ -65,6 +65,17 @@ 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}`; } @@ -107,7 +118,75 @@ async function setCachedSnapshot(entry: CachedSnapshot): Promise { ); } -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, @@ -120,42 +199,73 @@ 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); + }); + }, + ); +} - try { - await install.dependencies(sandbox, value.dependencies, signal); - await install.postinstall(sandbox, value.postinstall, signal); - await prepare?.(sandbox); - 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 { +/** + * Boot from a base dependency snapshot, run workspace prepare, and capture. + * Retries once after rebuilding the base when the parent snapshot is missing. + */ +async function buildWorkspaceFromBase(params: { + value: profile.Profile; + runtime: string; + timeoutMs: number; + baseSnapshotId: string; + signal?: AbortSignal; + prepare?: (sandbox: SandboxSession) => Promise; + rebuildBase: () => 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 sourceSnapshotId = params.baseSnapshotId; + for (let attempt = 0; attempt < 2; attempt += 1) { + params.signal?.throwIfAborted(); try { - await sandbox.stop(); - } catch { - // Snapshot creation may already finalize the sandbox; cleanup stays best-effort. + const sandbox = await createBuildSandbox({ + runtime: params.runtime, + timeoutMs: params.timeoutMs, + signal: params.signal, + sourceSnapshotId, + }); + return await withBuildSandbox(sandbox, async (active) => { + await params.prepare?.(active); + return await captureSnapshot( + active, + params.value.dependencyCount, + params.signal, + ); + }); + } catch (error) { + if (attempt > 0 || !isMissingError(error)) { + throw error; + } + sourceSnapshotId = await params.rebuildBase(); } } + throw new Error("Failed to build workspace snapshot from base"); }, ); } @@ -270,23 +380,162 @@ function getRebuildReason(params: { return undefined; } +async function resolveProfile( + params: ResolveParams, + currentProfile: profile.Profile, +): Promise { + 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 buildProfileSnapshot( + params, + currentProfile, + ); + 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 } : {}), + }; +} + +async function buildProfileSnapshot( + params: ResolveParams, + currentProfile: profile.Profile, +): Promise { + // Base profiles install deps from a fresh runtime. Workspace profiles extend + // the cached base snapshot when one exists; otherwise prepare on fresh runtime. + if (!currentProfile.baseHash) { + return await buildBase( + currentProfile, + params.runtime, + params.timeoutMs, + params.signal, + params.prepareWorkspace, + ); + } + + const baseSnapshot = await resolve({ + runtime: params.runtime, + timeoutMs: params.timeoutMs, + // Workspace force-rebuild should still reuse a healthy base when possible. + // A missing parent snapshot rebuilds base via the normal missing path. + signal: params.signal, + onProgress: params.onProgress, + }); + + if (!baseSnapshot.snapshotId) { + return await buildBase( + currentProfile, + params.runtime, + params.timeoutMs, + params.signal, + params.prepareWorkspace, + ); + } + + return await buildWorkspaceFromBase({ + value: currentProfile, + runtime: params.runtime, + timeoutMs: params.timeoutMs, + baseSnapshotId: baseSnapshot.snapshotId, + signal: params.signal, + prepare: params.prepareWorkspace, + rebuildBase: async () => { + const rebuilt = await resolve({ + runtime: params.runtime, + timeoutMs: params.timeoutMs, + forceRebuild: true, + staleSnapshotId: baseSnapshot.snapshotId, + signal: params.signal, + onProgress: params.onProgress, + }); + if (!rebuilt.snapshotId) { + throw new Error("Failed to rebuild base snapshot for workspace"); + } + return rebuilt.snapshotId; + }, + }); +} + /** 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; - workspace?: Workspace; - prepareWorkspace?: (sandbox: SandboxSession) => Promise; -}): 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(); @@ -300,91 +549,7 @@ 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, - params.prepareWorkspace, - ); - 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 await resolveProfile(params, currentProfile); }, ); } diff --git a/packages/junior/tests/component/sandbox/snapshot/resolve.test.ts b/packages/junior/tests/component/sandbox/snapshot/resolve.test.ts index 6338a0b39f..609858da80 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,131 @@ 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 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/unit/sandbox/snapshot/profile.test.ts b/packages/junior/tests/unit/sandbox/snapshot/profile.test.ts index 214a932774..44e9830c86 100644 --- a/packages/junior/tests/unit/sandbox/snapshot/profile.test.ts +++ b/packages/junior/tests/unit/sandbox/snapshot/profile.test.ts @@ -103,6 +103,68 @@ describe("snapshot dependency profile", () => { expect(first?.hash).not.toBe(changed?.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" }, From 1d0e084282024e37fd8f95683e8c6aa285ae4590 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:53:33 +0000 Subject: [PATCH 06/34] fix(workspaces): Tighten snapshot cache invariants Key workspace snapshots by the resolved base snapshot id, refresh same-id recipes when their profile changes, and document the owning lifecycle rules. Co-Authored-By: immutable dcramer --- .../junior/src/chat/agent-invocations/work.ts | 2 - packages/junior/src/chat/agent/types.ts | 1 + packages/junior/src/chat/api-turns/work.ts | 2 - packages/junior/src/chat/local/runner.ts | 2 - packages/junior/src/chat/sandbox/README.md | 11 +- packages/junior/src/chat/sandbox/session.ts | 9 +- .../src/chat/sandbox/snapshot/resolve.ts | 218 ++++++++++-------- .../component/misc/sandbox-executor.test.ts | 33 +++ .../sandbox/snapshot/resolve.test.ts | 55 +++++ 9 files changed, 224 insertions(+), 109 deletions(-) diff --git a/packages/junior/src/chat/agent-invocations/work.ts b/packages/junior/src/chat/agent-invocations/work.ts index d4a16676dc..71603aa2a4 100644 --- a/packages/junior/src/chat/agent-invocations/work.ts +++ b/packages/junior/src/chat/agent-invocations/work.ts @@ -382,8 +382,6 @@ export function createAgentInvocationWorker(options: { onInputCommitted: acknowledge, shouldYield: context.shouldYield, onSandboxRefChanged: async (nextSandboxRef) => { - // Keep the in-memory run hint optional, but pass null through so - // ThreadStatePatch can clear durable sandbox/workspace ids. sandboxRef = nextSandboxRef ?? undefined; await persistThreadStateById(invocation.childConversationId, { sandboxRef: nextSandboxRef, diff --git a/packages/junior/src/chat/agent/types.ts b/packages/junior/src/chat/agent/types.ts index ac537073a0..c8418e783b 100644 --- a/packages/junior/src/chat/agent/types.ts +++ b/packages/junior/src/chat/agent/types.ts @@ -157,6 +157,7 @@ export type AgentDurability = { recordPendingAuth?: ( pendingAuth: ConversationPendingAuthState | undefined, ) => void | Promise; + /** Persist a replacement sandbox reference; null clears the durable reference. */ onSandboxRefChanged?: (sandboxRef: SandboxRef | null) => void | Promise; }; diff --git a/packages/junior/src/chat/api-turns/work.ts b/packages/junior/src/chat/api-turns/work.ts index da840b6c89..a5699ddca9 100644 --- a/packages/junior/src/chat/api-turns/work.ts +++ b/packages/junior/src/chat/api-turns/work.ts @@ -794,8 +794,6 @@ export function createApiTurnWorker(options: { onInputCommitted: acknowledge, shouldYield: context.shouldYield, onSandboxRefChanged: async (nextSandboxRef) => { - // Keep the in-memory run hint optional, but pass null through so - // ThreadStatePatch can clear durable sandbox/workspace ids. sandboxRef = nextSandboxRef ?? undefined; await persistThreadStateById(context.conversationId, { conversation, diff --git a/packages/junior/src/chat/local/runner.ts b/packages/junior/src/chat/local/runner.ts index 2d6df6b49c..512dd4b1a6 100644 --- a/packages/junior/src/chat/local/runner.ts +++ b/packages/junior/src/chat/local/runner.ts @@ -371,8 +371,6 @@ async function runLocalAgentTurnInContext( delivery: deliverAssistantMessage, durability: { onSandboxRefChanged: async (nextSandboxRef) => { - // Keep the in-memory run hint optional, but pass null through so - // ThreadStatePatch can clear durable sandbox/workspace ids. sandboxRef = nextSandboxRef ?? undefined; await persistThreadStateById(input.conversationId, { conversation, diff --git a/packages/junior/src/chat/sandbox/README.md b/packages/junior/src/chat/sandbox/README.md index 3f9a52b224..c6883cb993 100644 --- a/packages/junior/src/chat/sandbox/README.md +++ b/packages/junior/src/chat/sandbox/README.md @@ -8,9 +8,10 @@ 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. - 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 +46,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/session.ts b/packages/junior/src/chat/sandbox/session.ts index f3d9c16b1a..5aaba24c24 100644 --- a/packages/junior/src/chat/sandbox/session.ts +++ b/packages/junior/src/chat/sandbox/session.ts @@ -912,7 +912,12 @@ export function createSandboxRuntime( return sandboxRef ? { ...sandboxRef } : undefined; }, async switchWorkspace(workspace, signal) { - if (activeWorkspace?.id === workspace.id && activeSandbox) { + const nextProfileHash = profileHash(SANDBOX_RUNTIME, workspace); + if ( + activeWorkspace?.id === workspace.id && + dependencyProfileHash === nextProfileHash && + activeSandbox + ) { return; } @@ -922,7 +927,7 @@ export function createSandboxRuntime( // Point the recipe at the target first so any concurrent re-acquire after // an aborted boot uses the new workspace instead of the old one. activeWorkspace = workspace; - dependencyProfileHash = profileHash(SANDBOX_RUNTIME, workspace); + dependencyProfileHash = nextProfileHash; sandboxRef = undefined; const inFlight = acquiringSandbox; diff --git a/packages/junior/src/chat/sandbox/snapshot/resolve.ts b/packages/junior/src/chat/sandbox/snapshot/resolve.ts index efc62e898c..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"; @@ -80,17 +81,27 @@ 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; } @@ -108,11 +119,14 @@ 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, ); @@ -217,10 +231,17 @@ async function buildBase( ); } -/** - * Boot from a base dependency snapshot, run workspace prepare, and capture. - * Retries once after rebuilding the base when the parent snapshot is missing. - */ +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; @@ -228,7 +249,6 @@ async function buildWorkspaceFromBase(params: { baseSnapshotId: string; signal?: AbortSignal; prepare?: (sandbox: SandboxSession) => Promise; - rebuildBase: () => Promise; }): Promise { return await trace( "sandbox.snapshot.build", @@ -240,38 +260,35 @@ async function buildWorkspaceFromBase(params: { "app.sandbox.snapshot.base_hash": params.value.baseHash, }, async () => { - let sourceSnapshotId = params.baseSnapshotId; - for (let attempt = 0; attempt < 2; attempt += 1) { - params.signal?.throwIfAborted(); - try { - const sandbox = await createBuildSandbox({ - runtime: params.runtime, - timeoutMs: params.timeoutMs, - signal: params.signal, - sourceSnapshotId, - }); - return await withBuildSandbox(sandbox, async (active) => { - await params.prepare?.(active); - return await captureSnapshot( - active, - params.value.dependencyCount, - params.signal, - ); - }); - } catch (error) { - if (attempt > 0 || !isMissingError(error)) { - throw error; - } - sourceSnapshotId = await params.rebuildBase(); + let sandbox: SandboxSession; + try { + 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; } - throw new Error("Failed to build workspace snapshot from base"); + 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<{ @@ -285,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); @@ -311,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, @@ -339,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, @@ -383,8 +400,13 @@ function getRebuildReason(params: { async function resolveProfile( params: ResolveParams, currentProfile: profile.Profile, + options: { + build?: () => Promise; + cacheIdentity?: string; + } = {}, ): Promise { - const cached = await getCachedSnapshot(currentProfile.hash); + const cacheIdentity = options.cacheIdentity ?? currentProfile.hash; + const cached = await getCachedSnapshot(cacheIdentity, currentProfile.hash); const cachedNeedsRebuild = Boolean( cached?.snapshotId && profile.isStale(currentProfile, cached.createdAtMs), @@ -421,10 +443,14 @@ async function resolveProfile( }; const lockResult = await withBuildLock( + cacheIdentity, currentProfile.hash, params.timeoutMs, async () => { - const latest = await getCachedSnapshot(currentProfile.hash); + const latest = await getCachedSnapshot( + cacheIdentity, + currentProfile.hash, + ); if (latest?.snapshotId && canUseCachedSnapshot(latest)) { await params.onProgress?.("cache_hit"); return { @@ -434,11 +460,16 @@ async function resolveProfile( } await params.onProgress?.("building_snapshot"); - const nextSnapshotId = await buildProfileSnapshot( - params, - currentProfile, - ); - await setCachedSnapshot({ + 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, @@ -468,63 +499,52 @@ async function resolveProfile( }; } -async function buildProfileSnapshot( +async function resolveWorkspaceProfile( params: ResolveParams, currentProfile: profile.Profile, -): Promise { - // Base profiles install deps from a fresh runtime. Workspace profiles extend - // the cached base snapshot when one exists; otherwise prepare on fresh runtime. - if (!currentProfile.baseHash) { - return await buildBase( - currentProfile, - params.runtime, - params.timeoutMs, - params.signal, - params.prepareWorkspace, - ); - } - - const baseSnapshot = await resolve({ - runtime: params.runtime, - timeoutMs: params.timeoutMs, - // Workspace force-rebuild should still reuse a healthy base when possible. - // A missing parent snapshot rebuilds base via the normal missing path. - signal: params.signal, - onProgress: params.onProgress, - }); - - if (!baseSnapshot.snapshotId) { - return await buildBase( - currentProfile, - params.runtime, - params.timeoutMs, - params.signal, - params.prepareWorkspace, - ); - } +): 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"); + } - return await buildWorkspaceFromBase({ - value: currentProfile, - runtime: params.runtime, - timeoutMs: params.timeoutMs, - baseSnapshotId: baseSnapshot.snapshotId, - signal: params.signal, - prepare: params.prepareWorkspace, - rebuildBase: async () => { - const rebuilt = await resolve({ - runtime: params.runtime, - timeoutMs: params.timeoutMs, - forceRebuild: true, - staleSnapshotId: baseSnapshot.snapshotId, - signal: params.signal, - onProgress: params.onProgress, + 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, + }), }); - if (!rebuilt.snapshotId) { - throw new Error("Failed to rebuild base snapshot for workspace"); + } catch (error) { + if (attempt > 0 || !(error instanceof MissingBaseSnapshotError)) { + throw error; } - return rebuilt.snapshotId; - }, - }); + staleBaseSnapshotId = error.snapshotId; + } + } + throw new Error("Failed to resolve workspace sandbox snapshot"); } /** Resolve or build the reusable snapshot for the current dependency profile. */ @@ -549,7 +569,9 @@ export async function resolve(params: ResolveParams): Promise { }; } - return await resolveProfile(params, currentProfile); + return currentProfile.baseHash + ? await resolveWorkspaceProfile(params, currentProfile) + : await resolveProfile(params, currentProfile); }, ); } diff --git a/packages/junior/tests/component/misc/sandbox-executor.test.ts b/packages/junior/tests/component/misc/sandbox-executor.test.ts index c72d20cf24..3fcaa34806 100644 --- a/packages/junior/tests/component/misc/sandbox-executor.test.ts +++ b/packages/junior/tests/component/misc/sandbox-executor.test.ts @@ -1008,6 +1008,39 @@ 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("clears a durably reported workspace when its switch fails", async () => { const initialSandbox = makeSandbox("sbx_workspace_initial"); const failedSandbox = makeSandbox("sbx_workspace_failed"); diff --git a/packages/junior/tests/component/sandbox/snapshot/resolve.test.ts b/packages/junior/tests/component/sandbox/snapshot/resolve.test.ts index 609858da80..9de17e8491 100644 --- a/packages/junior/tests/component/sandbox/snapshot/resolve.test.ts +++ b/packages/junior/tests/component/sandbox/snapshot/resolve.test.ts @@ -473,6 +473,61 @@ describe("snapshot resolution", () => { 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" }, From b425ca2b534dd1d04ef72a843c6082d217a6056c Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 22:06:59 +0000 Subject: [PATCH 07/34] fix(workspaces): Keep durable ref on same-recipe switch Idempotent switchWorkspace must no-op on matching recipe even when the provider session is cold, or it clears sandboxRef and boots a fresh sandbox. --- packages/junior/src/chat/sandbox/session.ts | 5 +-- .../component/misc/sandbox-executor.test.ts | 31 +++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/packages/junior/src/chat/sandbox/session.ts b/packages/junior/src/chat/sandbox/session.ts index 5aaba24c24..b20a2b6a9a 100644 --- a/packages/junior/src/chat/sandbox/session.ts +++ b/packages/junior/src/chat/sandbox/session.ts @@ -913,10 +913,11 @@ export function createSandboxRuntime( }, async switchWorkspace(workspace, signal) { const nextProfileHash = profileHash(SANDBOX_RUNTIME, workspace); + // Same recipe is a no-op even when the provider session is cold. Clearing + // sandboxRef here would force a fresh boot and drop durable working state. if ( activeWorkspace?.id === workspace.id && - dependencyProfileHash === nextProfileHash && - activeSandbox + dependencyProfileHash === nextProfileHash ) { return; } diff --git a/packages/junior/tests/component/misc/sandbox-executor.test.ts b/packages/junior/tests/component/misc/sandbox-executor.test.ts index 3fcaa34806..727245846a 100644 --- a/packages/junior/tests/component/misc/sandbox-executor.test.ts +++ b/packages/junior/tests/component/misc/sandbox-executor.test.ts @@ -1041,6 +1041,37 @@ describe("createTestSandbox", () => { expect(runtime.sandboxRef()?.id).toBe("sbx_workspace_refreshed"); }); + 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("clears a durably reported workspace when its switch fails", async () => { const initialSandbox = makeSandbox("sbx_workspace_initial"); const failedSandbox = makeSandbox("sbx_workspace_failed"); From 8ae01144a0200c2488321984ba1a8203a0fd6912 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 00:31:05 +0000 Subject: [PATCH 08/34] fix(workspaces): Start keepalive after workspace switch Successful switchWorkspace now uses ensureReadySandbox so the replacement session gets the same probe and keepalive path as normal tool acquisition. --- packages/junior/src/chat/sandbox/session.ts | 4 +- .../component/misc/sandbox-executor.test.ts | 42 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/packages/junior/src/chat/sandbox/session.ts b/packages/junior/src/chat/sandbox/session.ts index b20a2b6a9a..aa155002fa 100644 --- a/packages/junior/src/chat/sandbox/session.ts +++ b/packages/junior/src/chat/sandbox/session.ts @@ -959,7 +959,9 @@ export function createSandboxRuntime( } try { - await getOrAcquireSandbox(signal); + // Route through ensureReadySandbox so the replacement session gets the + // same probe + keepalive path as normal tool acquisition. + await ensureReadySandbox(signal); } catch (error) { // Roll back recipe identity so AGENTS.md selection and the next boot // stay aligned. The previous live sandbox is gone, so drop its id hint. diff --git a/packages/junior/tests/component/misc/sandbox-executor.test.ts b/packages/junior/tests/component/misc/sandbox-executor.test.ts index 727245846a..7d6e05dc7c 100644 --- a/packages/junior/tests/component/misc/sandbox-executor.test.ts +++ b/packages/junior/tests/component/misc/sandbox-executor.test.ts @@ -1072,6 +1072,48 @@ describe("createTestSandbox", () => { }); }); + 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"); From 9fdd057fca01de528fbd07271df0403fb95bfb4a Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 02:09:44 +0000 Subject: [PATCH 09/34] fix(workspaces): Check cancellation before switch Reject an already-aborted workspace switch before mutating recipe state or stopping the live sandbox. --- packages/junior/src/chat/sandbox/session.ts | 1 + .../component/misc/sandbox-executor.test.ts | 39 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/packages/junior/src/chat/sandbox/session.ts b/packages/junior/src/chat/sandbox/session.ts index aa155002fa..4a0654f8a9 100644 --- a/packages/junior/src/chat/sandbox/session.ts +++ b/packages/junior/src/chat/sandbox/session.ts @@ -912,6 +912,7 @@ export function createSandboxRuntime( return sandboxRef ? { ...sandboxRef } : undefined; }, async switchWorkspace(workspace, signal) { + signal?.throwIfAborted(); const nextProfileHash = profileHash(SANDBOX_RUNTIME, workspace); // Same recipe is a no-op even when the provider session is cold. Clearing // sandboxRef here would force a fresh boot and drop durable working state. diff --git a/packages/junior/tests/component/misc/sandbox-executor.test.ts b/packages/junior/tests/component/misc/sandbox-executor.test.ts index 7d6e05dc7c..e20ee580b4 100644 --- a/packages/junior/tests/component/misc/sandbox-executor.test.ts +++ b/packages/junior/tests/component/misc/sandbox-executor.test.ts @@ -1041,6 +1041,45 @@ describe("createTestSandbox", () => { 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("keeps a durable same-recipe sandbox when switch is repeated cold", async () => { hashMock.mockReturnValue("profile-same"); const workspace = { From a111c5c5deb7853ef628172f04713cfb2ed90aec Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 03:17:41 +0000 Subject: [PATCH 10/34] fix(workspaces): Stabilize workspace repo ordering Order workspace repos by provider/repo/checkout path in the store and normalize that order in the snapshot profile hash so identical recipes do not rebuild snapshots from query-order churn. --- .../src/chat/sandbox/snapshot/profile.ts | 10 +++- packages/junior/src/chat/workspaces/store.ts | 14 +++++- .../unit/sandbox/snapshot/profile.test.ts | 46 +++++++++++++++++++ 3 files changed, 67 insertions(+), 3 deletions(-) diff --git a/packages/junior/src/chat/sandbox/snapshot/profile.ts b/packages/junior/src/chat/sandbox/snapshot/profile.ts index 7c70aa1e7c..7a3dee4813 100644 --- a/packages/junior/src/chat/sandbox/snapshot/profile.ts +++ b/packages/junior/src/chat/sandbox/snapshot/profile.ts @@ -85,10 +85,18 @@ function floatingMaxAgeMs(): number { } function workspaceRecipe(workspace: Workspace) { + // Sort repos so profile hashes stay stable when query order differs. + const repos = [...workspace.repos].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: workspace.repos, + repos, setupScript: workspace.setupScript, }; } diff --git a/packages/junior/src/chat/workspaces/store.ts b/packages/junior/src/chat/workspaces/store.ts index 8af74c9190..33207cc26c 100644 --- a/packages/junior/src/chat/workspaces/store.ts +++ b/packages/junior/src/chat/workspaces/store.ts @@ -30,7 +30,9 @@ export async function listWorkspaces(db: JuniorDatabase): Promise { .from(juniorWorkspaceRepos) .orderBy( asc(juniorWorkspaceRepos.workspaceId), + asc(juniorWorkspaceRepos.provider), asc(juniorWorkspaceRepos.repo), + asc(juniorWorkspaceRepos.checkoutPath), ), ]); return workspaces.map((workspace) => @@ -57,7 +59,11 @@ export async function getWorkspaceByName( .select() .from(juniorWorkspaceRepos) .where(eq(juniorWorkspaceRepos.workspaceId, workspace.id)) - .orderBy(asc(juniorWorkspaceRepos.repo)); + .orderBy( + asc(juniorWorkspaceRepos.provider), + asc(juniorWorkspaceRepos.repo), + asc(juniorWorkspaceRepos.checkoutPath), + ); return workspaceFromRows(workspace, repos); } @@ -77,6 +83,10 @@ export async function getWorkspace( .select() .from(juniorWorkspaceRepos) .where(eq(juniorWorkspaceRepos.workspaceId, workspace.id)) - .orderBy(asc(juniorWorkspaceRepos.repo)); + .orderBy( + asc(juniorWorkspaceRepos.provider), + asc(juniorWorkspaceRepos.repo), + asc(juniorWorkspaceRepos.checkoutPath), + ); return workspaceFromRows(workspace, repos); } diff --git a/packages/junior/tests/unit/sandbox/snapshot/profile.test.ts b/packages/junior/tests/unit/sandbox/snapshot/profile.test.ts index 44e9830c86..1a6b5f04ae 100644 --- a/packages/junior/tests/unit/sandbox/snapshot/profile.test.ts +++ b/packages/junior/tests/unit/sandbox/snapshot/profile.test.ts @@ -103,6 +103,52 @@ describe("snapshot dependency profile", () => { 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("layers workspace profiles on the base hash without reinstall deps", () => { dependenciesMock.mockReturnValue([ { type: "npm", package: "example", version: "1.2.3" }, From 81cd36f332956e18a726aab1b0969cdb0f3e2d45 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 04:23:29 +0000 Subject: [PATCH 11/34] fix(sandbox): Stop leaked replacement on failed workspace switch If ensureReadySandbox fails after acquisition, stop the replacement session instead of dropping the only reference without cleanup. --- packages/junior/src/chat/sandbox/session.ts | 49 +++++++++---------- packages/junior/src/chat/sandbox/workspace.ts | 12 +++++ .../component/misc/sandbox-executor.test.ts | 40 +++++++++++++++ 3 files changed, 74 insertions(+), 27 deletions(-) diff --git a/packages/junior/src/chat/sandbox/session.ts b/packages/junior/src/chat/sandbox/session.ts index 4a0654f8a9..f72118ca2c 100644 --- a/packages/junior/src/chat/sandbox/session.ts +++ b/packages/junior/src/chat/sandbox/session.ts @@ -27,6 +27,7 @@ import { import { syncSkillsToSandbox } from "@/chat/sandbox/skill-sync"; import { createSandboxSession, + stopSession, type SandboxCommandResult, type SandboxFileSystem, type SandboxSession, @@ -535,6 +536,7 @@ export function createSandboxRuntime( networkPolicyKey = await applyNetworkPolicy(createdSandbox); await prepareSandbox(createdSandbox); } catch (error) { + await stopSession(createdSandbox); return failSetup(error); } @@ -627,6 +629,7 @@ export function createSandboxRuntime( await prepareSandbox(hintedSandbox); return rememberSandbox(hintedSandbox, networkPolicyKey); } catch (error) { + await stopSession(hintedSandbox); if (isSandboxUnavailableError(error)) { throw error; } @@ -897,14 +900,16 @@ 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 { @@ -914,8 +919,7 @@ export function createSandboxRuntime( async switchWorkspace(workspace, signal) { signal?.throwIfAborted(); const nextProfileHash = profileHash(SANDBOX_RUNTIME, workspace); - // Same recipe is a no-op even when the provider session is cold. Clearing - // sandboxRef here would force a fresh boot and drop durable working state. + // Same recipe is a no-op even when cold. if ( activeWorkspace?.id === workspace.id && dependencyProfileHash === nextProfileHash @@ -926,8 +930,7 @@ export function createSandboxRuntime( const previousWorkspace = activeWorkspace; const previousProfileHash = dependencyProfileHash; - // Point the recipe at the target first so any concurrent re-acquire after - // an aborted boot uses the new workspace instead of the old one. + // Point at the target first so concurrent re-acquire uses it. activeWorkspace = workspace; dependencyProfileHash = nextProfileHash; sandboxRef = undefined; @@ -946,31 +949,23 @@ export function createSandboxRuntime( } const previousSandbox = activeSandbox; - activeSandbox = null; - if (keepAliveTimer) { - clearTimeout(keepAliveTimer); - keepAliveTimer = undefined; - } - if (previousSandbox) { - try { - await previousSandbox.session.stop(); - } catch { - // Best-effort stop of the sandbox being replaced. - } - } + invalidateSession(); + await stopSession(previousSandbox?.session); + // Local ref survives failures that clear activeSandbox after acquire. + let replacement: SandboxSession | undefined; try { - // Route through ensureReadySandbox so the replacement session gets the - // same probe + keepalive path as normal tool acquisition. - await ensureReadySandbox(signal); + await ensureReadySandbox(signal, (s) => { + replacement = s; + }); } catch (error) { - // Roll back recipe identity so AGENTS.md selection and the next boot - // stay aligned. The previous live sandbox is gone, so drop its id hint. + const failed = activeSandbox?.session ?? replacement; activeWorkspace = previousWorkspace; dependencyProfileHash = previousProfileHash; - activeSandbox = null; + invalidateSession(); sandboxRef = undefined; reportedSandboxRef = undefined; + await stopSession(failed); await options.onSandboxRefChanged?.(null); throw error; } 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/tests/component/misc/sandbox-executor.test.ts b/packages/junior/tests/component/misc/sandbox-executor.test.ts index e20ee580b4..3e6b1e4f63 100644 --- a/packages/junior/tests/component/misc/sandbox-executor.test.ts +++ b/packages/junior/tests/component/misc/sandbox-executor.test.ts @@ -1199,6 +1199,46 @@ describe("createTestSandbox", () => { null, ]); expect(runtime.sandboxRef()).toBeUndefined(); + expect(failedSandbox.stop).toHaveBeenCalledTimes(1); + }); + + 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(); + + expect(failedSandbox.stop).toHaveBeenCalledTimes(1); + expect(runtime.sandboxRef()).toBeUndefined(); }); it("surfaces a generic sandbox setup failure for non-recoverable sync errors", async () => { From c8f8e864c7a03dbf20eb1978e91a35f7189151ec Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 04:25:25 +0000 Subject: [PATCH 12/34] fix(sandbox): Preserve workspace boot failure --- packages/junior/src/chat/sandbox/session.ts | 6 +++++- .../junior/tests/component/misc/sandbox-executor.test.ts | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/junior/src/chat/sandbox/session.ts b/packages/junior/src/chat/sandbox/session.ts index f72118ca2c..30124db721 100644 --- a/packages/junior/src/chat/sandbox/session.ts +++ b/packages/junior/src/chat/sandbox/session.ts @@ -966,7 +966,11 @@ export function createSandboxRuntime( sandboxRef = undefined; reportedSandboxRef = undefined; await stopSession(failed); - await options.onSandboxRefChanged?.(null); + try { + await options.onSandboxRefChanged?.(null); + } catch { + // Preserve the boot error when rollback persistence also fails. + } throw error; } }, diff --git a/packages/junior/tests/component/misc/sandbox-executor.test.ts b/packages/junior/tests/component/misc/sandbox-executor.test.ts index 3e6b1e4f63..ea4903e6e9 100644 --- a/packages/junior/tests/component/misc/sandbox-executor.test.ts +++ b/packages/junior/tests/component/misc/sandbox-executor.test.ts @@ -1183,8 +1183,11 @@ describe("createTestSandbox", () => { throw new Error("prepare failed"); } }, - onSandboxRefChanged: (ref) => { + onSandboxRefChanged: async (ref) => { refs.push(ref); + if (ref === null) { + throw new Error("persistence failed"); + } }, }); From 36e05f1f3a32ec6df83e70acdc9dd13ee7c5da99 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 04:30:42 +0000 Subject: [PATCH 13/34] fix(sandbox): Keep restored sandbox on prepare failure Do not stop a resumed durable sandbox when network policy or prepare fails. Leave the VM for a later reacquire instead of wiping prior state. --- packages/junior/src/chat/sandbox/session.ts | 2 +- .../component/misc/sandbox-executor.test.ts | 24 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/junior/src/chat/sandbox/session.ts b/packages/junior/src/chat/sandbox/session.ts index 30124db721..9532c1a6ba 100644 --- a/packages/junior/src/chat/sandbox/session.ts +++ b/packages/junior/src/chat/sandbox/session.ts @@ -629,7 +629,7 @@ export function createSandboxRuntime( await prepareSandbox(hintedSandbox); return rememberSandbox(hintedSandbox, networkPolicyKey); } catch (error) { - await stopSession(hintedSandbox); + // Keep the durable VM alive so a later reacquire can reuse it. if (isSandboxUnavailableError(error)) { throw error; } diff --git a/packages/junior/tests/component/misc/sandbox-executor.test.ts b/packages/junior/tests/component/misc/sandbox-executor.test.ts index ea4903e6e9..b7a7bbd6aa 100644 --- a/packages/junior/tests/component/misc/sandbox-executor.test.ts +++ b/packages/junior/tests/component/misc/sandbox-executor.test.ts @@ -1285,6 +1285,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( From 08009cfc5ffa361ef7f906eedf1b31feaceef83d Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 05:42:10 +0000 Subject: [PATCH 14/34] fix(sandbox): Stabilize workspace switch and profile hashing Capture prepareWorkspace recipes by value, restore cold durable sandbox hints on failed switch, and omit isPrimary from snapshot profile hashes. --- packages/junior/src/chat/sandbox/session.ts | 70 +++++++++---------- .../src/chat/sandbox/snapshot/profile.ts | 21 ++++-- .../component/misc/sandbox-executor.test.ts | 39 +++++++++++ .../unit/sandbox/snapshot/profile.test.ts | 46 ++++++++++++ 4 files changed, 134 insertions(+), 42 deletions(-) diff --git a/packages/junior/src/chat/sandbox/session.ts b/packages/junior/src/chat/sandbox/session.ts index 9532c1a6ba..cc73749576 100644 --- a/packages/junior/src/chat/sandbox/session.ts +++ b/packages/junior/src/chat/sandbox/session.ts @@ -156,21 +156,14 @@ function parseKeepAliveMs(): number { 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, }; } @@ -201,13 +194,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 lazy reacquisition. */ const invalidateSession = (sessionId?: string): void => { - if ( - sessionId && - activeSandbox && - activeSandbox.session.sessionId !== sessionId - ) { + if (sessionId && activeSandbox?.session.sessionId !== sessionId) { return; } activeSandbox = null; @@ -444,15 +433,16 @@ 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: activeWorkspace, - prepareWorkspace: activeWorkspace - ? async (sandbox) => await prepareWorkspaceSnapshot(sandbox, activeWorkspace!) + workspace: recipe, + prepareWorkspace: recipe + ? async (sandbox) => await prepareWorkspaceSnapshot(sandbox, recipe) : undefined, }); if (!rebuiltSnapshot.snapshotId) { @@ -505,13 +495,14 @@ export function createSandboxRuntime( "app.sandbox.runtime": runtime, }, async () => { + const recipe = activeWorkspace; const snapshot = await resolveSnapshot({ runtime, timeoutMs, signal, - workspace: activeWorkspace, - prepareWorkspace: activeWorkspace - ? async (sandbox) => await prepareWorkspaceSnapshot(sandbox, activeWorkspace!) + workspace: recipe, + prepareWorkspace: recipe + ? async (sandbox) => await prepareWorkspaceSnapshot(sandbox, recipe) : undefined, }); signal?.throwIfAborted(); @@ -927,9 +918,13 @@ export function createSandboxRuntime( return; } - const previousWorkspace = activeWorkspace; - const previousProfileHash = dependencyProfileHash; - + 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; @@ -948,10 +943,8 @@ export function createSandboxRuntime( } } - const previousSandbox = activeSandbox; invalidateSession(); - await stopSession(previousSandbox?.session); - + await stopSession(previous.sandbox?.session); // Local ref survives failures that clear activeSandbox after acquire. let replacement: SandboxSession | undefined; try { @@ -960,16 +953,23 @@ export function createSandboxRuntime( }); } catch (error) { const failed = activeSandbox?.session ?? replacement; - activeWorkspace = previousWorkspace; - dependencyProfileHash = previousProfileHash; + const reportedNew = reportedSandboxRef !== previous.reportedSandboxRef; + activeWorkspace = previous.workspace; + dependencyProfileHash = previous.profileHash; invalidateSession(); - sandboxRef = undefined; - reportedSandboxRef = undefined; await stopSession(failed); - try { - await options.onSandboxRefChanged?.(null); - } catch { - // Preserve the boot error when rollback persistence also fails. + // Keep prior durable hint only when nothing was stopped or reported. + if (previous.sandbox || failed || reportedNew) { + sandboxRef = undefined; + reportedSandboxRef = undefined; + try { + await options.onSandboxRefChanged?.(null); + } catch { + // Preserve the boot error when rollback persistence also fails. + } + } else { + sandboxRef = previous.sandboxRef; + reportedSandboxRef = previous.reportedSandboxRef; } throw error; } diff --git a/packages/junior/src/chat/sandbox/snapshot/profile.ts b/packages/junior/src/chat/sandbox/snapshot/profile.ts index 7a3dee4813..bef7fa5977 100644 --- a/packages/junior/src/chat/sandbox/snapshot/profile.ts +++ b/packages/junior/src/chat/sandbox/snapshot/profile.ts @@ -86,13 +86,20 @@ function floatingMaxAgeMs(): number { function workspaceRecipe(workspace: Workspace) { // Sort repos so profile hashes stay stable when query order differs. - const repos = [...workspace.repos].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); - }); + // 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(), diff --git a/packages/junior/tests/component/misc/sandbox-executor.test.ts b/packages/junior/tests/component/misc/sandbox-executor.test.ts index b7a7bbd6aa..0ced3ca5a7 100644 --- a/packages/junior/tests/component/misc/sandbox-executor.test.ts +++ b/packages/junior/tests/component/misc/sandbox-executor.test.ts @@ -1244,6 +1244,45 @@ describe("createTestSandbox", () => { expect(runtime.sandboxRef()).toBeUndefined(); }); + 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( diff --git a/packages/junior/tests/unit/sandbox/snapshot/profile.test.ts b/packages/junior/tests/unit/sandbox/snapshot/profile.test.ts index 1a6b5f04ae..9e39bcccec 100644 --- a/packages/junior/tests/unit/sandbox/snapshot/profile.test.ts +++ b/packages/junior/tests/unit/sandbox/snapshot/profile.test.ts @@ -149,6 +149,52 @@ describe("snapshot dependency profile", () => { 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" }, From dc40c94c6a1004d5a14b1fa5088ddf0d3f37eabe Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 05:50:26 +0000 Subject: [PATCH 15/34] fix(sandbox): Stop late in-flight sandbox on workspace switch After aborting an in-flight acquire, stop any session remembered while awaiting that promise, not only the pre-await previous sandbox. --- packages/junior/src/chat/sandbox/session.ts | 8 +-- .../component/misc/sandbox-executor.test.ts | 53 +++++++++++++++++++ 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/packages/junior/src/chat/sandbox/session.ts b/packages/junior/src/chat/sandbox/session.ts index cc73749576..5af8834990 100644 --- a/packages/junior/src/chat/sandbox/session.ts +++ b/packages/junior/src/chat/sandbox/session.ts @@ -194,7 +194,7 @@ export function createSandboxRuntime( callback: () => Promise, ): Promise => withSpan(name, op, traceContext, callback, attributes); - /** Drop unavailable live state; keep the durable hint for lazy reacquisition. */ + /** Drop unavailable live state; keep the durable hint for reacquire. */ const invalidateSession = (sessionId?: string): void => { if (sessionId && activeSandbox?.session.sessionId !== sessionId) { return; @@ -943,9 +943,11 @@ export function createSandboxRuntime( } } + // After await: aborted acquire may still remember a session. + const late = activeSandbox; invalidateSession(); await stopSession(previous.sandbox?.session); - // Local ref survives failures that clear activeSandbox after acquire. + if (late && late !== previous.sandbox) await stopSession(late.session); let replacement: SandboxSession | undefined; try { await ensureReadySandbox(signal, (s) => { @@ -959,7 +961,7 @@ export function createSandboxRuntime( invalidateSession(); await stopSession(failed); // Keep prior durable hint only when nothing was stopped or reported. - if (previous.sandbox || failed || reportedNew) { + if (previous.sandbox || late || failed || reportedNew) { sandboxRef = undefined; reportedSandboxRef = undefined; try { diff --git a/packages/junior/tests/component/misc/sandbox-executor.test.ts b/packages/junior/tests/component/misc/sandbox-executor.test.ts index 0ced3ca5a7..9c884394e6 100644 --- a/packages/junior/tests/component/misc/sandbox-executor.test.ts +++ b/packages/junior/tests/component/misc/sandbox-executor.test.ts @@ -1244,6 +1244,59 @@ describe("createTestSandbox", () => { expect(runtime.sandboxRef()).toBeUndefined(); }); + 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); + // Late create reports a durable ref; force the next boot to recreate instead of restore. + sandboxGetMock.mockRejectedValueOnce( + createApiError(404, "Not Found", "not_found", "Sandbox was not found"), + ); + 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(); + 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") From 784e31a3ea1088444b42227a0803af74ead770c2 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 05:55:58 +0000 Subject: [PATCH 16/34] fix(sandbox): Clear stale hint after late in-flight stop When an aborted acquire still reports a durable sandbox ref, clear that hint after stopping the late session so switch boots the target fresh. --- packages/junior/src/chat/sandbox/session.ts | 16 ++++++++-------- .../component/misc/sandbox-executor.test.ts | 7 +++---- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/packages/junior/src/chat/sandbox/session.ts b/packages/junior/src/chat/sandbox/session.ts index 5af8834990..fd368f86c0 100644 --- a/packages/junior/src/chat/sandbox/session.ts +++ b/packages/junior/src/chat/sandbox/session.ts @@ -196,9 +196,7 @@ export function createSandboxRuntime( /** Drop unavailable live state; keep the durable hint for reacquire. */ const invalidateSession = (sessionId?: string): void => { - if (sessionId && activeSandbox?.session.sessionId !== sessionId) { - return; - } + if (sessionId && activeSandbox?.session.sessionId !== sessionId) return; activeSandbox = null; if (keepAliveTimer) { clearTimeout(keepAliveTimer); @@ -943,11 +941,14 @@ export function createSandboxRuntime( } } - // After await: aborted acquire may still remember a session. + // Aborted acquire may still remember a session and rewrite the durable hint. const late = activeSandbox; invalidateSession(); await stopSession(previous.sandbox?.session); - if (late && late !== previous.sandbox) await stopSession(late.session); + if (late && late !== previous.sandbox) { + await stopSession(late.session); + sandboxRef = reportedSandboxRef = undefined; + } let replacement: SandboxSession | undefined; try { await ensureReadySandbox(signal, (s) => { @@ -960,10 +961,9 @@ export function createSandboxRuntime( dependencyProfileHash = previous.profileHash; invalidateSession(); await stopSession(failed); - // Keep prior durable hint only when nothing was stopped or reported. + // Keep prior durable hint only if nothing was stopped or reported. if (previous.sandbox || late || failed || reportedNew) { - sandboxRef = undefined; - reportedSandboxRef = undefined; + sandboxRef = reportedSandboxRef = undefined; try { await options.onSandboxRefChanged?.(null); } catch { diff --git a/packages/junior/tests/component/misc/sandbox-executor.test.ts b/packages/junior/tests/component/misc/sandbox-executor.test.ts index 9c884394e6..d1ccd564d9 100644 --- a/packages/junior/tests/component/misc/sandbox-executor.test.ts +++ b/packages/junior/tests/component/misc/sandbox-executor.test.ts @@ -1257,10 +1257,6 @@ describe("createTestSandbox", () => { return lateSandbox; }) .mockResolvedValueOnce(nextSandbox); - // Late create reports a durable ref; force the next boot to recreate instead of restore. - sandboxGetMock.mockRejectedValueOnce( - createApiError(404, "Not Found", "not_found", "Sandbox was not found"), - ); hashMock .mockReturnValueOnce("profile-initial") .mockReturnValueOnce("profile-next"); @@ -1294,6 +1290,9 @@ describe("createTestSandbox", () => { 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"); }); From 5aba8fe0ab05de5eb132cb3eaa75d3329e6f1abc Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 10:37:31 +0000 Subject: [PATCH 17/34] fix(sandbox): Require toThrow message in workspace switch test Bare rejects.toThrow() failed lint under --deny-warnings. --- packages/junior/tests/component/misc/sandbox-executor.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/junior/tests/component/misc/sandbox-executor.test.ts b/packages/junior/tests/component/misc/sandbox-executor.test.ts index d1ccd564d9..55e66a1a28 100644 --- a/packages/junior/tests/component/misc/sandbox-executor.test.ts +++ b/packages/junior/tests/component/misc/sandbox-executor.test.ts @@ -1238,7 +1238,9 @@ describe("createTestSandbox", () => { }); await runtime.acquire(); - await expect(runtime.switchWorkspace(nextWorkspace)).rejects.toThrow(); + await expect(runtime.switchWorkspace(nextWorkspace)).rejects.toThrow( + "Status code 410 is not ok", + ); expect(failedSandbox.stop).toHaveBeenCalledTimes(1); expect(runtime.sandboxRef()).toBeUndefined(); From 882d1984559cc34cacaa9bd06406ea9f761e5c34 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 19:45:27 +0000 Subject: [PATCH 18/34] fix(workspaces): Reject reserved sandbox checkout paths Share the reserved root names with cloneRepository and fail workspace prepare before cloning into skills, data, or .junior. Co-Authored-By: David Cramer --- packages/junior-github/src/plugin.ts | 8 ++++++- packages/junior-github/src/sandbox-paths.ts | 6 +++++ .../src/tools/clone-repository.ts | 3 +-- .../junior-github/tests/github-plugin.test.ts | 24 +++++++++++++++++++ 4 files changed, 38 insertions(+), 3 deletions(-) create mode 100644 packages/junior-github/src/sandbox-paths.ts diff --git a/packages/junior-github/src/plugin.ts b/packages/junior-github/src/plugin.ts index 3e8bcf53c5..cef1a7dfbe 100644 --- a/packages/junior-github/src/plugin.ts +++ b/packages/junior-github/src/plugin.ts @@ -53,6 +53,7 @@ import { prepareCommitMsgHook, } from "./git-config.js"; import { linkifyGitHubReferences } from "./reply-markdown.js"; +import { RESERVED_SANDBOX_DIRECTORIES } from "./sandbox-paths.js"; import { CREATE_TOOL_ROUTING_GUIDANCE, GITHUB_APP_ID_ENV, @@ -842,7 +843,12 @@ export function githubPlugin( 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 === "..") { + 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 }; 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 28774a11d9..827ed70e3c 100644 --- a/packages/junior-github/tests/github-plugin.test.ts +++ b/packages/junior-github/tests/github-plugin.test.ts @@ -2872,6 +2872,30 @@ Conversation: \`local:test:old-conversation\` 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"; From 95d0a37d4367588a55728cba9fd3045a14cd9b28 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 20:43:43 +0000 Subject: [PATCH 19/34] fix(sandbox): Restore prior sandbox on mid-switch cancel Defer stopping the previous session until the replacement is ready so a cancelled or failed switch can put the live sandbox back. --- packages/junior/src/chat/sandbox/session.ts | 76 +++++++++---------- .../component/misc/sandbox-executor.test.ts | 59 +++++++++++++- 2 files changed, 94 insertions(+), 41 deletions(-) diff --git a/packages/junior/src/chat/sandbox/session.ts b/packages/junior/src/chat/sandbox/session.ts index fd368f86c0..2b438bba1c 100644 --- a/packages/junior/src/chat/sandbox/session.ts +++ b/packages/junior/src/chat/sandbox/session.ts @@ -64,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 = @@ -138,12 +135,9 @@ 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, }; } @@ -746,39 +740,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)), @@ -786,18 +768,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?.(); @@ -941,10 +919,9 @@ export function createSandboxRuntime( } } - // Aborted acquire may still remember a session and rewrite the durable hint. + // Detach without stopping previous yet so mid-switch cancel can restore it. const late = activeSandbox; invalidateSession(); - await stopSession(previous.sandbox?.session); if (late && late !== previous.sandbox) { await stopSession(late.session); sandboxRef = reportedSandboxRef = undefined; @@ -955,19 +932,41 @@ export function createSandboxRuntime( 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); - // Keep prior durable hint only if nothing was stopped or reported. - if (previous.sandbox || late || failed || reportedNew) { + 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 { - // Preserve the boot error when rollback persistence also fails. + // Keep the original switch error. } } else { sandboxRef = previous.sandboxRef; @@ -975,6 +974,7 @@ export function createSandboxRuntime( } throw error; } + await stopSession(previous.sandbox?.session); }, async acquire(signal) { return await getOrAcquireSandbox(signal); diff --git a/packages/junior/tests/component/misc/sandbox-executor.test.ts b/packages/junior/tests/component/misc/sandbox-executor.test.ts index 55e66a1a28..8d341cea85 100644 --- a/packages/junior/tests/component/misc/sandbox-executor.test.ts +++ b/packages/junior/tests/component/misc/sandbox-executor.test.ts @@ -1080,6 +1080,56 @@ describe("createTestSandbox", () => { 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 = { @@ -1196,13 +1246,15 @@ describe("createTestSandbox", () => { "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" }, - null, + { id: "sbx_workspace_initial", workspaceId: "workspace-initial" }, ]); - expect(runtime.sandboxRef()).toBeUndefined(); + 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 () => { @@ -1243,7 +1295,8 @@ describe("createTestSandbox", () => { ); expect(failedSandbox.stop).toHaveBeenCalledTimes(1); - expect(runtime.sandboxRef()).toBeUndefined(); + 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 () => { From eaab3d4a82fe5c51ebbe7b228fe1dacba633f043 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 22:06:30 +0000 Subject: [PATCH 20/34] fix(sandbox): Keep durable workspace when recipe is missing Preserve workspaceId and profile hash on SandboxRef restore when the workspace recipe row cannot be loaded, instead of silently stripping the association on the next report. --- packages/junior/src/chat/sandbox/README.md | 3 +- packages/junior/src/chat/sandbox/session.ts | 41 +++++++++---------- .../component/misc/sandbox-executor.test.ts | 33 +++++++++++++++ 3 files changed, 55 insertions(+), 22 deletions(-) diff --git a/packages/junior/src/chat/sandbox/README.md b/packages/junior/src/chat/sandbox/README.md index c6883cb993..4e26f24e89 100644 --- a/packages/junior/src/chat/sandbox/README.md +++ b/packages/junior/src/chat/sandbox/README.md @@ -11,7 +11,8 @@ traffic through verified host egress. - 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. + 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 diff --git a/packages/junior/src/chat/sandbox/session.ts b/packages/junior/src/chat/sandbox/session.ts index 2b438bba1c..2e33fef202 100644 --- a/packages/junior/src/chat/sandbox/session.ts +++ b/packages/junior/src/chat/sandbox/session.ts @@ -177,7 +177,11 @@ export function createSandboxRuntime( const timeoutMs = options.timeoutMs ?? 1000 * 60 * 30; const traceContext = options.traceContext ?? {}; let activeWorkspace = options.workspace; - let dependencyProfileHash = profileHash(SANDBOX_RUNTIME, activeWorkspace); + // Keep durable workspace association when the recipe row is missing. + let dependencyProfileHash = activeWorkspace + ? profileHash(SANDBOX_RUNTIME, activeWorkspace) + : (options.sandboxRef?.profileHash ?? + profileHash(SANDBOX_RUNTIME, activeWorkspace)); const resolveCommandEnv = options.commandEnv ?? (async () => ({}) as Record); @@ -219,10 +223,11 @@ export function createSandboxRuntime( const reportSandboxRef = async ( nextSandbox: SandboxSession, ): Promise => { + const workspaceId = activeWorkspace?.id ?? sandboxRef?.workspaceId; const nextRef: SandboxRef = { id: nextSandbox.sandboxId, ...(dependencyProfileHash ? { profileHash: dependencyProfileHash } : {}), - ...(activeWorkspace ? { workspaceId: activeWorkspace.id } : {}), + ...(workspaceId ? { workspaceId } : {}), }; sandboxRef = nextRef; if ( @@ -530,40 +535,34 @@ export function createSandboxRuntime( if ( activeSandbox || !sandboxRef || - dependencyProfileHash === sandboxRef.profileHash + dependencyProfileHash === sandboxRef.profileHash || + // Without the recipe we cannot recompute the workspace profile; keep the hint. + (!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, diff --git a/packages/junior/tests/component/misc/sandbox-executor.test.ts b/packages/junior/tests/component/misc/sandbox-executor.test.ts index 8d341cea85..a274a95f48 100644 --- a/packages/junior/tests/component/misc/sandbox-executor.test.ts +++ b/packages/junior/tests/component/misc/sandbox-executor.test.ts @@ -1161,6 +1161,39 @@ describe("createTestSandbox", () => { }); }); + 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("starts keepalive after a successful workspace switch", async () => { vi.useFakeTimers(); process.env.VERCEL_SANDBOX_KEEPALIVE_MS = "5000"; From 50e0d0a3b6b42784306ae698c548745418ec2897 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 22:10:31 +0000 Subject: [PATCH 21/34] fix(sandbox): Scope missing workspace profile fallback --- packages/junior/src/chat/sandbox/session.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/junior/src/chat/sandbox/session.ts b/packages/junior/src/chat/sandbox/session.ts index 2e33fef202..357ad0a6f7 100644 --- a/packages/junior/src/chat/sandbox/session.ts +++ b/packages/junior/src/chat/sandbox/session.ts @@ -177,11 +177,11 @@ export function createSandboxRuntime( const timeoutMs = options.timeoutMs ?? 1000 * 60 * 30; const traceContext = options.traceContext ?? {}; let activeWorkspace = options.workspace; - // Keep durable workspace association when the recipe row is missing. - let dependencyProfileHash = activeWorkspace - ? profileHash(SANDBOX_RUNTIME, activeWorkspace) - : (options.sandboxRef?.profileHash ?? - profileHash(SANDBOX_RUNTIME, activeWorkspace)); + // 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); From f78518a9bfe03ce87810869b1443699e2424a9dc Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 22:59:56 +0000 Subject: [PATCH 22/34] fix(sandbox): Abort workspace setup scripts on cancel Forward the acquire AbortSignal into prepareWorkspaceSnapshot so long setup scripts stop when a workspace switch or turn is cancelled. --- packages/junior/src/chat/sandbox/session.ts | 27 +++++---- .../component/misc/sandbox-executor.test.ts | 58 +++++++++++++++++++ 2 files changed, 71 insertions(+), 14 deletions(-) diff --git a/packages/junior/src/chat/sandbox/session.ts b/packages/junior/src/chat/sandbox/session.ts index 357ad0a6f7..8579b17e5d 100644 --- a/packages/junior/src/chat/sandbox/session.ts +++ b/packages/junior/src/chat/sandbox/session.ts @@ -143,10 +143,7 @@ function truncateOutput( } 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; } @@ -209,16 +206,13 @@ 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, @@ -439,7 +433,8 @@ export function createSandboxRuntime( signal, workspace: recipe, prepareWorkspace: recipe - ? async (sandbox) => await prepareWorkspaceSnapshot(sandbox, recipe) + ? async (sandbox) => + await prepareWorkspaceSnapshot(sandbox, recipe, signal) : undefined, }); if (!rebuiltSnapshot.snapshotId) { @@ -459,13 +454,16 @@ 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( @@ -499,7 +497,8 @@ export function createSandboxRuntime( signal, workspace: recipe, prepareWorkspace: recipe - ? async (sandbox) => await prepareWorkspaceSnapshot(sandbox, recipe) + ? async (sandbox) => + await prepareWorkspaceSnapshot(sandbox, recipe, signal) : undefined, }); signal?.throwIfAborted(); @@ -532,11 +531,11 @@ export function createSandboxRuntime( }; const discardHintIfProfileChanged = (): void => { + // Missing recipe cannot recompute workspace profile; keep the durable hint. if ( activeSandbox || !sandboxRef || dependencyProfileHash === sandboxRef.profileHash || - // Without the recipe we cannot recompute the workspace profile; keep the hint. (!activeWorkspace && sandboxRef.workspaceId) ) { return; diff --git a/packages/junior/tests/component/misc/sandbox-executor.test.ts b/packages/junior/tests/component/misc/sandbox-executor.test.ts index a274a95f48..dbc9772a25 100644 --- a/packages/junior/tests/component/misc/sandbox-executor.test.ts +++ b/packages/junior/tests/component/misc/sandbox-executor.test.ts @@ -1194,6 +1194,64 @@ describe("createTestSandbox", () => { 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"; From c2aa91d0fa35ffe1695a3e769bea7453799e9467 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Thu, 13 Aug 2026 10:37:09 -0700 Subject: [PATCH 23/34] fix(github): Use Smart HTTP auth for workspace clones --- packages/junior-github/src/credential-support.ts | 8 ++++++-- packages/junior-github/src/plugin.ts | 3 ++- packages/junior-github/tests/github-plugin.test.ts | 6 ++++-- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/junior-github/src/credential-support.ts b/packages/junior-github/src/credential-support.ts index f08ee05743..121b6bc04b 100644 --- a/packages/junior-github/src/credential-support.ts +++ b/packages/junior-github/src/credential-support.ts @@ -447,7 +447,11 @@ function isGitSmartHttpDomain(domain: string): boolean { return domain.toLowerCase() === "github.com"; } -function authorizationFor(domain: string, token: string): string { +/** Build the GitHub authorization header for API and Git Smart HTTP requests. */ +export function githubAuthorizationHeader( + domain: string, + token: string, +): string { if (isGitSmartHttpDomain(domain)) { return `Basic ${Buffer.from(`x-access-token:${token}`).toString("base64")}`; } @@ -471,7 +475,7 @@ function createCredentialLease( ).map((domain) => ({ domain, headers: { - Authorization: authorizationFor(domain, input.token), + Authorization: githubAuthorizationHeader(domain, input.token), }, })), }, diff --git a/packages/junior-github/src/plugin.ts b/packages/junior-github/src/plugin.ts index cef1a7dfbe..619ea66d8a 100644 --- a/packages/junior-github/src/plugin.ts +++ b/packages/junior-github/src/plugin.ts @@ -68,6 +68,7 @@ import { GitHubPluginSetupError, createPermissionCache, credentialUnavailable, + githubAuthorizationHeader, githubRepositoryFromLeaseScope, githubRepositoryFromUrl, githubRepositoryLeaseScope, @@ -875,7 +876,7 @@ export function githubPlugin( env: { GIT_CONFIG_COUNT: "1", GIT_CONFIG_KEY_0: "http.extraHeader", - GIT_CONFIG_VALUE_0: `Authorization: Bearer ${token.token}`, + GIT_CONFIG_VALUE_0: `Authorization: ${githubAuthorizationHeader("github.com", token.token)}`, }, }); if (result.exitCode !== 0) { diff --git a/packages/junior-github/tests/github-plugin.test.ts b/packages/junior-github/tests/github-plugin.test.ts index 827ed70e3c..080701cc31 100644 --- a/packages/junior-github/tests/github-plugin.test.ts +++ b/packages/junior-github/tests/github-plugin.test.ts @@ -2827,7 +2827,7 @@ Conversation: \`local:test:old-conversation\` ]); }); - it("preloads workspace repositories with an installation token", async () => { + it("preloads workspace repositories with Git Smart HTTP installation auth", async () => { const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); process.env.GITHUB_APP_ID = "123"; process.env.GITHUB_INSTALLATION_ID = "456"; @@ -2867,7 +2867,9 @@ Conversation: \`local:test:old-conversation\` }); expect(runs.map((run) => run.args?.at(-1))).toEqual(["sentry", "junior"]); expect(runs[0]?.env).toMatchObject({ - GIT_CONFIG_VALUE_0: "Authorization: Bearer installation-token", + GIT_CONFIG_VALUE_0: `Authorization: Basic ${Buffer.from( + "x-access-token:installation-token", + ).toString("base64")}`, }); expect(runs[0]?.args?.join(" ")).not.toContain("installation-token"); }); From b99b1658d2b66fe323e67971819365176280620b Mon Sep 17 00:00:00 2001 From: David Cramer Date: Thu, 13 Aug 2026 11:43:04 -0700 Subject: [PATCH 24/34] refactor(workspaces): Simplify sandbox lifecycle --- TERMINOLOGY.md | 2 +- apps/example/README.md | 1 + apps/example/server.ts | 3 + apps/example/workspaces.ts | 17 + .../content/docs/operate/sandbox-snapshots.md | 13 +- .../junior-github/src/credential-support.ts | 8 +- packages/junior-github/src/plugin.ts | 13 - .../junior-github/tests/github-plugin.test.ts | 21 +- packages/junior/README.md | 25 + packages/junior/src/app.ts | 8 + .../junior/src/chat/agent-invocations/work.ts | 4 +- packages/junior/src/chat/agent/sandbox.ts | 11 +- packages/junior/src/chat/agent/tools.ts | 8 +- packages/junior/src/chat/agent/types.ts | 18 +- packages/junior/src/chat/api-turns/work.ts | 4 +- packages/junior/src/chat/local/runner.ts | 4 +- .../junior/src/chat/runtime/agent-runner.ts | 4 + packages/junior/src/chat/sandbox/README.md | 20 +- packages/junior/src/chat/sandbox/sandbox.ts | 7 +- packages/junior/src/chat/sandbox/session.ts | 218 ++++---- .../src/chat/sandbox/snapshot/profile.ts | 61 +-- .../src/chat/sandbox/snapshot/resolve.ts | 465 ++++++------------ packages/junior/src/chat/tools/types.ts | 1 + packages/junior/src/chat/workspaces/config.ts | 64 +++ packages/junior/src/chat/workspaces/store.ts | 92 ---- packages/junior/src/chat/workspaces/tools.ts | 21 +- packages/junior/src/chat/workspaces/types.ts | 1 - packages/junior/src/cli/chat.ts | 34 +- packages/junior/src/cli/init.ts | 14 + packages/junior/src/db/schema.ts | 5 - packages/junior/src/db/schema/workspaces.ts | 46 -- .../component/misc/sandbox-executor.test.ts | 364 +++----------- .../sandbox/snapshot/resolve.test.ts | 144 +----- .../component/scheduled-tasks-sql.test.ts | 2 +- .../junior/tests/unit/cli/init-cli.test.ts | 12 + .../unit/sandbox/snapshot/profile.test.ts | 172 ++----- .../tests/unit/tools/workspaces.test.ts | 31 +- 37 files changed, 659 insertions(+), 1279 deletions(-) create mode 100644 apps/example/workspaces.ts create mode 100644 packages/junior/src/chat/workspaces/config.ts delete mode 100644 packages/junior/src/chat/workspaces/store.ts delete mode 100644 packages/junior/src/db/schema/workspaces.ts diff --git a/TERMINOLOGY.md b/TERMINOLOGY.md index 0133f95c9e..ea238cdf22 100644 --- a/TERMINOLOGY.md +++ b/TERMINOLOGY.md @@ -4,7 +4,7 @@ Canonical words used across Junior's code and documentation. ## Terms -- **Workspace**: a named recipe that selects repositories and setup instructions for a sandbox snapshot. +- **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`. diff --git a/apps/example/README.md b/apps/example/README.md index 02f10c661d..6d2c2121e5 100644 --- a/apps/example/README.md +++ b/apps/example/README.md @@ -36,4 +36,5 @@ heartbeat, or server paths use them. - `plugins.ts` is the single source of truth for installed plugin registrations and runtime hook plugins in this app - `nitro.config.ts` points `juniorNitro()` at `./plugins` so plugin content is copied into the build output and exposed to runtime through the virtual config module - `server.ts` imports the same plugin set and passes it to `createApp({ plugins })` so local dev and built bundles load identical runtime plugins +- `workspaces.ts` defines install-wide Workspace recipes and `server.ts` passes them to `createApp({ workspaces })`; local chat loads the same module - root `pnpm dev` starts a local heartbeat loop that calls `/api/internal/heartbeat` every minute, matching the production cron pulse used for plugin heartbeats and stale dispatch recovery diff --git a/apps/example/server.ts b/apps/example/server.ts index 5d5d2adce8..34254471ac 100644 --- a/apps/example/server.ts +++ b/apps/example/server.ts @@ -10,10 +10,12 @@ const [ exampleDashboardMockConversations, }, { plugins }, + { workspaces }, ] = await Promise.all([ import("@sentry/junior"), import("./dashboard.ts"), import("./plugins.ts"), + import("./workspaces.ts"), ]); const app = await createApp({ @@ -24,6 +26,7 @@ const app = await createApp({ mockConversations: exampleDashboardMockConversations(), }, plugins, + workspaces, configDefaults: { "sentry.org": "sentry", }, diff --git a/apps/example/workspaces.ts b/apps/example/workspaces.ts new file mode 100644 index 0000000000..6f380d0629 --- /dev/null +++ b/apps/example/workspaces.ts @@ -0,0 +1,17 @@ +import { defineJuniorWorkspaces } from "@sentry/junior"; + +export const workspaces = defineJuniorWorkspaces([ + { + id: "junior", + name: "junior", + setupScript: "", + repos: [ + { + provider: "github", + repo: "getsentry/junior", + checkoutPath: "junior", + isPrimary: true, + }, + ], + }, +]); diff --git a/packages/docs/src/content/docs/operate/sandbox-snapshots.md b/packages/docs/src/content/docs/operate/sandbox-snapshots.md index a0d178fb8f..595683ab74 100644 --- a/packages/docs/src/content/docs/operate/sandbox-snapshots.md +++ b/packages/docs/src/content/docs/operate/sandbox-snapshots.md @@ -15,7 +15,7 @@ Junior plugins can declare sandbox runtime dependencies such as npm CLIs, system ## When snapshots are used -Snapshots are used only when loaded plugins declare runtime dependencies or runtime postinstall commands. If the dependency profile is empty, Junior creates a base sandbox without snapshot warmup. +Snapshots are used when loaded plugins declare runtime dependencies or runtime postinstall commands, or when a Workspace prepares repository contents. If the dependency profile is empty and no Workspace is selected, Junior creates a base sandbox without snapshot warmup. The common deploy path runs snapshot warmup during build: @@ -37,10 +37,19 @@ Junior computes the snapshot profile from its global baseline and loaded plugin | npm dependencies | Global and plugin `runtime-dependencies` entries with `type: npm`. | | system dependencies | Global and plugin `runtime-dependencies` entries with `type: system`. | | postinstall commands | Global and plugin `runtime-postinstall` entries. | +| Workspace recipe | Repository providers, names, checkout paths, and setup script. | | manual rebuild epoch | `SANDBOX_SNAPSHOT_REBUILD_EPOCH`, when set. | Any change to those inputs produces a new profile hash and a new snapshot. +## Repository Workspaces + +Define install-wide Workspace recipes with `defineJuniorWorkspaces(...)`, then pass them to `createApp({ workspaces })`. Put the value in an app-local `workspaces.ts` file so `junior chat` loads the same recipes. + +Junior builds one complete snapshot for each selected Workspace. The build installs runtime dependencies, prepares repositories, runs the setup script, and then captures the snapshot. The first switch builds the snapshot on demand. Later switches reuse it until its floating profile becomes stale. + +Provider plugins prepare repositories through Junior's host egress proxy. Junior removes the credential route before it runs the setup script and captures the snapshot. Real provider credentials do not enter the Sandbox or the captured snapshot. + ## Cache and rebuild behavior Snapshot metadata is stored in Redis by profile hash. Junior serializes rebuilds for the same profile so concurrent builds do not create duplicate snapshots. @@ -62,7 +71,7 @@ Leave the variable unset to use the Vercel default. Requested vCPU counts must b ## Failure behavior -Snapshot build failures are deploy blockers. Junior must not silently continue with partially installed dependencies. +Warmup snapshot failures are deploy blockers. A lazy Workspace snapshot failure stops that switch and leaves the current Sandbox active. Junior does not continue with partially prepared contents. Check these first: diff --git a/packages/junior-github/src/credential-support.ts b/packages/junior-github/src/credential-support.ts index 121b6bc04b..f08ee05743 100644 --- a/packages/junior-github/src/credential-support.ts +++ b/packages/junior-github/src/credential-support.ts @@ -447,11 +447,7 @@ function isGitSmartHttpDomain(domain: string): boolean { return domain.toLowerCase() === "github.com"; } -/** Build the GitHub authorization header for API and Git Smart HTTP requests. */ -export function githubAuthorizationHeader( - domain: string, - token: string, -): string { +function authorizationFor(domain: string, token: string): string { if (isGitSmartHttpDomain(domain)) { return `Basic ${Buffer.from(`x-access-token:${token}`).toString("base64")}`; } @@ -475,7 +471,7 @@ function createCredentialLease( ).map((domain) => ({ domain, headers: { - Authorization: githubAuthorizationHeader(domain, input.token), + Authorization: authorizationFor(domain, input.token), }, })), }, diff --git a/packages/junior-github/src/plugin.ts b/packages/junior-github/src/plugin.ts index 619ea66d8a..ce460a28fe 100644 --- a/packages/junior-github/src/plugin.ts +++ b/packages/junior-github/src/plugin.ts @@ -68,7 +68,6 @@ import { GitHubPluginSetupError, createPermissionCache, credentialUnavailable, - githubAuthorizationHeader, githubRepositoryFromLeaseScope, githubRepositoryFromUrl, githubRepositoryLeaseScope, @@ -854,13 +853,6 @@ export function githubPlugin( } 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", @@ -873,11 +865,6 @@ export function githubPlugin( path, ], cwd: ctx.sandbox.root, - env: { - GIT_CONFIG_COUNT: "1", - GIT_CONFIG_KEY_0: "http.extraHeader", - GIT_CONFIG_VALUE_0: `Authorization: ${githubAuthorizationHeader("github.com", token.token)}`, - }, }); if (result.exitCode !== 0) { throw new Error( diff --git a/packages/junior-github/tests/github-plugin.test.ts b/packages/junior-github/tests/github-plugin.test.ts index 080701cc31..78a8124120 100644 --- a/packages/junior-github/tests/github-plugin.test.ts +++ b/packages/junior-github/tests/github-plugin.test.ts @@ -2827,15 +2827,7 @@ Conversation: \`local:test:old-conversation\` ]); }); - it("preloads workspace repositories with Git Smart HTTP installation auth", 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(); + it("preloads workspace repositories through sandbox egress", async () => { const runs: Array<{ args?: string[]; env?: Record }> = []; const ctx = { db, @@ -2861,17 +2853,8 @@ Conversation: \`local:test:old-conversation\` 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: Basic ${Buffer.from( - "x-access-token:installation-token", - ).toString("base64")}`, - }); - expect(runs[0]?.args?.join(" ")).not.toContain("installation-token"); + expect(runs[0]?.env).toBeUndefined(); }); it("rejects reserved workspace checkout paths", async () => { diff --git a/packages/junior/README.md b/packages/junior/README.md index 2888588951..2b28849924 100644 --- a/packages/junior/README.md +++ b/packages/junior/README.md @@ -34,6 +34,31 @@ const app = await createApp({ export default app; ``` +Define named repository Workspaces in `workspaces.ts`: + +```ts +import { defineJuniorWorkspaces } from "@sentry/junior"; + +export const workspaces = defineJuniorWorkspaces([ + { + id: "app", + name: "app", + setupScript: "pnpm install", + repos: [ + { + provider: "github", + repo: "example/app", + checkoutPath: "app", + isPrimary: true, + }, + ], + }, +]); +``` + +Pass this value to `createApp({ workspaces })`. The local `junior chat` +command also loads an app-local `workspaces.ts` file. + Run `junior init my-bot` to scaffold a complete project including `vercel.json` for Vercel deployment. Use `defineJuniorPlugins([...])` in a runtime-safe plugin module, then point diff --git a/packages/junior/src/app.ts b/packages/junior/src/app.ts index e8bd583cbe..211a55076b 100644 --- a/packages/junior/src/app.ts +++ b/packages/junior/src/app.ts @@ -28,6 +28,8 @@ import { setSandboxResourceConfig, type SandboxResourceConfig, } from "@/chat/sandbox/resources"; +import { defineJuniorWorkspaces } from "@/chat/workspaces/config"; +import type { Workspace } from "@/chat/workspaces/types"; import { pluginCatalogRuntime } from "@/chat/plugins/catalog-runtime"; import { type PluginRouteRegistration, @@ -92,12 +94,14 @@ import { ingestEventTasks } from "@/chat/event-tasks/ingest"; import { receiveLocalOAuthCredential } from "@/chat/local/credential-sync"; export { defineJuniorPlugins } from "./plugins"; +export { defineJuniorWorkspaces }; export { JUNIOR_VERSION } from "./version"; export type { JuniorPluginInput, JuniorPluginSet, JuniorPluginSetOptions, } from "./plugins"; +export type { Workspace, WorkspaceRepo } from "@/chat/workspaces/types"; export interface JuniorAppOptions { /** Authenticated dashboard mounted by core when configured. */ @@ -121,6 +125,8 @@ export interface JuniorAppOptions { conversationWork?: VercelConversationWorkCallbackOptions; /** Direct plugin set override. Usually omitted when `juniorNitro()` uses a plugin module. */ plugins?: JuniorPluginSet; + /** Install-wide named repository Workspace recipes. */ + workspaces?: readonly Workspace[]; /** Sandbox execution options. */ sandbox?: SandboxResourceConfig & { /** @@ -606,6 +612,7 @@ export async function createApp(options?: JuniorAppOptions): Promise { ); } const dashboard = options?.dashboard ?? virtualConfig?.dashboard; + const workspaces = defineJuniorWorkspaces(options?.workspaces ?? []); const configuredPlugins = options?.plugins ?? virtualConfig?.pluginSet; const plugins = pluginRuntimeRegistrationsFromPluginSet(configuredPlugins); const pluginConfig = configuredPlugins @@ -703,6 +710,7 @@ export async function createApp(options?: JuniorAppOptions): Promise { bindSpawnAgent: (request) => bindSpawnAgent(request, { queue: conversationWorkQueue }), tracePropagation, + workspaces, }); const runtimeServiceOverrides = { replyExecutor: { agentRunner }, diff --git a/packages/junior/src/chat/agent-invocations/work.ts b/packages/junior/src/chat/agent-invocations/work.ts index 71603aa2a4..b19512b66a 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 ?? undefined; + sandboxRef = nextSandboxRef; await persistThreadStateById(invocation.childConversationId, { - sandboxRef: nextSandboxRef, + sandboxRef, }); }, }, diff --git a/packages/junior/src/chat/agent/sandbox.ts b/packages/junior/src/chat/agent/sandbox.ts index a14e015fec..71f67e8d3e 100644 --- a/packages/junior/src/chat/agent/sandbox.ts +++ b/packages/junior/src/chat/agent/sandbox.ts @@ -41,9 +41,12 @@ export interface AgentSandboxOptions { configurationValues: Record; getActiveSkill(): Skill | null; prepareSandbox(workspace: SandboxWorkspace): void | Promise; - prepareWorkspace?(workspace: SandboxWorkspace, recipe: Workspace): Promise; - onSandboxRefChanged(sandboxRef: SandboxRef | undefined): void; - persistSandboxRef?(sandboxRef: SandboxRef | null): void | Promise; + prepareWorkspace?( + workspace: SandboxWorkspace, + recipe: Workspace, + ): Promise; + onSandboxRefChanged(sandboxRef: SandboxRef): void; + persistSandboxRef?(sandboxRef: SandboxRef): void | Promise; } export interface AgentSandbox { @@ -153,7 +156,7 @@ export function createAgentSandbox(options: AgentSandboxOptions): AgentSandbox { prepare: options.prepareSandbox, prepareWorkspace: options.prepareWorkspace, onSandboxRefChanged: async (sandboxRef) => { - options.onSandboxRefChanged(sandboxRef ?? undefined); + options.onSandboxRefChanged(sandboxRef); await options.persistSandboxRef?.(sandboxRef); }, }); diff --git a/packages/junior/src/chat/agent/tools.ts b/packages/junior/src/chat/agent/tools.ts index 6972092c11..4a3ca49822 100644 --- a/packages/junior/src/chat/agent/tools.ts +++ b/packages/junior/src/chat/agent/tools.ts @@ -38,8 +38,6 @@ 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"; @@ -223,8 +221,11 @@ export async function wireAgentTools( actor: args.currentActor, actors: args.currentActors, }); + const workspaces = args.run.environment?.workspaces ?? []; const workspace = args.state.sandboxRef?.workspaceId - ? await getWorkspace(getDb(), args.state.sandboxRef.workspaceId) + ? workspaces.find( + (value) => value.id === args.state.sandboxRef?.workspaceId, + ) : undefined; const agentSandbox = createAgentSandbox({ sandboxRef: args.state.sandboxRef, @@ -371,6 +372,7 @@ export async function wireAgentTools( attachmentStorage: args.run.environment?.attachmentStorage, workspaces: { activeWorkspaceId: () => agentSandbox.sandboxRef()?.workspaceId, + recipes: workspaces, switch: agentSandbox.switchWorkspace, }, } as ToolRuntimeContext; diff --git a/packages/junior/src/chat/agent/types.ts b/packages/junior/src/chat/agent/types.ts index c8418e783b..a6408bfdeb 100644 --- a/packages/junior/src/chat/agent/types.ts +++ b/packages/junior/src/chat/agent/types.ts @@ -35,6 +35,7 @@ import type { } from "@/chat/tools/types"; import type { AssistantMessage } from "@earendil-works/pi-ai"; import type { AttachmentStorage } from "@/chat/attachments/storage"; +import type { Workspace } from "@/chat/workspaces/types"; /** One attachment the model may see for the current instruction. */ export type AgentAttachment = { @@ -132,9 +133,7 @@ export type AgentRunState = { * The runner must commit the preceding agent boundary before invoking this * port; the accepted reply transaction appends only this message. */ -export type AgentDelivery = ( - message: AssistantMessage, -) => void | Promise; +export type AgentDelivery = (message: AssistantMessage) => void | Promise; /** Resume the agent turn after a transient or ambiguous delivery failure. */ export class RetryableDeliveryError extends Error { @@ -157,8 +156,7 @@ export type AgentDurability = { recordPendingAuth?: ( pendingAuth: ConversationPendingAuthState | undefined, ) => void | Promise; - /** Persist a replacement sandbox reference; null clears the durable reference. */ - onSandboxRefChanged?: (sandboxRef: SandboxRef | null) => void | Promise; + onSandboxRefChanged?: (sandboxRef: SandboxRef) => void | Promise; }; /** Best-effort progress events. Failures here never affect the run. */ @@ -182,6 +180,8 @@ export type AgentEnvironment = { sandboxTracePropagation?: SandboxEgressTracePropagationConfig; /** Per-slice sandbox egress signal storage override. */ sandboxEgressSignals?: SandboxEgressSignalTransport; + /** Immutable install-wide Workspace recipes for this run. */ + workspaces?: readonly Workspace[]; toolOverrides?: { imageGenerate?: ImageGenerateToolDeps; viewImage?: ViewImageToolDeps; @@ -304,7 +304,9 @@ export function assertRunConsistency( switch (source.platform) { case "slack": { if (destination.platform !== "slack") { - throw new TypeError("Run source and destination platforms do not match"); + throw new TypeError( + "Run source and destination platforms do not match", + ); } if (source.teamId !== destination.teamId) { throw new TypeError("Slack source and destination teams do not match"); @@ -313,7 +315,9 @@ export function assertRunConsistency( } case "local": { if (destination.platform !== "local") { - throw new TypeError("Run source and destination platforms do not match"); + throw new TypeError( + "Run source and destination platforms do not match", + ); } if (source.conversationId !== destination.conversationId) { throw new TypeError( diff --git a/packages/junior/src/chat/api-turns/work.ts b/packages/junior/src/chat/api-turns/work.ts index a5699ddca9..c616963b7c 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 ?? undefined; + sandboxRef = nextSandboxRef; await persistThreadStateById(context.conversationId, { conversation, - sandboxRef: nextSandboxRef, + sandboxRef, }); }, recordPendingAuth: async (pendingAuth) => { diff --git a/packages/junior/src/chat/local/runner.ts b/packages/junior/src/chat/local/runner.ts index 512dd4b1a6..5c9ca139a8 100644 --- a/packages/junior/src/chat/local/runner.ts +++ b/packages/junior/src/chat/local/runner.ts @@ -371,10 +371,10 @@ async function runLocalAgentTurnInContext( delivery: deliverAssistantMessage, durability: { onSandboxRefChanged: async (nextSandboxRef) => { - sandboxRef = nextSandboxRef ?? undefined; + sandboxRef = nextSandboxRef; await persistThreadStateById(input.conversationId, { conversation, - sandboxRef: nextSandboxRef, + sandboxRef, }); }, recordPendingAuth: async (pendingAuth) => { diff --git a/packages/junior/src/chat/runtime/agent-runner.ts b/packages/junior/src/chat/runtime/agent-runner.ts index b9fb06ffd2..b15503b947 100644 --- a/packages/junior/src/chat/runtime/agent-runner.ts +++ b/packages/junior/src/chat/runtime/agent-runner.ts @@ -8,6 +8,7 @@ import { isExperimentalFeatureEnabled } from "@/chat/experimental"; import type { AgentRunOutcome } from "@/chat/runtime/agent-run-outcome"; import type { SandboxEgressTracePropagationConfig } from "@/chat/sandbox/egress/tracing"; import type { AttachmentStorage } from "@/chat/attachments/storage"; +import type { Workspace } from "@/chat/workspaces/types"; const AGENT_ABORT_SETTLE_GRACE_MS = 5_000; @@ -24,11 +25,13 @@ export function createAgentRunner( bindSpawnAgent?: (run: AgentRun) => SpawnAgent | undefined; streamFn?: StreamFn; tracePropagation?: SandboxEgressTracePropagationConfig; + workspaces?: readonly Workspace[]; }, ): AgentRunner { const attachmentStorage = options?.attachmentStorage; const streamFn = options?.streamFn; const tracePropagation = options?.tracePropagation; + const workspaces = options?.workspaces; const bindSpawnAgent = options?.bindSpawnAgent; const canBindSpawn = Boolean(bindSpawnAgent) && isExperimentalFeatureEnabled("subagents"); @@ -48,6 +51,7 @@ export function createAgentRunner( run.environment?.attachmentStorage ?? attachmentStorage, sandboxTracePropagation: run.environment?.sandboxTracePropagation ?? tracePropagation, + workspaces: run.environment?.workspaces ?? workspaces, }, ...(spawnAgent ? { diff --git a/packages/junior/src/chat/sandbox/README.md b/packages/junior/src/chat/sandbox/README.md index 4e26f24e89..961ad58560 100644 --- a/packages/junior/src/chat/sandbox/README.md +++ b/packages/junior/src/chat/sandbox/README.md @@ -9,10 +9,10 @@ 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`, 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. + 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 a configured Workspace is removed, restore keeps the durable + Workspace id and profile hash. - 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 @@ -47,10 +47,14 @@ 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. +- A Workspace recipe is part of the profile hash. One build installs runtime + dependencies, prepares repositories, runs setup, and captures the complete + snapshot. +- Repository preparation uses host egress for provider credentials. Snapshot + state and Sandbox commands do not receive real provider credentials. Setup + runs after Junior removes the credential route from the build Sandbox. +- A Workspace switch prepares a candidate Sandbox before it updates durable or + live state. A failed candidate leaves the current Sandbox unchanged. - 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/sandbox.ts b/packages/junior/src/chat/sandbox/sandbox.ts index 00b8b446e5..e3e2745d15 100644 --- a/packages/junior/src/chat/sandbox/sandbox.ts +++ b/packages/junior/src/chat/sandbox/sandbox.ts @@ -101,8 +101,11 @@ export interface SandboxOptions { credentialEgress?: CredentialContext; egressSignals?: SandboxEgressSignalTransport; prepare?: (workspace: SandboxWorkspace) => void | Promise; - prepareWorkspace?: (workspace: SandboxWorkspace, recipe: Workspace) => Promise; - onSandboxRefChanged?: (sandboxRef: SandboxRef | null) => void | Promise; + prepareWorkspace?: ( + workspace: SandboxWorkspace, + recipe: Workspace, + ) => Promise; + onSandboxRefChanged?: (sandboxRef: SandboxRef) => void | Promise; } interface SandboxToolCallContext { diff --git a/packages/junior/src/chat/sandbox/session.ts b/packages/junior/src/chat/sandbox/session.ts index 8579b17e5d..6843b72024 100644 --- a/packages/junior/src/chat/sandbox/session.ts +++ b/packages/junior/src/chat/sandbox/session.ts @@ -113,7 +113,11 @@ interface ActiveSandbox { session: SandboxSession; networkPolicyKey?: string; } - +interface SandboxCandidate extends ActiveSandbox { + profileHash?: string; + ref: SandboxRef; + workspace?: Workspace; +} interface SandboxRuntimeOptions { sandboxRef?: SandboxRef; workspace?: Workspace; @@ -127,8 +131,11 @@ interface SandboxRuntimeOptions { traceHeaders?: TracePropagationHeaders, ) => NetworkPolicy | undefined; onSandboxPrepare?: (sandbox: SandboxSession) => void | Promise; - onWorkspacePrepare?: (sandbox: SandboxSession, workspace: Workspace) => Promise; - onSandboxRefChanged?: (sandboxRef: SandboxRef | null) => void | Promise; + onWorkspacePrepare?: ( + sandbox: SandboxSession, + workspace: Workspace, + ) => Promise; + onSandboxRefChanged?: (sandboxRef: SandboxRef) => void | Promise; } function truncateOutput( @@ -143,7 +150,10 @@ function truncateOutput( } 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; } @@ -206,7 +216,8 @@ 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. @@ -214,27 +225,30 @@ export function createSandboxRuntime( sandboxName: string, ): 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; + const persistSandboxRef = async (nextRef: SandboxRef): Promise => { if ( reportedSandboxRef?.id === nextRef.id && reportedSandboxRef.profileHash === nextRef.profileHash && reportedSandboxRef.workspaceId === nextRef.workspaceId ) { + sandboxRef = nextRef; return; } await options.onSandboxRefChanged?.(nextRef); + sandboxRef = nextRef; reportedSandboxRef = nextRef; }; + const sandboxReference = ( + session: SandboxSession, + workspace: Workspace | undefined, + hash: string | undefined, + ): SandboxRef => ({ + id: session.sandboxId, + ...(hash ? { profileHash: hash } : {}), + ...(workspace?.id ? { workspaceId: workspace.id } : {}), + }); + const rememberSandbox = ( nextSandbox: SandboxSession, networkPolicyKey?: string, @@ -387,9 +401,16 @@ export function createSandboxRuntime( sandboxCredentials: SandboxCredentials | undefined; sandboxName: string; signal?: AbortSignal; + workspace?: Workspace; }): Promise => { - const { runtime, snapshot, sandboxCredentials, sandboxName, signal } = - params; + const { + runtime, + snapshot, + sandboxCredentials, + sandboxName, + signal, + workspace, + } = params; signal?.throwIfAborted(); if (!snapshot.snapshotId) { @@ -424,17 +445,16 @@ 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 + workspace, + prepareWorkspace: workspace ? async (sandbox) => - await prepareWorkspaceSnapshot(sandbox, recipe, signal) + await prepareWorkspaceSnapshot(sandbox, workspace, signal) : undefined, }); if (!rebuiltSnapshot.snapshotId) { @@ -457,7 +477,13 @@ export function createSandboxRuntime( signal?: AbortSignal, ): Promise => { signal?.throwIfAborted(); + await applyNetworkPolicy(sandbox); await options.onWorkspacePrepare?.(sandbox, workspace); + // The provider hook is trusted and runs through credential egress. Remove + // that route before the app-owned setup script runs and before capture. + if (options.createNetworkPolicy) { + await sandbox.update({ networkPolicy: "allow-all" }); + } if (!workspace.setupScript.trim()) return; const result = await sandbox.runCommand({ cmd: "bash", @@ -472,9 +498,11 @@ export function createSandboxRuntime( } }; - const createFreshSandbox = async ( + const createSandboxCandidate = async ( + workspace: Workspace | undefined, + hash: string | undefined, signal?: AbortSignal, - ): Promise => { + ): Promise => { const runtime = SANDBOX_RUNTIME; const sandboxCredentials = getVercelSandboxCredentials(); const sandboxName = createSandboxName(); @@ -490,15 +518,14 @@ export function createSandboxRuntime( "app.sandbox.runtime": runtime, }, async () => { - const recipe = activeWorkspace; const snapshot = await resolveSnapshot({ runtime, timeoutMs, signal, - workspace: recipe, - prepareWorkspace: recipe + workspace, + prepareWorkspace: workspace ? async (sandbox) => - await prepareWorkspaceSnapshot(sandbox, recipe, signal) + await prepareWorkspaceSnapshot(sandbox, workspace, signal) : undefined, }); signal?.throwIfAborted(); @@ -509,6 +536,7 @@ export function createSandboxRuntime( sandboxCredentials, sandboxName, signal, + workspace, }); }, ); @@ -516,8 +544,6 @@ export function createSandboxRuntime( return failSetup(error); } - await reportSandboxRef(createdSandbox); - let networkPolicyKey: string | undefined; try { networkPolicyKey = await applyNetworkPolicy(createdSandbox); @@ -527,7 +553,30 @@ export function createSandboxRuntime( return failSetup(error); } - return rememberSandbox(createdSandbox, networkPolicyKey); + return { + session: createdSandbox, + networkPolicyKey, + profileHash: hash, + ref: sandboxReference(createdSandbox, workspace, hash), + workspace, + }; + }; + + const createFreshSandbox = async ( + signal?: AbortSignal, + ): Promise => { + const candidate = await createSandboxCandidate( + activeWorkspace, + dependencyProfileHash, + signal, + ); + try { + await persistSandboxRef(candidate.ref); + return rememberSandbox(candidate.session, candidate.networkPolicyKey); + } catch (error) { + await stopSession(candidate.session); + throw error; + } }; const discardHintIfProfileChanged = (): void => { @@ -605,9 +654,9 @@ export function createSandboxRuntime( let networkPolicyKey: string | undefined; try { - await reportSandboxRef(hintedSandbox); networkPolicyKey = await applyNetworkPolicy(hintedSandbox); await prepareSandbox(hintedSandbox); + await persistSandboxRef({ ...ref, id: hintedSandbox.sandboxId }); return rememberSandbox(hintedSandbox, networkPolicyKey); } catch (error) { // Keep the durable VM alive so a later reacquire can reuse it. @@ -865,10 +914,8 @@ export function createSandboxRuntime( const ensureReadySandbox = async ( signal?: AbortSignal, - onAcquired?: (session: SandboxSession) => void, ): Promise => { const session = await getOrAcquireSandbox(signal); - onAcquired?.(session); signal?.throwIfAborted(); await probeSession(session); signal?.throwIfAborted(); @@ -892,87 +939,42 @@ export function createSandboxRuntime( 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. - } + // Finish any normal acquisition before building a replacement. The + // current Sandbox remains active while the candidate is prepared. + if (acquiringSandbox) { + await getOrAcquireSandbox(signal); } - // 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; + const previous = activeSandbox; + let candidate: SandboxCandidate | undefined; try { - await ensureReadySandbox(signal, (s) => { - replacement = s; - }); + candidate = await createSandboxCandidate( + workspace, + nextProfileHash, + signal, + ); + await probeSession(candidate.session); + signal?.throwIfAborted(); + await extendKeepAlive(candidate.session); + signal?.throwIfAborted(); + await persistSandboxRef(candidate.ref); } 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; - } + await stopSession(candidate?.session); throw error; } - await stopSession(previous.sandbox?.session); + + if (keepAliveTimer) { + clearTimeout(keepAliveTimer); + keepAliveTimer = undefined; + } + activeWorkspace = candidate.workspace; + dependencyProfileHash = candidate.profileHash; + activeSandbox = { + session: candidate.session, + networkPolicyKey: candidate.networkPolicyKey, + }; + startKeepAlive(candidate.session); + await stopSession(previous?.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 bef7fa5977..14111d4939 100644 --- a/packages/junior/src/chat/sandbox/snapshot/profile.ts +++ b/packages/junior/src/chat/sandbox/snapshot/profile.ts @@ -19,12 +19,6 @@ 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 { @@ -102,21 +96,20 @@ function workspaceRecipe(workspace: Workspace) { }); 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 { +/** Build the complete profile that selects one reusable sandbox snapshot. */ +export function create(runtime: string, workspace?: Workspace): Profile | null { const dependencies = mergeDependencies([ ...GLOBAL_RUNTIME_DEPENDENCIES, ...pluginCatalogRuntime.getRuntimeDependencies(), ]); const pluginPostinstall = pluginCatalogRuntime.getRuntimePostinstall(); const postinstall = [...GLOBAL_RUNTIME_POSTINSTALL, ...pluginPostinstall]; - if (dependencies.length === 0 && postinstall.length === 0) { + if (dependencies.length === 0 && postinstall.length === 0 && !workspace) { return null; } @@ -125,7 +118,8 @@ function createBase(runtime: string): Profile | null { // containing them expire on the same schedule as floating npm selectors. const floating = dependencies.some((dependency) => isFloating(dependency)) || - pluginPostinstall.length > 0; + pluginPostinstall.length > 0 || + Boolean(workspace); const hash = createHash("sha256") .update( JSON.stringify({ @@ -134,6 +128,7 @@ function createBase(runtime: string): Profile | null { rebuildEpoch, dependencies, postinstall, + workspace: workspace ? workspaceRecipe(workspace) : null, }), ) .digest("hex"); @@ -147,47 +142,11 @@ function createBase(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, workspace?: Workspace): string | undefined { +export function hash( + runtime: string, + workspace?: Workspace, +): string | undefined { return create(runtime, workspace)?.hash; } diff --git a/packages/junior/src/chat/sandbox/snapshot/resolve.ts b/packages/junior/src/chat/sandbox/snapshot/resolve.ts index 5f37ce820c..f86558b56e 100644 --- a/packages/junior/src/chat/sandbox/snapshot/resolve.ts +++ b/packages/junior/src/chat/sandbox/snapshot/resolve.ts @@ -1,4 +1,3 @@ -import { createHash } from "node:crypto"; import { Sandbox } from "@vercel/sandbox"; import { z } from "zod"; import { getVercelSandboxCredentials } from "@/chat/sandbox/credentials"; @@ -6,9 +5,12 @@ 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, type SandboxSession } from "@/chat/sandbox/workspace"; -import { sleep } from "@/chat/sleep"; +import { + createSandboxSession, + type SandboxSession, +} from "@/chat/sandbox/workspace"; import type { Workspace } from "@/chat/workspaces/types"; +import { sleep } from "@/chat/sleep"; import { getStateAdapter } from "@/chat/state/adapter"; // Snapshot resolution owns cache and lock coordination. Profile selection and @@ -53,7 +55,6 @@ export interface Snapshot { resolveOutcome: ResolveOutcome; rebuildReason?: RebuildReason; } - export type ProgressPhase = | "resolve_start" | "cache_hit" @@ -66,42 +67,21 @@ 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(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"); +function profileLockKey(profileHash: string): string { + return `${SNAPSHOT_LOCK_PREFIX}:${profileHash}`; } /** Read one cached snapshot pointer; only a missing key is a cache miss. */ async function getCachedSnapshot( - cacheIdentity: string, - profileHash = cacheIdentity, + profileHash: string, ): Promise { const state = getStateAdapter(); await state.connect(); - const raw = await state.get(profileCacheKey(cacheIdentity)); + const raw = await state.get(profileCacheKey(profileHash)); if (typeof raw !== "string") { return null; } @@ -119,88 +99,17 @@ async function getCachedSnapshot( } /** Persist one dependency profile's reusable snapshot pointer. */ -async function setCachedSnapshot( - cacheIdentity: string, - entry: CachedSnapshot, -): Promise { +async function setCachedSnapshot(entry: CachedSnapshot): Promise { const state = getStateAdapter(); await state.connect(); await state.set( - profileCacheKey(cacheIdentity), + profileCacheKey(entry.profileHash), JSON.stringify(entry), SNAPSHOT_CACHE_TTL_MS, ); } -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( +async function build( value: profile.Profile, runtime: string, timeoutMs: number, @@ -213,82 +122,48 @@ async function buildBase( { "app.sandbox.runtime": runtime, "app.sandbox.snapshot.dependency_count": value.dependencyCount, - "app.sandbox.snapshot.build_mode": "base", }, async () => { - 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"; - } -} + const sandboxCredentials = getVercelSandboxCredentials(); + const resources = getSandboxResources(); + const sandbox = createSandboxSession( + await Sandbox.create({ + timeout: timeoutMs, + runtime, + signal, + ...(sandboxCredentials ?? {}), + ...(resources ? { resources } : {}), + }), + ); -/** 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 { - 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); + await install.dependencies(sandbox, value.dependencies, signal); + await install.postinstall(sandbox, value.postinstall, signal); + await prepare?.(sandbox); + 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. } - 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<{ @@ -302,7 +177,7 @@ async function withBuildLock( signal?.throwIfAborted(); const state = getStateAdapter(); await state.connect(); - const lockKey = profileLockKey(cacheIdentity); + const lockKey = profileLockKey(profileHash); const lockTtlMs = timeoutMs + SNAPSHOT_BUILD_LOCK_BUFFER_MS; const tryAcquireLock = async () => await state.acquireLock(lockKey, lockTtlMs); @@ -328,7 +203,7 @@ async function withBuildLock( Date.now() + lockTtlMs + SNAPSHOT_WAIT_FOR_LOCK_BUFFER_MS; while (Date.now() < waitUntil) { signal?.throwIfAborted(); - const cached = await getCachedSnapshot(cacheIdentity, profileHash); + const cached = await getCachedSnapshot(profileHash); if (cached?.snapshotId && canUseCachedSnapshot(cached)) { return { snapshotId: cached.snapshotId, @@ -356,7 +231,7 @@ async function withBuildLock( } signal?.throwIfAborted(); - const cached = await getCachedSnapshot(cacheIdentity, profileHash); + const cached = await getCachedSnapshot(profileHash); if (cached?.snapshotId && canUseCachedSnapshot(cached)) { return { snapshotId: cached.snapshotId, @@ -397,165 +272,23 @@ 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: ResolveParams): Promise { +export async function resolve(params: { + runtime: string; + timeoutMs: number; + forceRebuild?: boolean; + staleSnapshotId?: string; + onProgress?: (phase: ProgressPhase) => void | Promise; + signal?: AbortSignal; + workspace?: Workspace; + prepareWorkspace?: (sandbox: SandboxSession) => Promise; +}): 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(); @@ -569,9 +302,91 @@ export async function resolve(params: ResolveParams): Promise { }; } - return currentProfile.baseHash - ? await resolveWorkspaceProfile(params, currentProfile) - : await resolveProfile(params, currentProfile); + 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, + params.prepareWorkspace, + ); + 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 } : {}), + }; }, ); } diff --git a/packages/junior/src/chat/tools/types.ts b/packages/junior/src/chat/tools/types.ts index 05c315e759..7bd54242eb 100644 --- a/packages/junior/src/chat/tools/types.ts +++ b/packages/junior/src/chat/tools/types.ts @@ -103,6 +103,7 @@ interface BaseToolRuntimeContext { workspace: SandboxWorkspace; workspaces?: { activeWorkspaceId(): string | undefined; + recipes: readonly Workspace[]; switch(workspace: Workspace, signal?: AbortSignal): Promise; }; /** Report whether the model currently executing the turn accepts images. */ diff --git a/packages/junior/src/chat/workspaces/config.ts b/packages/junior/src/chat/workspaces/config.ts new file mode 100644 index 0000000000..4b467d0c13 --- /dev/null +++ b/packages/junior/src/chat/workspaces/config.ts @@ -0,0 +1,64 @@ +import type { Workspace } from "./types"; + +function copyWorkspace(workspace: Workspace): Workspace { + return { + ...workspace, + repos: workspace.repos.map((repo) => ({ ...repo })), + }; +} + +function validateWorkspaces(input: readonly Workspace[]): Workspace[] { + const ids = new Set(); + const names = new Set(); + + return input.map((value) => { + const workspace = copyWorkspace(value); + if (!workspace.id.trim()) throw new Error("Workspace id must not be empty"); + if (!workspace.name.trim()) { + throw new Error("Workspace name must not be empty"); + } + if (ids.has(workspace.id)) { + throw new Error(`Duplicate Workspace id: ${workspace.id}`); + } + if (names.has(workspace.name)) { + throw new Error(`Duplicate Workspace name: ${workspace.name}`); + } + ids.add(workspace.id); + names.add(workspace.name); + + const checkoutPaths = new Set(); + let primaryRepoCount = 0; + for (const repo of workspace.repos) { + if (!repo.provider.trim() || !repo.repo.trim()) { + throw new Error( + `Workspace ${workspace.name} repository provider and name must not be empty`, + ); + } + if (!repo.checkoutPath.trim()) { + throw new Error( + `Workspace ${workspace.name} checkout path must not be empty`, + ); + } + if (checkoutPaths.has(repo.checkoutPath)) { + throw new Error( + `Workspace ${workspace.name} has duplicate checkout path: ${repo.checkoutPath}`, + ); + } + checkoutPaths.add(repo.checkoutPath); + if (repo.isPrimary) primaryRepoCount += 1; + } + if (primaryRepoCount > 1) { + throw new Error( + `Workspace ${workspace.name} must not have more than one primary repository`, + ); + } + return workspace; + }); +} + +/** Define immutable install-wide Workspace recipes. */ +export function defineJuniorWorkspaces( + input: readonly Workspace[], +): Workspace[] { + return validateWorkspaces(input); +} diff --git a/packages/junior/src/chat/workspaces/store.ts b/packages/junior/src/chat/workspaces/store.ts deleted file mode 100644 index 33207cc26c..0000000000 --- a/packages/junior/src/chat/workspaces/store.ts +++ /dev/null @@ -1,92 +0,0 @@ -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 index 17ca1511bf..4010da3d8b 100644 --- a/packages/junior/src/chat/workspaces/tools.ts +++ b/packages/junior/src/chat/workspaces/tools.ts @@ -1,11 +1,10 @@ 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"; +import type { Workspace } from "./types"; const repoSchema = z.object({ provider: z.string(), @@ -19,7 +18,7 @@ const workspaceSchema = z.object({ repos: z.array(repoSchema), }); -function view(workspace: Awaited>[number]) { +function view(workspace: Workspace) { return { id: workspace.id, name: workspace.name, @@ -33,7 +32,9 @@ function view(workspace: Awaited>[number]) { } /** Build tools for listing and selecting registered workspaces. */ -export function createWorkspaceTools(context: ToolRuntimeContext): ToolRegistry { +export function createWorkspaceTools( + context: ToolRuntimeContext, +): ToolRegistry { if (!context.workspaces) return {}; return { listWorkspaces: zodTool({ @@ -53,11 +54,14 @@ export function createWorkspaceTools(context: ToolRuntimeContext): ToolRegistry async execute() { return { active_workspace_id: context.workspaces!.activeWorkspaceId() ?? null, - workspaces: (await listWorkspaces(getDb())).map(view), + workspaces: [...context.workspaces!.recipes] + .sort((left, right) => left.name.localeCompare(right.name)) + .map(view), }; }, }), switchWorkspace: zodTool({ + executionMode: "sequential", annotations: { destructiveHint: true, idempotentHint: true, @@ -75,8 +79,11 @@ export function createWorkspaceTools(context: ToolRuntimeContext): ToolRegistry workspace: workspaceSchema, }), async execute({ name }, options) { - const workspace = await getWorkspaceByName(getDb(), name); - if (!workspace) throw new ToolInputError(`Workspace not found: ${name}`); + const workspace = context.workspaces!.recipes.find( + (value) => value.name === 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 index 3460aa7562..c7f5a74ea4 100644 --- a/packages/junior/src/chat/workspaces/types.ts +++ b/packages/junior/src/chat/workspaces/types.ts @@ -10,6 +10,5 @@ export interface Workspace { id: string; name: string; setupScript: string; - updatedAt: Date; repos: WorkspaceRepo[]; } diff --git a/packages/junior/src/cli/chat.ts b/packages/junior/src/cli/chat.ts index 7640b33010..2af41c5c52 100644 --- a/packages/junior/src/cli/chat.ts +++ b/packages/junior/src/cli/chat.ts @@ -13,7 +13,8 @@ import { import { randomUUID } from "node:crypto"; import * as readline from "node:readline/promises"; import { createJiti } from "jiti"; -import { loadAppPluginSet } from "@/plugin-module"; +import { loadAppPluginSet, resolvePluginModule } from "@/plugin-module"; +import type { Workspace } from "@/chat/workspaces/types"; import { normalizeLocalConversationId } from "@/chat/local/conversation"; import type { LocalAgentReply, @@ -136,6 +137,35 @@ async function loadLocalPluginSet(): Promise { ); } +/** Load app-local Workspace recipes for source-mode local chat. */ +async function loadLocalWorkspaces(): Promise { + let moduleRef: ReturnType; + try { + moduleRef = resolvePluginModule(process.cwd(), { + module: "./workspaces", + exportName: "workspaces", + }); + } catch (error) { + if ( + error instanceof Error && + error.message === 'Plugin module "./workspaces" could not be resolved' + ) { + return undefined; + } + throw error; + } + const mod = await localPluginLoader.import>( + moduleRef.importPath, + ); + const { defineJuniorWorkspaces } = await import("@/chat/workspaces/config"); + if (!Array.isArray(mod.workspaces)) { + throw new Error( + `Workspace module ${moduleRef.importUrl}#workspaces must export an array`, + ); + } + return defineJuniorWorkspaces(mod.workspaces as Workspace[]); +} + /** Configure plugin hooks after local chat has selected its state adapter. */ async function configureLocalChatPlugins( pluginSet?: JuniorPluginSet | null, @@ -236,6 +266,7 @@ async function prepareLocalChatRun( ) { defaultStateAdapterForLocalChat(); await configureLocalChatPlugins(pluginSet); + const workspaces = await loadLocalWorkspaces(); // Local chat is the createApp-equivalent entrypoint. Opt into experimental // subagents here so spawnAgent matches the wired child-worker path. const { setExperimentalFeatures } = await import("@/chat/experimental"); @@ -274,6 +305,7 @@ async function prepareLocalChatRun( agentRunner = createAgentRunner(executeAgentRun, { bindSpawnAgent: (request) => bindSpawnAgent(request, { queue: localConversationWork.queue }), + workspaces, }); const oauthCallback = await startLocalOAuthCallbackServer(agentRunner); const deps: LocalAgentTurnDeps = { diff --git a/packages/junior/src/cli/init.ts b/packages/junior/src/cli/init.ts index 6c46d7493f..2ddf0094f0 100644 --- a/packages/junior/src/cli/init.ts +++ b/packages/junior/src/cli/init.ts @@ -12,13 +12,16 @@ initSentry(); const [ { createApp }, { plugins }, + { workspaces }, ] = await Promise.all([ import("@sentry/junior"), import("./plugins.ts"), + import("./workspaces.ts"), ]); const app = await createApp({ plugins, + workspaces, }); export default app; @@ -50,6 +53,16 @@ export const plugins = defineJuniorPlugins([ ); } +function writeWorkspacesFile(targetDir: string): void { + fs.writeFileSync( + path.join(targetDir, "workspaces.ts"), + `import { defineJuniorWorkspaces } from "@sentry/junior"; + +export const workspaces = defineJuniorWorkspaces([]); +`, + ); +} + function writeNitroConfig(targetDir: string): void { fs.writeFileSync( path.join(targetDir, "nitro.config.ts"), @@ -267,6 +280,7 @@ SENTRY_AUTH_TOKEN= writeServerEntry(target); writeInstrumentFile(target); writePluginsFile(target); + writeWorkspacesFile(target); writeNitroConfig(target); writeTsConfig(target); writePnpmWorkspace(target); diff --git a/packages/junior/src/db/schema.ts b/packages/junior/src/db/schema.ts index cf7c33d39d..55371b38bf 100644 --- a/packages/junior/src/db/schema.ts +++ b/packages/junior/src/db/schema.ts @@ -21,7 +21,6 @@ import { juniorSchedulerTasks, } from "./schema/scheduled-tasks"; import { juniorUsers } from "./schema/users"; -import { juniorWorkspaceRepos, juniorWorkspaces } from "./schema/workspaces"; export { juniorArtifacts, @@ -43,8 +42,6 @@ export { juniorSchedulerRuns, juniorSchedulerTasks, juniorUsers, - juniorWorkspaceRepos, - juniorWorkspaces, }; export const juniorSqlSchema = { @@ -67,6 +64,4 @@ 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 deleted file mode 100644 index 8d4b731e75..0000000000 --- a/packages/junior/src/db/schema/workspaces.ts +++ /dev/null @@ -1,46 +0,0 @@ -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 dbc9772a25..96f9cc9366 100644 --- a/packages/junior/tests/component/misc/sandbox-executor.test.ts +++ b/packages/junior/tests/component/misc/sandbox-executor.test.ts @@ -82,7 +82,6 @@ vi.mock("@/chat/config", async (importOriginal) => { getChatConfig: () => memoryConfig, }; }); - vi.mock("@/chat/plugins/catalog-runtime", () => ({ pluginCatalogRuntime: { getProviders: () => [ @@ -595,37 +594,7 @@ describe("createTestSandbox", () => { expect(executor.getSandboxId()).toBe("sbx_stopped"); }); - it("retains a fresh sandbox hint when its setup session becomes unavailable", async () => { - const stoppedSandbox = makeSandbox("sbx_fresh_stopped", { - mkDirError: createApiError( - 410, - "Gone", - "sandbox_stopped", - "Sandbox has stopped execution and is no longer available", - ), - }); - const recoveredSandbox = makeSandbox("sbx_fresh_stopped"); - hashMock.mockReturnValue("profile-v1"); - sandboxCreateMock.mockResolvedValueOnce(stoppedSandbox); - sandboxGetMock.mockResolvedValueOnce(recoveredSandbox); - - const executor = createTestSandbox(); - executor.configureSkills([]); - - await expect(executor.createSandbox()).rejects.toBeInstanceOf( - ToolInputError, - ); - const sandbox = await executor.createSandbox(); - - await expectWorkspaceToDelegate(sandbox, recoveredSandbox); - expect(sandboxCreateMock).toHaveBeenCalledTimes(1); - expect(sandboxGetMock).toHaveBeenCalledWith({ - name: "sbx_fresh_stopped", - resume: true, - }); - }); - - it("reports a fresh sandbox reference before session preparation can fail", async () => { + it("reports a fresh sandbox reference only after preparation succeeds", async () => { const unavailable = createClosedStreamError(); const freshSandbox = makeSandbox("sbx_prepare_failure"); const callOrder: string[] = []; @@ -649,21 +618,22 @@ describe("createTestSandbox", () => { ToolInputError, ); - expect(callOrder).toEqual(["reference", "prepare"]); - expect(executor.getSandboxId()).toBe("sbx_prepare_failure"); + expect(callOrder).toEqual(["prepare"]); + expect(executor.getSandboxId()).toBeUndefined(); + expect(freshSandbox.stop).toHaveBeenCalledTimes(1); }); it("retries durable reference reporting after persistence fails", async () => { const freshSandbox = makeSandbox("sbx_ref_retry"); - const restoredSandbox = makeSandbox("sbx_ref_retry"); - restoredSandbox.session.sessionId = "sbx_ref_retry_restored"; + const replacementSandbox = makeSandbox("sbx_ref_retry_replacement"); const persistenceError = new Error("state unavailable"); const onSandboxAcquired = vi .fn() .mockRejectedValueOnce(persistenceError) .mockResolvedValueOnce(undefined); - sandboxCreateMock.mockResolvedValueOnce(freshSandbox); - sandboxGetMock.mockResolvedValueOnce(restoredSandbox); + sandboxCreateMock + .mockResolvedValueOnce(freshSandbox) + .mockResolvedValueOnce(replacementSandbox); const executor = createTestSandbox({ onSandboxAcquired }); executor.configureSkills([]); @@ -672,11 +642,9 @@ describe("createTestSandbox", () => { await expect(executor.createSandbox()).resolves.toBeDefined(); expect(onSandboxAcquired).toHaveBeenCalledTimes(2); - expect(sandboxCreateMock).toHaveBeenCalledTimes(1); - expect(sandboxGetMock).toHaveBeenCalledWith({ - name: "sbx_ref_retry", - resume: true, - }); + expect(sandboxCreateMock).toHaveBeenCalledTimes(2); + expect(sandboxGetMock).not.toHaveBeenCalled(); + expect(freshSandbox.stop).toHaveBeenCalledTimes(1); }); it("shares in-flight sandbox setup across parallel executor initialization", async () => { @@ -1021,7 +989,6 @@ describe("createTestSandbox", () => { id: "workspace-1", name: "sentry", setupScript: "", - updatedAt: new Date("2026-08-11T00:00:00.000Z"), repos: [], }; const runtime = createSandboxRuntime({ @@ -1033,7 +1000,6 @@ describe("createTestSandbox", () => { await runtime.acquire(); await runtime.switchWorkspace({ ...workspace, - updatedAt: new Date("2026-08-12T00:00:00.000Z"), }); expect(sandboxCreateMock).toHaveBeenCalledTimes(2); @@ -1041,46 +1007,7 @@ describe("createTestSandbox", () => { 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 () => { + it("keeps 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; @@ -1100,7 +1027,6 @@ describe("createTestSandbox", () => { id: "workspace-initial", name: "initial", setupScript: "", - updatedAt: new Date("2026-08-11T00:00:00.000Z"), repos: [], }; const runtime = createSandboxRuntime({ @@ -1136,7 +1062,6 @@ describe("createTestSandbox", () => { id: "workspace-1", name: "sentry", setupScript: "", - updatedAt: new Date("2026-08-11T00:00:00.000Z"), repos: [], }; const runtime = createSandboxRuntime({ @@ -1166,8 +1091,11 @@ describe("createTestSandbox", () => { hashMock.mockReturnValue("profile-base"); const restored = makeSandbox("sbx_missing_recipe"); sandboxGetMock.mockResolvedValueOnce(restored); - const refs: Array<{ id: string; workspaceId?: string; profileHash?: string } | null> = - []; + const refs: Array<{ + id: string; + workspaceId?: string; + profileHash?: string; + } | null> = []; const runtime = createSandboxRuntime({ sandboxRef: { id: "sbx_missing_recipe", @@ -1194,6 +1122,62 @@ describe("createTestSandbox", () => { expect(refs).toEqual([]); }); + it("limits credential egress to Workspace provider preparation", async () => { + const buildSandbox = makeSandbox("sbx_workspace_build"); + const activeSandbox = makeSandbox("sbx_workspace_active"); + const policy = { + allow: { + "*": [], + "github.com": [ + { + forwardURL: + "https://junior.example.com/api/internal/sandbox-egress/token", + }, + ], + }, + }; + const createNetworkPolicy = vi.fn(() => policy); + const onWorkspacePrepare = vi.fn(async () => { + expect(buildSandbox.update).toHaveBeenLastCalledWith({ + networkPolicy: policy, + }); + }); + resolveMock.mockImplementationOnce(async (params: any) => { + await params.prepareWorkspace?.(buildSandbox); + return { + snapshotId: "snap_workspace", + profileHash: "profile-workspace", + dependencyCount: 0, + cacheHit: false, + resolveOutcome: "rebuilt", + }; + }); + hashMock.mockReturnValue("profile-workspace"); + sandboxCreateMock.mockResolvedValueOnce(activeSandbox); + const runtime = createSandboxRuntime({ + workspace: { + id: "workspace-1", + name: "sentry", + setupScript: "", + repos: [], + }, + skills: [], + referenceFiles: [], + createNetworkPolicy, + onWorkspacePrepare, + }); + + await runtime.acquire(); + + expect(onWorkspacePrepare).toHaveBeenCalledTimes(1); + expect(buildSandbox.update).toHaveBeenNthCalledWith(1, { + networkPolicy: policy, + }); + expect(buildSandbox.update).toHaveBeenNthCalledWith(2, { + networkPolicy: "allow-all", + }); + }); + it("forwards abort signal into workspace setup scripts", async () => { const buildSandbox = makeSandbox("sbx_workspace_setup_signal"); const controller = new AbortController(); @@ -1203,7 +1187,9 @@ describe("createTestSandbox", () => { resolve(); await new Promise((settle) => { releaseSetup = settle; - input.signal?.addEventListener("abort", () => settle(), { once: true }); + input.signal?.addEventListener("abort", () => settle(), { + once: true, + }); }); if (input.signal?.aborted) { const error = new Error("aborted"); @@ -1229,7 +1215,6 @@ describe("createTestSandbox", () => { id: "workspace-setup", name: "setup", setupScript: "echo ready", - updatedAt: new Date("2026-08-12T00:00:00.000Z"), repos: [], }, skills: [], @@ -1252,61 +1237,18 @@ describe("createTestSandbox", () => { 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 () => { + it("keeps the durable workspace reference 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 refs: Array<{ id: string; workspaceId?: string }> = []; const initialWorkspace = { id: "workspace-initial", name: "initial", setupScript: "", - updatedAt: new Date("2026-08-11T00:00:00.000Z"), repos: [], }; const nextWorkspace = { @@ -1326,9 +1268,6 @@ describe("createTestSandbox", () => { }, onSandboxRefChanged: async (ref) => { refs.push(ref); - if (ref === null) { - throw new Error("persistence failed"); - } }, }); @@ -1337,60 +1276,16 @@ describe("createTestSandbox", () => { "sandbox setup failed", ); - // Failed replacement is stopped; prior live sandbox is restored and re-reported. + // Failed replacement is stopped before it can replace durable or live state. 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 () => { + it("waits for an in-flight acquisition before workspace switch", async () => { const lateSandbox = makeSandbox("sbx_workspace_late_inflight"); const nextSandbox = makeSandbox("sbx_workspace_switch_target"); let releaseCreate: (() => void) | undefined; @@ -1410,7 +1305,6 @@ describe("createTestSandbox", () => { id: "workspace-initial", name: "initial", setupScript: "", - updatedAt: new Date("2026-08-11T00:00:00.000Z"), repos: [], }; const nextWorkspace = { @@ -1424,63 +1318,22 @@ describe("createTestSandbox", () => { referenceFiles: [], }); - // Cold acquire is still in flight when switch aborts it. + // Cold acquire is still in flight when the switch starts. 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. + // Finish the current acquisition before the candidate build starts. 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( @@ -1522,59 +1375,6 @@ 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( - 410, - "Gone", - "snapshot_not_found", - "The sandbox snapshot was not found", - ), - ])("replaces a permanently missing sandbox reference", async (missing) => { - const freshSandbox = makeSandbox("sbx_replacement"); - const onSandboxAcquired = vi.fn(); - sandboxGetMock.mockRejectedValueOnce(missing); - sandboxCreateMock.mockResolvedValueOnce(freshSandbox); - - const executor = createTestSandbox({ - sandboxId: "sbx_missing", - onSandboxAcquired, - }); - executor.configureSkills([]); - - await executor.createSandbox(); - - expect(executor.getSandboxId()).toBe("sbx_replacement"); - expect(onSandboxAcquired).toHaveBeenCalledWith({ - sandboxId: "sbx_replacement", - }); - expect(sandboxCreateMock).toHaveBeenCalledTimes(1); - }); - it("defers to SDK OIDC resolution when VERCEL_OIDC_TOKEN is set without explicit credentials", async () => { process.env.VERCEL_OIDC_TOKEN = "oidc-jwt-token"; process.env.VERCEL_TEAM_ID = "team_123"; diff --git a/packages/junior/tests/component/sandbox/snapshot/resolve.test.ts b/packages/junior/tests/component/sandbox/snapshot/resolve.test.ts index 9de17e8491..7e81c7175a 100644 --- a/packages/junior/tests/component/sandbox/snapshot/resolve.test.ts +++ b/packages/junior/tests/component/sandbox/snapshot/resolve.test.ts @@ -411,21 +411,17 @@ describe("snapshot resolution", () => { expect(sandboxCreateMock).not.toHaveBeenCalled(); }); - it("builds workspace snapshots by extending the cached base snapshot", async () => { + it("builds and reuses one complete workspace 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); + sandboxCreateMock.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", @@ -446,20 +442,12 @@ describe("snapshot resolution", () => { 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(sandboxCreateMock).toHaveBeenCalledTimes(1); + expect(sandboxCreateMock).toHaveBeenCalledWith( 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(); + expect(workspaceSandbox.runCommand).toHaveBeenCalled(); const reused = await resolveSnapshot({ runtime: "node22", @@ -469,127 +457,7 @@ describe("snapshot resolution", () => { }); 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(sandboxCreateMock).toHaveBeenCalledTimes(1); 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 a8b38845dd..5e8d5cdbd2 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: 13, + migrated: 12, }); const [migrated] = await fixture.sql.query<{ creatorIdentityId: string | null; diff --git a/packages/junior/tests/unit/cli/init-cli.test.ts b/packages/junior/tests/unit/cli/init-cli.test.ts index dde3674c15..49d6fdd65a 100644 --- a/packages/junior/tests/unit/cli/init-cli.test.ts +++ b/packages/junior/tests/unit/cli/init-cli.test.ts @@ -74,6 +74,7 @@ describe("init cli", () => { expect(fs.existsSync(path.join(target, "vercel.json"))).toBe(true); expect(fs.existsSync(path.join(target, "nitro.config.ts"))).toBe(true); expect(fs.existsSync(path.join(target, "plugins.ts"))).toBe(true); + expect(fs.existsSync(path.join(target, "workspaces.ts"))).toBe(true); expect(fs.existsSync(path.join(target, "vite.config.ts"))).toBe(false); expect(fs.existsSync(path.join(target, "tsconfig.json"))).toBe(true); expect(fs.existsSync(path.join(target, "app", "SOUL.md"))).toBe(true); @@ -109,8 +110,10 @@ describe("init cli", () => { ); expect(serverEntry).toContain('import("@sentry/junior")'); expect(serverEntry).toContain('import("./plugins.ts")'); + expect(serverEntry).toContain('import("./workspaces.ts")'); expect(serverEntry).toContain("createApp({"); expect(serverEntry).toContain("plugins,"); + expect(serverEntry).toContain("workspaces,"); const instrumentFile = fs.readFileSync( path.join(target, "instrument.mjs"), @@ -158,6 +161,15 @@ describe("init cli", () => { expect(pluginsFile).toContain("memoryPlugin()"); expect(pluginsFile).toContain('"@sentry/junior-maintenance"'); + const workspacesFile = fs.readFileSync( + path.join(target, "workspaces.ts"), + "utf8", + ); + expect(workspacesFile).toContain( + 'import { defineJuniorWorkspaces } from "@sentry/junior";', + ); + expect(workspacesFile).toContain("defineJuniorWorkspaces([])"); + const pkg = readJsonFile<{ dependencies: Record; devDependencies: Record; diff --git a/packages/junior/tests/unit/sandbox/snapshot/profile.test.ts b/packages/junior/tests/unit/sandbox/snapshot/profile.test.ts index 9e39bcccec..32d49487ee 100644 --- a/packages/junior/tests/unit/sandbox/snapshot/profile.test.ts +++ b/packages/junior/tests/unit/sandbox/snapshot/profile.test.ts @@ -77,12 +77,11 @@ describe("snapshot dependency profile", () => { expect(profile?.floating).toBe(true); }); - it("includes workspace contents in the profile hash", () => { + 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", @@ -94,167 +93,74 @@ describe("snapshot dependency profile", () => { }; const first = create("node22", workspace); - const changed = create("node22", { + const changedSetup = 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, - }, - ], + const changedRepo = create("node22", { + ...workspace, + repos: [{ ...workspace.repos[0]!, repo: "getsentry/junior" }], }); - expect(first?.hash).toBe(second?.hash); + expect(first?.hash).not.toBe(changedSetup?.hash); + expect(first?.hash).not.toBe(changedRepo?.hash); + expect(first?.floating).toBe(true); }); - it("ignores isPrimary when hashing workspace profiles", () => { - const updatedAt = new Date("2026-03-10T00:00:00.000Z"); + it("normalizes repository order and ignores the primary selection", () => { + const repos = [ + { + provider: "github", + repo: "getsentry/sentry", + checkoutPath: "sentry", + isPrimary: true, + }, + { + provider: "github", + repo: "getsentry/relay", + checkoutPath: "relay", + isPrimary: false, + }, + ]; 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, - }, - ], + setupScript: "", + repos, }); - const second = create("node22", { + const reordered = 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, - }, - ], + setupScript: "", + repos: [...repos] + .reverse() + .map((repo) => ({ ...repo, isPrimary: !repo.isPrimary })), }); - expect(first?.hash).toBe(second?.hash); + expect(first?.hash).toBe(reordered?.hash); }); - it("layers workspace profiles on the base hash without reinstall deps", () => { + it("installs dependencies in the complete Workspace profile", () => { 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, - }, - ], + setupScript: "", + repos: [], }; - - 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); + const changed = create("node22", workspace); - expect(first?.baseHash).not.toBe(second?.baseHash); - expect(first?.hash).not.toBe(second?.hash); + expect(first?.dependencies).toEqual([ + { type: "npm", package: "example", version: "1.2.3" }, + ]); + expect(first?.hash).not.toBe(changed?.hash); }); it("changes the hash when the rebuild epoch changes", () => { diff --git a/packages/junior/tests/unit/tools/workspaces.test.ts b/packages/junior/tests/unit/tools/workspaces.test.ts index 4e0584e256..addb115465 100644 --- a/packages/junior/tests/unit/tools/workspaces.test.ts +++ b/packages/junior/tests/unit/tools/workspaces.test.ts @@ -1,25 +1,11 @@ import { describe, expect, it, vi } from "vitest"; +import { defineJuniorWorkspaces } from "@/chat/workspaces/config"; 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", @@ -31,13 +17,19 @@ const workspace = { }; describe("workspace tools", () => { + it("validates install-wide Workspace recipes", () => { + expect(defineJuniorWorkspaces([workspace])).toEqual([workspace]); + expect(() => defineJuniorWorkspaces([workspace, { ...workspace }])).toThrow( + "Duplicate Workspace id: workspace-1", + ); + }); + it("lists and switches registered workspaces", async () => { - listWorkspacesMock.mockResolvedValue([workspace]); - getWorkspaceByNameMock.mockResolvedValue(workspace); const switchWorkspace = vi.fn(); const tools = createWorkspaceTools({ workspaces: { activeWorkspaceId: () => undefined, + recipes: [workspace], switch: switchWorkspace, }, } as never); @@ -48,7 +40,10 @@ describe("workspace tools", () => { workspaces: [{ id: "workspace-1", name: "sentry" }], }); - const switched = await tools.switchWorkspace!.execute!({ name: "sentry" }, {}); + const switched = await tools.switchWorkspace!.execute!( + { name: "sentry" }, + {}, + ); expect(switchWorkspace).toHaveBeenCalledWith(workspace, undefined); expect(switched).toMatchObject({ workspace: { name: "sentry" } }); }); From 70ac983929d883912cf8f80b0e7b52c52608bbfb Mon Sep 17 00:00:00 2001 From: David Cramer Date: Thu, 13 Aug 2026 12:08:52 -0700 Subject: [PATCH 25/34] fix(workspaces): Restore database configuration --- apps/example/README.md | 1 - apps/example/server.ts | 3 - apps/example/workspaces.ts | 17 ---- .../content/docs/operate/sandbox-snapshots.md | 2 +- packages/junior/README.md | 25 +---- packages/junior/src/app.ts | 8 -- packages/junior/src/chat/agent/tools.ts | 8 +- packages/junior/src/chat/agent/types.ts | 3 - .../junior/src/chat/runtime/agent-runner.ts | 4 - packages/junior/src/chat/tools/types.ts | 1 - packages/junior/src/chat/workspaces/config.ts | 64 ------------- packages/junior/src/chat/workspaces/store.ts | 91 +++++++++++++++++++ packages/junior/src/chat/workspaces/tools.ts | 13 +-- packages/junior/src/cli/chat.ts | 34 +------ packages/junior/src/cli/init.ts | 14 --- packages/junior/src/db/schema.ts | 5 + packages/junior/src/db/schema/workspaces.ts | 46 ++++++++++ .../component/scheduled-tasks-sql.test.ts | 2 +- .../junior/tests/unit/cli/init-cli.test.ts | 12 --- .../tests/unit/tools/workspaces.test.ts | 25 +++-- 20 files changed, 170 insertions(+), 208 deletions(-) delete mode 100644 apps/example/workspaces.ts delete mode 100644 packages/junior/src/chat/workspaces/config.ts create mode 100644 packages/junior/src/chat/workspaces/store.ts create mode 100644 packages/junior/src/db/schema/workspaces.ts diff --git a/apps/example/README.md b/apps/example/README.md index 6d2c2121e5..02f10c661d 100644 --- a/apps/example/README.md +++ b/apps/example/README.md @@ -36,5 +36,4 @@ heartbeat, or server paths use them. - `plugins.ts` is the single source of truth for installed plugin registrations and runtime hook plugins in this app - `nitro.config.ts` points `juniorNitro()` at `./plugins` so plugin content is copied into the build output and exposed to runtime through the virtual config module - `server.ts` imports the same plugin set and passes it to `createApp({ plugins })` so local dev and built bundles load identical runtime plugins -- `workspaces.ts` defines install-wide Workspace recipes and `server.ts` passes them to `createApp({ workspaces })`; local chat loads the same module - root `pnpm dev` starts a local heartbeat loop that calls `/api/internal/heartbeat` every minute, matching the production cron pulse used for plugin heartbeats and stale dispatch recovery diff --git a/apps/example/server.ts b/apps/example/server.ts index 34254471ac..5d5d2adce8 100644 --- a/apps/example/server.ts +++ b/apps/example/server.ts @@ -10,12 +10,10 @@ const [ exampleDashboardMockConversations, }, { plugins }, - { workspaces }, ] = await Promise.all([ import("@sentry/junior"), import("./dashboard.ts"), import("./plugins.ts"), - import("./workspaces.ts"), ]); const app = await createApp({ @@ -26,7 +24,6 @@ const app = await createApp({ mockConversations: exampleDashboardMockConversations(), }, plugins, - workspaces, configDefaults: { "sentry.org": "sentry", }, diff --git a/apps/example/workspaces.ts b/apps/example/workspaces.ts deleted file mode 100644 index 6f380d0629..0000000000 --- a/apps/example/workspaces.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { defineJuniorWorkspaces } from "@sentry/junior"; - -export const workspaces = defineJuniorWorkspaces([ - { - id: "junior", - name: "junior", - setupScript: "", - repos: [ - { - provider: "github", - repo: "getsentry/junior", - checkoutPath: "junior", - isPrimary: true, - }, - ], - }, -]); diff --git a/packages/docs/src/content/docs/operate/sandbox-snapshots.md b/packages/docs/src/content/docs/operate/sandbox-snapshots.md index 595683ab74..52691eb94a 100644 --- a/packages/docs/src/content/docs/operate/sandbox-snapshots.md +++ b/packages/docs/src/content/docs/operate/sandbox-snapshots.md @@ -44,7 +44,7 @@ Any change to those inputs produces a new profile hash and a new snapshot. ## Repository Workspaces -Define install-wide Workspace recipes with `defineJuniorWorkspaces(...)`, then pass them to `createApp({ workspaces })`. Put the value in an app-local `workspaces.ts` file so `junior chat` loads the same recipes. +Junior stores install-wide Workspace recipes and their repositories in SQL. The agent reads this configuration when it lists a Workspace, resumes an active Workspace, or starts a switch. Junior builds one complete snapshot for each selected Workspace. The build installs runtime dependencies, prepares repositories, runs the setup script, and then captures the snapshot. The first switch builds the snapshot on demand. Later switches reuse it until its floating profile becomes stale. diff --git a/packages/junior/README.md b/packages/junior/README.md index 2b28849924..0825c8aa6d 100644 --- a/packages/junior/README.md +++ b/packages/junior/README.md @@ -34,30 +34,7 @@ const app = await createApp({ export default app; ``` -Define named repository Workspaces in `workspaces.ts`: - -```ts -import { defineJuniorWorkspaces } from "@sentry/junior"; - -export const workspaces = defineJuniorWorkspaces([ - { - id: "app", - name: "app", - setupScript: "pnpm install", - repos: [ - { - provider: "github", - repo: "example/app", - checkoutPath: "app", - isPrimary: true, - }, - ], - }, -]); -``` - -Pass this value to `createApp({ workspaces })`. The local `junior chat` -command also loads an app-local `workspaces.ts` file. +Named repository Workspace recipes are stored in the Junior SQL database. Run `junior init my-bot` to scaffold a complete project including `vercel.json` for Vercel deployment. diff --git a/packages/junior/src/app.ts b/packages/junior/src/app.ts index 211a55076b..e8bd583cbe 100644 --- a/packages/junior/src/app.ts +++ b/packages/junior/src/app.ts @@ -28,8 +28,6 @@ import { setSandboxResourceConfig, type SandboxResourceConfig, } from "@/chat/sandbox/resources"; -import { defineJuniorWorkspaces } from "@/chat/workspaces/config"; -import type { Workspace } from "@/chat/workspaces/types"; import { pluginCatalogRuntime } from "@/chat/plugins/catalog-runtime"; import { type PluginRouteRegistration, @@ -94,14 +92,12 @@ import { ingestEventTasks } from "@/chat/event-tasks/ingest"; import { receiveLocalOAuthCredential } from "@/chat/local/credential-sync"; export { defineJuniorPlugins } from "./plugins"; -export { defineJuniorWorkspaces }; export { JUNIOR_VERSION } from "./version"; export type { JuniorPluginInput, JuniorPluginSet, JuniorPluginSetOptions, } from "./plugins"; -export type { Workspace, WorkspaceRepo } from "@/chat/workspaces/types"; export interface JuniorAppOptions { /** Authenticated dashboard mounted by core when configured. */ @@ -125,8 +121,6 @@ export interface JuniorAppOptions { conversationWork?: VercelConversationWorkCallbackOptions; /** Direct plugin set override. Usually omitted when `juniorNitro()` uses a plugin module. */ plugins?: JuniorPluginSet; - /** Install-wide named repository Workspace recipes. */ - workspaces?: readonly Workspace[]; /** Sandbox execution options. */ sandbox?: SandboxResourceConfig & { /** @@ -612,7 +606,6 @@ export async function createApp(options?: JuniorAppOptions): Promise { ); } const dashboard = options?.dashboard ?? virtualConfig?.dashboard; - const workspaces = defineJuniorWorkspaces(options?.workspaces ?? []); const configuredPlugins = options?.plugins ?? virtualConfig?.pluginSet; const plugins = pluginRuntimeRegistrationsFromPluginSet(configuredPlugins); const pluginConfig = configuredPlugins @@ -710,7 +703,6 @@ export async function createApp(options?: JuniorAppOptions): Promise { bindSpawnAgent: (request) => bindSpawnAgent(request, { queue: conversationWorkQueue }), tracePropagation, - workspaces, }); const runtimeServiceOverrides = { replyExecutor: { agentRunner }, diff --git a/packages/junior/src/chat/agent/tools.ts b/packages/junior/src/chat/agent/tools.ts index 4a3ca49822..6972092c11 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"; @@ -221,11 +223,8 @@ export async function wireAgentTools( actor: args.currentActor, actors: args.currentActors, }); - const workspaces = args.run.environment?.workspaces ?? []; const workspace = args.state.sandboxRef?.workspaceId - ? workspaces.find( - (value) => value.id === args.state.sandboxRef?.workspaceId, - ) + ? await getWorkspace(getDb(), args.state.sandboxRef.workspaceId) : undefined; const agentSandbox = createAgentSandbox({ sandboxRef: args.state.sandboxRef, @@ -372,7 +371,6 @@ export async function wireAgentTools( attachmentStorage: args.run.environment?.attachmentStorage, workspaces: { activeWorkspaceId: () => agentSandbox.sandboxRef()?.workspaceId, - recipes: workspaces, switch: agentSandbox.switchWorkspace, }, } as ToolRuntimeContext; diff --git a/packages/junior/src/chat/agent/types.ts b/packages/junior/src/chat/agent/types.ts index a6408bfdeb..85b6047ee9 100644 --- a/packages/junior/src/chat/agent/types.ts +++ b/packages/junior/src/chat/agent/types.ts @@ -35,7 +35,6 @@ import type { } from "@/chat/tools/types"; import type { AssistantMessage } from "@earendil-works/pi-ai"; import type { AttachmentStorage } from "@/chat/attachments/storage"; -import type { Workspace } from "@/chat/workspaces/types"; /** One attachment the model may see for the current instruction. */ export type AgentAttachment = { @@ -180,8 +179,6 @@ export type AgentEnvironment = { sandboxTracePropagation?: SandboxEgressTracePropagationConfig; /** Per-slice sandbox egress signal storage override. */ sandboxEgressSignals?: SandboxEgressSignalTransport; - /** Immutable install-wide Workspace recipes for this run. */ - workspaces?: readonly Workspace[]; toolOverrides?: { imageGenerate?: ImageGenerateToolDeps; viewImage?: ViewImageToolDeps; diff --git a/packages/junior/src/chat/runtime/agent-runner.ts b/packages/junior/src/chat/runtime/agent-runner.ts index b15503b947..b9fb06ffd2 100644 --- a/packages/junior/src/chat/runtime/agent-runner.ts +++ b/packages/junior/src/chat/runtime/agent-runner.ts @@ -8,7 +8,6 @@ import { isExperimentalFeatureEnabled } from "@/chat/experimental"; import type { AgentRunOutcome } from "@/chat/runtime/agent-run-outcome"; import type { SandboxEgressTracePropagationConfig } from "@/chat/sandbox/egress/tracing"; import type { AttachmentStorage } from "@/chat/attachments/storage"; -import type { Workspace } from "@/chat/workspaces/types"; const AGENT_ABORT_SETTLE_GRACE_MS = 5_000; @@ -25,13 +24,11 @@ export function createAgentRunner( bindSpawnAgent?: (run: AgentRun) => SpawnAgent | undefined; streamFn?: StreamFn; tracePropagation?: SandboxEgressTracePropagationConfig; - workspaces?: readonly Workspace[]; }, ): AgentRunner { const attachmentStorage = options?.attachmentStorage; const streamFn = options?.streamFn; const tracePropagation = options?.tracePropagation; - const workspaces = options?.workspaces; const bindSpawnAgent = options?.bindSpawnAgent; const canBindSpawn = Boolean(bindSpawnAgent) && isExperimentalFeatureEnabled("subagents"); @@ -51,7 +48,6 @@ export function createAgentRunner( run.environment?.attachmentStorage ?? attachmentStorage, sandboxTracePropagation: run.environment?.sandboxTracePropagation ?? tracePropagation, - workspaces: run.environment?.workspaces ?? workspaces, }, ...(spawnAgent ? { diff --git a/packages/junior/src/chat/tools/types.ts b/packages/junior/src/chat/tools/types.ts index 7bd54242eb..05c315e759 100644 --- a/packages/junior/src/chat/tools/types.ts +++ b/packages/junior/src/chat/tools/types.ts @@ -103,7 +103,6 @@ interface BaseToolRuntimeContext { workspace: SandboxWorkspace; workspaces?: { activeWorkspaceId(): string | undefined; - recipes: readonly Workspace[]; switch(workspace: Workspace, signal?: AbortSignal): Promise; }; /** Report whether the model currently executing the turn accepts images. */ diff --git a/packages/junior/src/chat/workspaces/config.ts b/packages/junior/src/chat/workspaces/config.ts deleted file mode 100644 index 4b467d0c13..0000000000 --- a/packages/junior/src/chat/workspaces/config.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { Workspace } from "./types"; - -function copyWorkspace(workspace: Workspace): Workspace { - return { - ...workspace, - repos: workspace.repos.map((repo) => ({ ...repo })), - }; -} - -function validateWorkspaces(input: readonly Workspace[]): Workspace[] { - const ids = new Set(); - const names = new Set(); - - return input.map((value) => { - const workspace = copyWorkspace(value); - if (!workspace.id.trim()) throw new Error("Workspace id must not be empty"); - if (!workspace.name.trim()) { - throw new Error("Workspace name must not be empty"); - } - if (ids.has(workspace.id)) { - throw new Error(`Duplicate Workspace id: ${workspace.id}`); - } - if (names.has(workspace.name)) { - throw new Error(`Duplicate Workspace name: ${workspace.name}`); - } - ids.add(workspace.id); - names.add(workspace.name); - - const checkoutPaths = new Set(); - let primaryRepoCount = 0; - for (const repo of workspace.repos) { - if (!repo.provider.trim() || !repo.repo.trim()) { - throw new Error( - `Workspace ${workspace.name} repository provider and name must not be empty`, - ); - } - if (!repo.checkoutPath.trim()) { - throw new Error( - `Workspace ${workspace.name} checkout path must not be empty`, - ); - } - if (checkoutPaths.has(repo.checkoutPath)) { - throw new Error( - `Workspace ${workspace.name} has duplicate checkout path: ${repo.checkoutPath}`, - ); - } - checkoutPaths.add(repo.checkoutPath); - if (repo.isPrimary) primaryRepoCount += 1; - } - if (primaryRepoCount > 1) { - throw new Error( - `Workspace ${workspace.name} must not have more than one primary repository`, - ); - } - return workspace; - }); -} - -/** Define immutable install-wide Workspace recipes. */ -export function defineJuniorWorkspaces( - input: readonly Workspace[], -): Workspace[] { - return validateWorkspaces(input); -} diff --git a/packages/junior/src/chat/workspaces/store.ts b/packages/junior/src/chat/workspaces/store.ts new file mode 100644 index 0000000000..d8dd09df90 --- /dev/null +++ b/packages/junior/src/chat/workspaces/store.ts @@ -0,0 +1,91 @@ +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, + 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 index 4010da3d8b..584d9f2425 100644 --- a/packages/junior/src/chat/workspaces/tools.ts +++ b/packages/junior/src/chat/workspaces/tools.ts @@ -1,10 +1,11 @@ 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 type { Workspace } from "./types"; +import { getWorkspaceByName, listWorkspaces } from "./store"; const repoSchema = z.object({ provider: z.string(), @@ -18,7 +19,7 @@ const workspaceSchema = z.object({ repos: z.array(repoSchema), }); -function view(workspace: Workspace) { +function view(workspace: Awaited>[number]) { return { id: workspace.id, name: workspace.name, @@ -54,9 +55,7 @@ export function createWorkspaceTools( async execute() { return { active_workspace_id: context.workspaces!.activeWorkspaceId() ?? null, - workspaces: [...context.workspaces!.recipes] - .sort((left, right) => left.name.localeCompare(right.name)) - .map(view), + workspaces: (await listWorkspaces(getDb())).map(view), }; }, }), @@ -79,9 +78,7 @@ export function createWorkspaceTools( workspace: workspaceSchema, }), async execute({ name }, options) { - const workspace = context.workspaces!.recipes.find( - (value) => value.name === name, - ); + const workspace = await getWorkspaceByName(getDb(), name); if (!workspace) throw new ToolInputError(`Workspace not found: ${name}`); await context.workspaces!.switch(workspace, options.signal); diff --git a/packages/junior/src/cli/chat.ts b/packages/junior/src/cli/chat.ts index 2af41c5c52..7640b33010 100644 --- a/packages/junior/src/cli/chat.ts +++ b/packages/junior/src/cli/chat.ts @@ -13,8 +13,7 @@ import { import { randomUUID } from "node:crypto"; import * as readline from "node:readline/promises"; import { createJiti } from "jiti"; -import { loadAppPluginSet, resolvePluginModule } from "@/plugin-module"; -import type { Workspace } from "@/chat/workspaces/types"; +import { loadAppPluginSet } from "@/plugin-module"; import { normalizeLocalConversationId } from "@/chat/local/conversation"; import type { LocalAgentReply, @@ -137,35 +136,6 @@ async function loadLocalPluginSet(): Promise { ); } -/** Load app-local Workspace recipes for source-mode local chat. */ -async function loadLocalWorkspaces(): Promise { - let moduleRef: ReturnType; - try { - moduleRef = resolvePluginModule(process.cwd(), { - module: "./workspaces", - exportName: "workspaces", - }); - } catch (error) { - if ( - error instanceof Error && - error.message === 'Plugin module "./workspaces" could not be resolved' - ) { - return undefined; - } - throw error; - } - const mod = await localPluginLoader.import>( - moduleRef.importPath, - ); - const { defineJuniorWorkspaces } = await import("@/chat/workspaces/config"); - if (!Array.isArray(mod.workspaces)) { - throw new Error( - `Workspace module ${moduleRef.importUrl}#workspaces must export an array`, - ); - } - return defineJuniorWorkspaces(mod.workspaces as Workspace[]); -} - /** Configure plugin hooks after local chat has selected its state adapter. */ async function configureLocalChatPlugins( pluginSet?: JuniorPluginSet | null, @@ -266,7 +236,6 @@ async function prepareLocalChatRun( ) { defaultStateAdapterForLocalChat(); await configureLocalChatPlugins(pluginSet); - const workspaces = await loadLocalWorkspaces(); // Local chat is the createApp-equivalent entrypoint. Opt into experimental // subagents here so spawnAgent matches the wired child-worker path. const { setExperimentalFeatures } = await import("@/chat/experimental"); @@ -305,7 +274,6 @@ async function prepareLocalChatRun( agentRunner = createAgentRunner(executeAgentRun, { bindSpawnAgent: (request) => bindSpawnAgent(request, { queue: localConversationWork.queue }), - workspaces, }); const oauthCallback = await startLocalOAuthCallbackServer(agentRunner); const deps: LocalAgentTurnDeps = { diff --git a/packages/junior/src/cli/init.ts b/packages/junior/src/cli/init.ts index 2ddf0094f0..6c46d7493f 100644 --- a/packages/junior/src/cli/init.ts +++ b/packages/junior/src/cli/init.ts @@ -12,16 +12,13 @@ initSentry(); const [ { createApp }, { plugins }, - { workspaces }, ] = await Promise.all([ import("@sentry/junior"), import("./plugins.ts"), - import("./workspaces.ts"), ]); const app = await createApp({ plugins, - workspaces, }); export default app; @@ -53,16 +50,6 @@ export const plugins = defineJuniorPlugins([ ); } -function writeWorkspacesFile(targetDir: string): void { - fs.writeFileSync( - path.join(targetDir, "workspaces.ts"), - `import { defineJuniorWorkspaces } from "@sentry/junior"; - -export const workspaces = defineJuniorWorkspaces([]); -`, - ); -} - function writeNitroConfig(targetDir: string): void { fs.writeFileSync( path.join(targetDir, "nitro.config.ts"), @@ -280,7 +267,6 @@ SENTRY_AUTH_TOKEN= writeServerEntry(target); writeInstrumentFile(target); writePluginsFile(target); - writeWorkspacesFile(target); writeNitroConfig(target); writeTsConfig(target); writePnpmWorkspace(target); diff --git a/packages/junior/src/db/schema.ts b/packages/junior/src/db/schema.ts index 55371b38bf..cf7c33d39d 100644 --- a/packages/junior/src/db/schema.ts +++ b/packages/junior/src/db/schema.ts @@ -21,6 +21,7 @@ import { juniorSchedulerTasks, } from "./schema/scheduled-tasks"; import { juniorUsers } from "./schema/users"; +import { juniorWorkspaceRepos, juniorWorkspaces } from "./schema/workspaces"; export { juniorArtifacts, @@ -42,6 +43,8 @@ export { juniorSchedulerRuns, juniorSchedulerTasks, juniorUsers, + juniorWorkspaceRepos, + juniorWorkspaces, }; export const juniorSqlSchema = { @@ -64,4 +67,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..779987aeb0 --- /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/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/cli/init-cli.test.ts b/packages/junior/tests/unit/cli/init-cli.test.ts index 49d6fdd65a..dde3674c15 100644 --- a/packages/junior/tests/unit/cli/init-cli.test.ts +++ b/packages/junior/tests/unit/cli/init-cli.test.ts @@ -74,7 +74,6 @@ describe("init cli", () => { expect(fs.existsSync(path.join(target, "vercel.json"))).toBe(true); expect(fs.existsSync(path.join(target, "nitro.config.ts"))).toBe(true); expect(fs.existsSync(path.join(target, "plugins.ts"))).toBe(true); - expect(fs.existsSync(path.join(target, "workspaces.ts"))).toBe(true); expect(fs.existsSync(path.join(target, "vite.config.ts"))).toBe(false); expect(fs.existsSync(path.join(target, "tsconfig.json"))).toBe(true); expect(fs.existsSync(path.join(target, "app", "SOUL.md"))).toBe(true); @@ -110,10 +109,8 @@ describe("init cli", () => { ); expect(serverEntry).toContain('import("@sentry/junior")'); expect(serverEntry).toContain('import("./plugins.ts")'); - expect(serverEntry).toContain('import("./workspaces.ts")'); expect(serverEntry).toContain("createApp({"); expect(serverEntry).toContain("plugins,"); - expect(serverEntry).toContain("workspaces,"); const instrumentFile = fs.readFileSync( path.join(target, "instrument.mjs"), @@ -161,15 +158,6 @@ describe("init cli", () => { expect(pluginsFile).toContain("memoryPlugin()"); expect(pluginsFile).toContain('"@sentry/junior-maintenance"'); - const workspacesFile = fs.readFileSync( - path.join(target, "workspaces.ts"), - "utf8", - ); - expect(workspacesFile).toContain( - 'import { defineJuniorWorkspaces } from "@sentry/junior";', - ); - expect(workspacesFile).toContain("defineJuniorWorkspaces([])"); - const pkg = readJsonFile<{ dependencies: Record; devDependencies: Record; diff --git a/packages/junior/tests/unit/tools/workspaces.test.ts b/packages/junior/tests/unit/tools/workspaces.test.ts index addb115465..ea6e5f5b36 100644 --- a/packages/junior/tests/unit/tools/workspaces.test.ts +++ b/packages/junior/tests/unit/tools/workspaces.test.ts @@ -1,7 +1,20 @@ import { describe, expect, it, vi } from "vitest"; -import { defineJuniorWorkspaces } from "@/chat/workspaces/config"; 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", @@ -17,19 +30,13 @@ const workspace = { }; describe("workspace tools", () => { - it("validates install-wide Workspace recipes", () => { - expect(defineJuniorWorkspaces([workspace])).toEqual([workspace]); - expect(() => defineJuniorWorkspaces([workspace, { ...workspace }])).toThrow( - "Duplicate Workspace id: workspace-1", - ); - }); - it("lists and switches registered workspaces", async () => { + listWorkspacesMock.mockResolvedValue([workspace]); + getWorkspaceByNameMock.mockResolvedValue(workspace); const switchWorkspace = vi.fn(); const tools = createWorkspaceTools({ workspaces: { activeWorkspaceId: () => undefined, - recipes: [workspace], switch: switchWorkspace, }, } as never); From 3a64070068153690abc426c353bd519b07497daa Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:51:53 +0000 Subject: [PATCH 26/34] fix(sandbox): Preserve null clears and stabilize workspace paths Keep durable sandbox clear signals through run adapters so a failed inline persist still clears on the final write. Reject reserved checkout paths case-insensitively, and sort workspace recipe repos with code-point order for locale-stable profile hashes. --- packages/junior-github/src/plugin.ts | 4 +- packages/junior-github/src/sandbox-paths.ts | 5 ++ .../src/tools/clone-repository.ts | 4 +- .../junior-github/tests/github-plugin.test.ts | 24 ++++++ .../junior/src/chat/agent-invocations/work.ts | 9 ++- packages/junior/src/chat/agent/index.ts | 9 ++- packages/junior/src/chat/agent/sandbox.ts | 6 +- packages/junior/src/chat/agent/tools.ts | 2 +- packages/junior/src/chat/api-turns/work.ts | 7 +- packages/junior/src/chat/local/runner.ts | 11 ++- .../src/chat/sandbox/snapshot/profile.ts | 13 ++- .../junior/src/chat/services/turn-result.ts | 6 +- .../component/runtime/thread-state.test.ts | 29 +++++++ .../unit/sandbox/snapshot/profile.test.ts | 81 +++++++++++++++++++ 14 files changed, 187 insertions(+), 23 deletions(-) diff --git a/packages/junior-github/src/plugin.ts b/packages/junior-github/src/plugin.ts index ce460a28fe..aca12f8cb1 100644 --- a/packages/junior-github/src/plugin.ts +++ b/packages/junior-github/src/plugin.ts @@ -53,7 +53,7 @@ import { prepareCommitMsgHook, } from "./git-config.js"; import { linkifyGitHubReferences } from "./reply-markdown.js"; -import { RESERVED_SANDBOX_DIRECTORIES } from "./sandbox-paths.js"; +import { isReservedSandboxDirectory } from "./sandbox-paths.js"; import { CREATE_TOOL_ROUTING_GUIDANCE, GITHUB_APP_ID_ENV, @@ -847,7 +847,7 @@ export function githubPlugin( !/^[A-Za-z0-9._-]+$/.test(entry.path) || entry.path === "." || entry.path === ".." || - RESERVED_SANDBOX_DIRECTORIES.has(entry.path) + isReservedSandboxDirectory(entry.path) ) { throw new Error(`Invalid workspace checkout path: ${entry.path}`); } diff --git a/packages/junior-github/src/sandbox-paths.ts b/packages/junior-github/src/sandbox-paths.ts index f950b11a9e..1ae8779b6e 100644 --- a/packages/junior-github/src/sandbox-paths.ts +++ b/packages/junior-github/src/sandbox-paths.ts @@ -4,3 +4,8 @@ export const RESERVED_SANDBOX_DIRECTORIES = new Set([ "data", "skills", ]); + +/** True when a checkout path collides with a reserved sandbox root (case-insensitive). */ +export function isReservedSandboxDirectory(path: string): boolean { + return RESERVED_SANDBOX_DIRECTORIES.has(path.toLowerCase()); +} diff --git a/packages/junior-github/src/tools/clone-repository.ts b/packages/junior-github/src/tools/clone-repository.ts index e96d3df910..9823a23dfa 100644 --- a/packages/junior-github/src/tools/clone-repository.ts +++ b/packages/junior-github/src/tools/clone-repository.ts @@ -6,7 +6,7 @@ import { type ToolRegistrationHookContext, } from "@sentry/junior-plugin-api"; import { z } from "zod"; -import { RESERVED_SANDBOX_DIRECTORIES } from "../sandbox-paths.js"; +import { isReservedSandboxDirectory } from "../sandbox-paths.js"; const inputSchema = z .object({ @@ -43,7 +43,7 @@ function parseRepo(value: string): { name: string; owner: string } { } function defaultDirectory(repoName: string): string { - return RESERVED_SANDBOX_DIRECTORIES.has(repoName) + return isReservedSandboxDirectory(repoName) ? `${repoName}-repo` : repoName; } diff --git a/packages/junior-github/tests/github-plugin.test.ts b/packages/junior-github/tests/github-plugin.test.ts index 78a8124120..24b1962e47 100644 --- a/packages/junior-github/tests/github-plugin.test.ts +++ b/packages/junior-github/tests/github-plugin.test.ts @@ -2881,6 +2881,30 @@ Conversation: \`local:test:old-conversation\` ); }); + it("rejects reserved workspace checkout paths case-insensitively", 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/src/chat/agent-invocations/work.ts b/packages/junior/src/chat/agent-invocations/work.ts index b19512b66a..c9f66482f9 100644 --- a/packages/junior/src/chat/agent-invocations/work.ts +++ b/packages/junior/src/chat/agent-invocations/work.ts @@ -290,7 +290,7 @@ export function createAgentInvocationWorker(options: { const lifecycle = new ConversationTurnLifecycleService( getConversationEventStore(), ); - let sandboxRef: SandboxRef | undefined; + let sandboxRef: SandboxRef | null | undefined; let history: PiMessage[]; try { if (invocation.childConversationId !== context.conversationId) { @@ -376,12 +376,14 @@ export function createAgentInvocationWorker(options: { disabledFeatures: ["handoff", "interactive-auth", "subagents"], reasoning: invocation.reasoningLevel, state: { - sandboxRef, + // Agent run state only tracks a live/absent ref, not an explicit clear. + sandboxRef: sandboxRef ?? undefined, }, durability: { onInputCommitted: acknowledge, shouldYield: context.shouldYield, onSandboxRefChanged: async (nextSandboxRef) => { + // Keep null so a failed inline persist still clears on final write. sandboxRef = nextSandboxRef; await persistThreadStateById(invocation.childConversationId, { sandboxRef, @@ -442,7 +444,8 @@ export function createAgentInvocationWorker(options: { const result = outcome.result; const failed = result.diagnostics.outcome !== "success"; await persistThreadStateById(invocation.childConversationId, { - sandboxRef: result.sandboxRef ?? sandboxRef, + sandboxRef: + result.sandboxRef !== undefined ? result.sandboxRef : sandboxRef, }); if (result.piMessages?.length) { await saveTurnCheckpoint({ diff --git a/packages/junior/src/chat/agent/index.ts b/packages/junior/src/chat/agent/index.ts index 201ca98ab0..4dc017968b 100644 --- a/packages/junior/src/chat/agent/index.ts +++ b/packages/junior/src/chat/agent/index.ts @@ -33,6 +33,7 @@ import { } from "@/chat/logging"; import { getConfigDefaults } from "@/chat/configuration/defaults"; import { SkillSandbox } from "@/chat/sandbox/skill-sandbox"; +import type { SandboxRef } from "@/chat/sandbox/ref"; import { findSkillByName, parseSkillInvocation, @@ -358,7 +359,7 @@ async function executeAgentRunInPrivacyContext( const turnTimeoutBudgetMs = Math.max(0, turnDeadlineAtMs - replyStartedAtMs); let resume: ResumeState | undefined; - let lastKnownSandboxRef = state.sandboxRef; + let lastKnownSandboxRef: SandboxRef | null | undefined = state.sandboxRef; let mcpToolManager: McpToolManager | undefined; let closeTools: (() => Promise) | undefined; let connectedMcpProviders = new Set(); @@ -1652,7 +1653,11 @@ async function executeAgentRunInPrivacyContext( newMessages, userInput, toolCalls, - sandboxRef: wiring.getSandboxRef(), + // Prefer the durability hint so an explicit clear (null) survives result. + sandboxRef: + lastKnownSandboxRef !== undefined + ? lastKnownSandboxRef + : wiring.getSandboxRef(), piMessages: [...agent.state.messages], durationMs: Date.now() - replyStartedAtMs, generatedFileCount: generatedFiles.length, diff --git a/packages/junior/src/chat/agent/sandbox.ts b/packages/junior/src/chat/agent/sandbox.ts index 71f67e8d3e..09d65f04ca 100644 --- a/packages/junior/src/chat/agent/sandbox.ts +++ b/packages/junior/src/chat/agent/sandbox.ts @@ -45,8 +45,9 @@ export interface AgentSandboxOptions { workspace: SandboxWorkspace, recipe: Workspace, ): Promise; - onSandboxRefChanged(sandboxRef: SandboxRef): void; - persistSandboxRef?(sandboxRef: SandboxRef): void | Promise; + /** In-memory run hint. null means cleared; undefined means unknown/unchanged. */ + onSandboxRefChanged(sandboxRef: SandboxRef | null | undefined): void; + persistSandboxRef?(sandboxRef: SandboxRef | null): void | Promise; } export interface AgentSandbox { @@ -156,6 +157,7 @@ export function createAgentSandbox(options: AgentSandboxOptions): AgentSandbox { prepare: options.prepareSandbox, prepareWorkspace: options.prepareWorkspace, onSandboxRefChanged: async (sandboxRef) => { + // Keep null as a clear signal for the final post-run persist fallback. options.onSandboxRefChanged(sandboxRef); await options.persistSandboxRef?.(sandboxRef); }, diff --git a/packages/junior/src/chat/agent/tools.ts b/packages/junior/src/chat/agent/tools.ts index 6972092c11..d34fcf5941 100644 --- a/packages/junior/src/chat/agent/tools.ts +++ b/packages/junior/src/chat/agent/tools.ts @@ -95,7 +95,7 @@ interface ToolWiringArgs { invokedSkill: SkillMetadata | null; onEvent?: (event: AgentEvent) => void | Promise; onFatalToolError(error: Error): void; - onSandboxRefChanged: (sandboxRef: SandboxRef | undefined) => void; + onSandboxRefChanged: (sandboxRef: SandboxRef | null | undefined) => void; preAgentPromptMessages: () => PiMessage[]; recordConnectedMcpProvider: (provider: string) => Promise; requestHandoff?: ToolRuntimeContext["handoff"]; diff --git a/packages/junior/src/chat/api-turns/work.ts b/packages/junior/src/chat/api-turns/work.ts index c616963b7c..5b2828ed46 100644 --- a/packages/junior/src/chat/api-turns/work.ts +++ b/packages/junior/src/chat/api-turns/work.ts @@ -621,7 +621,7 @@ export function createApiTurnWorker(options: { conversation, conversationId: context.conversationId, }); - let sandboxRef: SandboxRef | undefined = + let sandboxRef: SandboxRef | null | undefined = getPersistedSandboxState(persisted); const initialSandboxRef = sandboxRef; @@ -787,7 +787,7 @@ export function createApiTurnWorker(options: { }), state: { pendingAuth: conversation.processing.pendingAuth, - sandboxRef, + sandboxRef: sandboxRef ?? undefined, }, delivery: deliverAssistantMessage, durability: { @@ -849,7 +849,8 @@ export function createApiTurnWorker(options: { }); await persistThreadStateById(context.conversationId, { conversation: completedState.conversation, - sandboxRef: reply.sandboxRef ?? sandboxRef, + sandboxRef: + reply.sandboxRef !== undefined ? reply.sandboxRef : sandboxRef, }); if (reply.piMessages?.length) { // Prefer the live checkpoint slice after yield/resume; first diff --git a/packages/junior/src/chat/local/runner.ts b/packages/junior/src/chat/local/runner.ts index 5c9ca139a8..396bbc1df2 100644 --- a/packages/junior/src/chat/local/runner.ts +++ b/packages/junior/src/chat/local/runner.ts @@ -62,6 +62,7 @@ import { type OAuthAuthorization, } from "@/chat/oauth-authorization"; import type { SandboxEgressSignalTransport } from "@/chat/sandbox/egress/signals"; +import type { SandboxRef } from "@/chat/sandbox/ref"; const SENTRY_EVENT_ID_PATTERN = /^[a-f0-9]{32}$/i; @@ -215,7 +216,8 @@ async function runLocalAgentTurnInContext( conversation, conversationId: input.conversationId, }); - let sandboxRef = getPersistedSandboxState(persisted); + let sandboxRef: SandboxRef | null | undefined = + getPersistedSandboxState(persisted); const initialSandboxRef = sandboxRef; const turnId = localTurnId(); @@ -350,7 +352,8 @@ async function runLocalAgentTurnInContext( }, state: { pendingAuth: conversation.processing.pendingAuth, - sandboxRef, + // Agent run state only tracks a live/absent ref, not an explicit clear. + sandboxRef: sandboxRef ?? undefined, }, onEvent: async (event) => { if (event.type === "status") { @@ -371,6 +374,7 @@ async function runLocalAgentTurnInContext( delivery: deliverAssistantMessage, durability: { onSandboxRefChanged: async (nextSandboxRef) => { + // Keep null so a failed inline persist still clears on final write. sandboxRef = nextSandboxRef; await persistThreadStateById(input.conversationId, { conversation, @@ -504,7 +508,8 @@ async function runLocalAgentTurnInContext( try { await persistThreadStateById(input.conversationId, { conversation: completedState.conversation, - sandboxRef: reply.sandboxRef ?? sandboxRef, + sandboxRef: + reply.sandboxRef !== undefined ? reply.sandboxRef : sandboxRef, }); if (reply.piMessages?.length) { // Destination acceptance is the completion boundary: this first commits diff --git a/packages/junior/src/chat/sandbox/snapshot/profile.ts b/packages/junior/src/chat/sandbox/snapshot/profile.ts index 14111d4939..2b931156a0 100644 --- a/packages/junior/src/chat/sandbox/snapshot/profile.ts +++ b/packages/junior/src/chat/sandbox/snapshot/profile.ts @@ -78,6 +78,13 @@ function floatingMaxAgeMs(): number { : DEFAULT_FLOATING_MAX_AGE_MS; } +/** Locale-independent string order for stable profile hashes. */ +function compareCodePoints(left: string, right: string): number { + if (left < right) return -1; + if (left > right) return 1; + return 0; +} + 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. @@ -88,11 +95,11 @@ function workspaceRecipe(workspace: Workspace) { checkoutPath, })) .sort((left, right) => { - const provider = left.provider.localeCompare(right.provider); + const provider = compareCodePoints(left.provider, right.provider); if (provider !== 0) return provider; - const repo = left.repo.localeCompare(right.repo); + const repo = compareCodePoints(left.repo, right.repo); if (repo !== 0) return repo; - return left.checkoutPath.localeCompare(right.checkoutPath); + return compareCodePoints(left.checkoutPath, right.checkoutPath); }); return { id: workspace.id, diff --git a/packages/junior/src/chat/services/turn-result.ts b/packages/junior/src/chat/services/turn-result.ts index d753f43494..b1a92816a6 100644 --- a/packages/junior/src/chat/services/turn-result.ts +++ b/packages/junior/src/chat/services/turn-result.ts @@ -38,7 +38,8 @@ export interface AgentTurnDiagnostics { export interface AgentRunResult { /** Sanitized terminal text for diagnostics and failure fallback, not success delivery. */ text: string; - sandboxRef?: SandboxRef; + /** Latest sandbox ref; null means the durable reference was cleared. */ + sandboxRef?: SandboxRef | null; piMessages?: PiMessage[]; diagnostics: AgentTurnDiagnostics; } @@ -47,7 +48,8 @@ export interface TurnResultInput { newMessages: unknown[]; userInput: string; toolCalls: string[]; - sandboxRef?: SandboxRef; + /** Latest sandbox ref; null means the durable reference was cleared. */ + sandboxRef?: SandboxRef | null; piMessages?: PiMessage[]; durationMs?: number; generatedFileCount: number; diff --git a/packages/junior/tests/component/runtime/thread-state.test.ts b/packages/junior/tests/component/runtime/thread-state.test.ts index 4139b56838..7c8262009b 100644 --- a/packages/junior/tests/component/runtime/thread-state.test.ts +++ b/packages/junior/tests/component/runtime/thread-state.test.ts @@ -58,6 +58,35 @@ describe("thread sandbox state", () => { expect(getPersistedSandboxState(state)).toBeUndefined(); }); + it("final fallback still clears when null is preserved after failed inline write", async () => { + // Mirrors durability adapters: keep null in the local variable so a later + // persist can clear even if an earlier onSandboxRefChanged write failed. + const conversationId = "local:test:thread-sandbox-null-fallback"; + await persistThreadStateById(conversationId, { + sandboxRef: { id: "sandbox-stale", profileHash: "profile-stale" }, + }); + + let sandboxRef: { id: string; profileHash?: string } | null | undefined = { + id: "sandbox-stale", + profileHash: "profile-stale", + }; + const resultSandboxRef: { id: string } | null | undefined = null; + + // Inline clear signal collapses only when coerced with ?? undefined. + sandboxRef = null; + await persistThreadStateById(conversationId, { + sandboxRef: + resultSandboxRef !== undefined ? resultSandboxRef : sandboxRef, + }); + + const state = await getPersistedThreadState(conversationId); + expect(getPersistedSandboxState(state)).toBeUndefined(); + expect(state).toMatchObject({ + app_sandbox_id: "", + app_sandbox_dependency_profile_hash: "", + }); + }); + it("writes thread scratch with Junior's 7-day TTL", async () => { const stateAdapter = getStateAdapter(); const set = vi.spyOn(stateAdapter, "set"); diff --git a/packages/junior/tests/unit/sandbox/snapshot/profile.test.ts b/packages/junior/tests/unit/sandbox/snapshot/profile.test.ts index 32d49487ee..917e4dbba9 100644 --- a/packages/junior/tests/unit/sandbox/snapshot/profile.test.ts +++ b/packages/junior/tests/unit/sandbox/snapshot/profile.test.ts @@ -140,6 +140,87 @@ describe("snapshot dependency profile", () => { expect(first?.hash).toBe(reordered?.hash); }); + it("keeps workspace profile hashes stable without localeCompare", () => { + const updatedAt = new Date("2026-03-10T00:00:00.000Z"); + // Code-point order differs from some locales for mixed case / symbols. + const reposA = [ + { + provider: "github", + repo: "getsentry/Zulu", + checkoutPath: "Zulu", + isPrimary: true, + }, + { + provider: "github", + repo: "getsentry/alpha", + checkoutPath: "alpha", + isPrimary: false, + }, + ]; + const reposB = [...reposA].reverse(); + const first = create("node22", { + id: "workspace-1", + name: "sentry", + setupScript: "pnpm install", + updatedAt, + repos: reposA, + }); + const second = create("node22", { + id: "workspace-1", + name: "sentry", + setupScript: "pnpm install", + updatedAt, + repos: reposB, + }); + 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("installs dependencies in the complete Workspace profile", () => { dependenciesMock.mockReturnValue([ { type: "npm", package: "example", version: "1.2.3" }, From 930f6a15ae825787545955e7d6680fa676be6cb1 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Thu, 13 Aug 2026 12:49:52 -0700 Subject: [PATCH 27/34] fix(workspaces): align sandbox preparation lifecycle --- packages/junior/src/chat/agent/sandbox.ts | 1 + packages/junior/src/chat/agent/tools.ts | 4 +- .../junior/src/chat/plugins/agent-hooks.ts | 61 +++++++++-- packages/junior/src/chat/sandbox/README.md | 4 +- packages/junior/src/chat/sandbox/sandbox.ts | 1 + packages/junior/src/chat/sandbox/session.ts | 13 +-- .../component/misc/sandbox-executor.test.ts | 73 +++++++++++-- .../tests/unit/plugins/agent-hooks.test.ts | 100 +++++++++++++++++- 8 files changed, 225 insertions(+), 32 deletions(-) diff --git a/packages/junior/src/chat/agent/sandbox.ts b/packages/junior/src/chat/agent/sandbox.ts index 09d65f04ca..be9ce19355 100644 --- a/packages/junior/src/chat/agent/sandbox.ts +++ b/packages/junior/src/chat/agent/sandbox.ts @@ -44,6 +44,7 @@ export interface AgentSandboxOptions { prepareWorkspace?( workspace: SandboxWorkspace, recipe: Workspace, + signal?: AbortSignal, ): Promise; /** In-memory run hint. null means cleared; undefined means unknown/unchanged. */ onSandboxRefChanged(sandboxRef: SandboxRef | null | undefined): void; diff --git a/packages/junior/src/chat/agent/tools.ts b/packages/junior/src/chat/agent/tools.ts index d34fcf5941..586a962744 100644 --- a/packages/junior/src/chat/agent/tools.ts +++ b/packages/junior/src/chat/agent/tools.ts @@ -239,8 +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), + prepareWorkspace: async (sandbox, recipe, signal) => + await pluginHooks.prepareWorkspace?.(sandbox, recipe.repos, signal), onSandboxRefChanged: args.onSandboxRefChanged, persistSandboxRef: args.durability.onSandboxRefChanged, }); diff --git a/packages/junior/src/chat/plugins/agent-hooks.ts b/packages/junior/src/chat/plugins/agent-hooks.ts index 6b3f9f2e3b..96fdb6a3fc 100644 --- a/packages/junior/src/chat/plugins/agent-hooks.ts +++ b/packages/junior/src/chat/plugins/agent-hooks.ts @@ -35,6 +35,7 @@ import { createPluginEmbedder, createPluginModel } from "@/chat/plugins/model"; import type { PluginPromptContributionContext } from "@/chat/plugins/prompt"; import { createPluginState } from "@/chat/plugins/state"; import { SANDBOX_WORKSPACE_ROOT } from "@/chat/sandbox/paths"; +import { runNonInteractiveCommand } from "@/chat/sandbox/noninteractive-command"; import type { AnyToolDefinition } from "@/chat/tools/definition"; import { getDashboardConversationLink } from "@/chat/slack/dashboard-link"; import { canRouteResourceEvents } from "@/chat/resource-events/workspace"; @@ -91,7 +92,15 @@ 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; + prepareWorkspace?( + workspace: SandboxWorkspace, + repos: Array<{ + provider: string; + repo: string; + checkoutPath: string; + }>, + signal?: AbortSignal, + ): Promise; } let registeredPlugins: PluginRegistration[] = []; @@ -628,7 +637,9 @@ export function getPluginTools( switch (context.source.platform) { case "slack": if (context.destination.platform !== "slack") { - throw new TypeError("Slack plugin context requires Slack destination"); + throw new TypeError( + "Slack plugin context requires Slack destination", + ); } pluginContext = { ...common, @@ -641,7 +652,9 @@ export function getPluginTools( break; case "local": if (context.destination.platform !== "local") { - throw new TypeError("Local plugin context requires local destination"); + throw new TypeError( + "Local plugin context requires local destination", + ); } pluginContext = { ...common, @@ -1297,7 +1310,19 @@ function normalizeEnv(value: unknown): Record { return env; } -function createSandboxCapability(workspace: SandboxWorkspace): PluginSandbox { +function preparationSignal( + inputSignal?: AbortSignal, + ownerSignal?: AbortSignal, +): AbortSignal | undefined { + if (!inputSignal) return ownerSignal; + if (!ownerSignal) return inputSignal; + return AbortSignal.any([inputSignal, ownerSignal]); +} + +function createSandboxCapability( + workspace: SandboxWorkspace, + ownerSignal?: AbortSignal, +): PluginSandbox { return { root: SANDBOX_WORKSPACE_ROOT, juniorRoot: `${SANDBOX_WORKSPACE_ROOT}/.junior`, @@ -1305,7 +1330,11 @@ function createSandboxCapability(workspace: SandboxWorkspace): PluginSandbox { return (await workspace.readFileToBuffer({ path: filePath })) ?? null; }, async run(input: SandboxCommandInput) { - const result = await workspace.runCommand(input); + const signal = preparationSignal(input.signal, ownerSignal); + const result = await runNonInteractiveCommand(workspace, { + ...input, + ...(signal ? { signal } : {}), + }); return { exitCode: result.exitCode, stdout: result.stdout, @@ -1377,8 +1406,26 @@ export function createPluginHookRunner( } } }, - async prepareWorkspace(sandbox, repos) { - const sandboxCapability = createSandboxCapability(sandbox); + async prepareWorkspace(sandbox, repos, signal) { + const preparers = new Set( + loaded + .filter((plugin) => plugin.hooks?.workspacePrepare) + .map((plugin) => plugin.manifest.name), + ); + const unhandledProviders = [ + ...new Set( + repos + .map((repo) => repo.provider) + .filter((provider) => !preparers.has(provider)), + ), + ].sort(); + if (unhandledProviders.length > 0) { + throw new Error( + `Workspace repository providers have no preparation hook: ${unhandledProviders.join(", ")}`, + ); + } + + const sandboxCapability = createSandboxCapability(sandbox, signal); for (const plugin of loaded) { const hook = plugin.hooks?.workspacePrepare; if (!hook) continue; diff --git a/packages/junior/src/chat/sandbox/README.md b/packages/junior/src/chat/sandbox/README.md index 961ad58560..229cf97d68 100644 --- a/packages/junior/src/chat/sandbox/README.md +++ b/packages/junior/src/chat/sandbox/README.md @@ -11,8 +11,8 @@ traffic through verified host egress. - 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 a configured Workspace is removed, restore keeps the durable - Workspace id and profile hash. + session ids. A removed Workspace recipe invalidates its stored profile the + same way any other removed profile input does. - 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 diff --git a/packages/junior/src/chat/sandbox/sandbox.ts b/packages/junior/src/chat/sandbox/sandbox.ts index e3e2745d15..90d3d78dfc 100644 --- a/packages/junior/src/chat/sandbox/sandbox.ts +++ b/packages/junior/src/chat/sandbox/sandbox.ts @@ -104,6 +104,7 @@ export interface SandboxOptions { prepareWorkspace?: ( workspace: SandboxWorkspace, recipe: Workspace, + signal?: AbortSignal, ) => Promise; onSandboxRefChanged?: (sandboxRef: SandboxRef) => void | Promise; } diff --git a/packages/junior/src/chat/sandbox/session.ts b/packages/junior/src/chat/sandbox/session.ts index 6843b72024..83661afe2d 100644 --- a/packages/junior/src/chat/sandbox/session.ts +++ b/packages/junior/src/chat/sandbox/session.ts @@ -134,6 +134,7 @@ interface SandboxRuntimeOptions { onWorkspacePrepare?: ( sandbox: SandboxSession, workspace: Workspace, + signal?: AbortSignal, ) => Promise; onSandboxRefChanged?: (sandboxRef: SandboxRef) => void | Promise; } @@ -184,11 +185,7 @@ export function createSandboxRuntime( const timeoutMs = options.timeoutMs ?? 1000 * 60 * 30; const traceContext = options.traceContext ?? {}; 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); + let dependencyProfileHash = profileHash(SANDBOX_RUNTIME, activeWorkspace); const resolveCommandEnv = options.commandEnv ?? (async () => ({}) as Record); @@ -478,7 +475,7 @@ export function createSandboxRuntime( ): Promise => { signal?.throwIfAborted(); await applyNetworkPolicy(sandbox); - await options.onWorkspacePrepare?.(sandbox, workspace); + await options.onWorkspacePrepare?.(sandbox, workspace, signal); // The provider hook is trusted and runs through credential egress. Remove // that route before the app-owned setup script runs and before capture. if (options.createNetworkPolicy) { @@ -580,12 +577,10 @@ export function createSandboxRuntime( }; const discardHintIfProfileChanged = (): void => { - // Missing recipe cannot recompute workspace profile; keep the durable hint. if ( activeSandbox || !sandboxRef || - dependencyProfileHash === sandboxRef.profileHash || - (!activeWorkspace && sandboxRef.workspaceId) + dependencyProfileHash === sandboxRef.profileHash ) { return; } diff --git a/packages/junior/tests/component/misc/sandbox-executor.test.ts b/packages/junior/tests/component/misc/sandbox-executor.test.ts index 96f9cc9366..cdd4c38903 100644 --- a/packages/junior/tests/component/misc/sandbox-executor.test.ts +++ b/packages/junior/tests/component/misc/sandbox-executor.test.ts @@ -1086,11 +1086,10 @@ describe("createTestSandbox", () => { }); }); - it("keeps durable workspaceId when the recipe row is missing", async () => { - // Base-only hash would previously discard the workspace hint and strip workspaceId. + it("discards the Workspace hint when its recipe row is missing", async () => { hashMock.mockReturnValue("profile-base"); - const restored = makeSandbox("sbx_missing_recipe"); - sandboxGetMock.mockResolvedValueOnce(restored); + const fresh = makeSandbox("sbx_after_recipe_removed"); + sandboxCreateMock.mockResolvedValueOnce(fresh); const refs: Array<{ id: string; workspaceId?: string; @@ -1111,15 +1110,18 @@ describe("createTestSandbox", () => { await runtime.acquire(); - expect(sandboxGetMock).toHaveBeenCalled(); - expect(sandboxCreateMock).not.toHaveBeenCalled(); + expect(sandboxGetMock).not.toHaveBeenCalled(); + expect(sandboxCreateMock).toHaveBeenCalledTimes(1); expect(runtime.sandboxRef()).toEqual({ - id: "sbx_missing_recipe", - profileHash: "profile-workspace", - workspaceId: "workspace-deleted", + id: "sbx_after_recipe_removed", + profileHash: "profile-base", }); - // Same durable identity is not rewritten. - expect(refs).toEqual([]); + expect(refs).toEqual([ + { + id: "sbx_after_recipe_removed", + profileHash: "profile-base", + }, + ]); }); it("limits credential egress to Workspace provider preparation", async () => { @@ -1237,6 +1239,55 @@ describe("createTestSandbox", () => { releaseSetup?.(); }); + it("forwards abort signal into Workspace provider preparation", async () => { + const buildSandbox = makeSandbox("sbx_workspace_provider_signal"); + const controller = new AbortController(); + let providerSignal: AbortSignal | undefined; + let markProviderStarted: (() => void) | undefined; + const providerStarted = new Promise((resolve) => { + markProviderStarted = resolve; + }); + resolveMock.mockImplementationOnce(async (params: any) => { + await params.prepareWorkspace?.(buildSandbox); + return { + snapshotId: "snap_workspace_provider", + profileHash: "profile-workspace-provider", + dependencyCount: 0, + cacheHit: false, + resolveOutcome: "built", + }; + }); + hashMock.mockReturnValue("profile-workspace-provider"); + const runtime = createSandboxRuntime({ + workspace: { + id: "workspace-provider", + name: "provider", + setupScript: "", + repos: [], + }, + skills: [], + referenceFiles: [], + onWorkspacePrepare: async (_sandbox, _workspace, signal) => { + providerSignal = signal; + markProviderStarted?.(); + await new Promise((resolve) => { + signal?.addEventListener("abort", () => resolve(), { once: true }); + }); + signal?.throwIfAborted(); + }, + }); + + const acquirePromise = runtime.acquire(controller.signal); + await providerStarted; + expect(providerSignal).toBeInstanceOf(AbortSignal); + expect(providerSignal?.aborted).toBe(false); + + controller.abort("cancel provider preparation"); + + await expect(acquirePromise).rejects.toBe("cancel provider preparation"); + expect(providerSignal?.aborted).toBe(true); + }); + it("keeps the durable workspace reference when its switch fails", async () => { const initialSandbox = makeSandbox("sbx_workspace_initial"); const failedSandbox = makeSandbox("sbx_workspace_failed"); diff --git a/packages/junior/tests/unit/plugins/agent-hooks.test.ts b/packages/junior/tests/unit/plugins/agent-hooks.test.ts index 1a6146dadc..5c9ec220c2 100644 --- a/packages/junior/tests/unit/plugins/agent-hooks.test.ts +++ b/packages/junior/tests/unit/plugins/agent-hooks.test.ts @@ -44,7 +44,10 @@ import { } from "@/chat/plugins/agent-hooks"; import { createTools } from "@/chat/tools"; import type { ToolRuntimeContext } from "@/chat/tools/types"; -import type { SandboxSession } from "@/chat/sandbox/workspace"; +import type { + SandboxCommandInput, + SandboxSession, +} from "@/chat/sandbox/workspace"; const demoToolResultSchema = pluginToolOutputSchema.extend({ message: z.string(), @@ -1686,6 +1689,101 @@ describe("agent plugin hooks", () => { } }); + it("runs Workspace preparation non-interactively with owner cancellation", async () => { + const runCommand = vi.fn(async (_input: SandboxCommandInput) => ({ + exitCode: 0, + stdout: "", + stderr: "", + })); + const previous = setPlugins([ + defineJuniorPlugin({ + manifest: { + name: "agent-demo", + displayName: "Agent Demo", + description: "Agent demo", + }, + hooks: { + async workspacePrepare(ctx) { + await ctx.sandbox.run({ + cmd: "git", + args: ["clone", "https://example.com/demo.git", "demo"], + cwd: ctx.sandbox.root, + }); + }, + }, + }), + ]); + try { + const controller = new AbortController(); + const sandbox = { + ...fakeSandbox([]), + runCommand, + }; + + await createPluginHookRunner().prepareWorkspace?.( + sandbox, + [ + { + provider: "agent-demo", + repo: "example/demo", + checkoutPath: "demo", + }, + ], + controller.signal, + ); + + expect(runCommand).toHaveBeenCalledTimes(1); + const command = runCommand.mock.calls[0]?.[0]; + expect(command).toMatchObject({ + cmd: "bash", + cwd: "/vercel/sandbox", + signal: controller.signal, + }); + expect(command?.args?.[0]).toBe("-c"); + expect(command?.args?.[1]).toContain("GIT_TERMINAL_PROMPT"); + expect(command?.args?.[1]).toContain( + "'git' 'clone' 'https://example.com/demo.git' 'demo'", + ); + } finally { + setPlugins(previous); + } + }); + + it("rejects unhandled Workspace repository providers before preparation", async () => { + const workspacePrepare = vi.fn(async () => {}); + const previous = setPlugins([ + defineJuniorPlugin({ + manifest: { + name: "agent-demo", + displayName: "Agent Demo", + description: "Agent demo", + }, + hooks: { workspacePrepare }, + }), + ]); + try { + await expect( + createPluginHookRunner().prepareWorkspace?.(fakeSandbox([]), [ + { + provider: "agent-demo", + repo: "example/demo", + checkoutPath: "demo", + }, + { + provider: "missing-provider", + repo: "example/missing", + checkoutPath: "missing", + }, + ]), + ).rejects.toThrow( + "Workspace repository providers have no preparation hook: missing-provider", + ); + expect(workspacePrepare).not.toHaveBeenCalled(); + } finally { + setPlugins(previous); + } + }); + it("materializes beforeToolExecute actors from the live actors getter per call", async () => { const seenActorSets: unknown[][] = []; const previous = setPlugins([ From 08d07346e99f5d6134cc5f73ed960ecd8664569e Mon Sep 17 00:00:00 2001 From: David Cramer Date: Thu, 13 Aug 2026 13:00:55 -0700 Subject: [PATCH 28/34] test(workspaces): use streamed agent harness --- .../tests/integration/workspace-tools.test.ts | 111 ++++++++++++++++++ .../tests/unit/tools/workspaces.test.ts | 57 --------- 2 files changed, 111 insertions(+), 57 deletions(-) create mode 100644 packages/junior/tests/integration/workspace-tools.test.ts delete mode 100644 packages/junior/tests/unit/tools/workspaces.test.ts diff --git a/packages/junior/tests/integration/workspace-tools.test.ts b/packages/junior/tests/integration/workspace-tools.test.ts new file mode 100644 index 0000000000..0860eced65 --- /dev/null +++ b/packages/junior/tests/integration/workspace-tools.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; +import { getDb } from "@/chat/db"; +import { normalizeLocalConversationId } from "@/chat/local/conversation"; +import { + runLocalAgentTurn, + type LocalAgentReply, + type LocalToolResult, +} from "@/chat/local/runner"; +import { juniorWorkspaceRepos, juniorWorkspaces } from "@/db/schema"; +import { createModelAgentRunner } from "../fixtures/agent-runner"; +import { createModelStream } from "../fixtures/model-stream"; + +describe("Workspace tools", () => { + it("runs Workspace tools through the real agent tool path", async () => { + const now = new Date("2026-08-13T12:00:00.000Z"); + const workspace = { + id: "workspace-1", + name: "sentry", + setupScript: "pnpm install", + repos: [ + { + provider: "github", + repo: "getsentry/sentry", + checkoutPath: "sentry", + isPrimary: true, + }, + ], + }; + const db = getDb(); + await db.insert(juniorWorkspaces).values({ + id: workspace.id, + name: workspace.name, + setupScript: workspace.setupScript, + createdAt: now, + updatedAt: now, + }); + await db.insert(juniorWorkspaceRepos).values({ + workspaceId: workspace.id, + ...workspace.repos[0]!, + }); + const conversationId = normalizeLocalConversationId({ + alias: "workspace-tools", + cwd: "/tmp/local-agent-workspace-tools", + }); + expect(conversationId).toBeDefined(); + const delivered: LocalAgentReply[] = []; + const results: LocalToolResult[] = []; + + await runLocalAgentTurn( + { + conversationId: conversationId!, + message: "List the available Workspaces.", + }, + { + agentRunner: createModelAgentRunner( + createModelStream([ + { type: "toolCall", name: "listWorkspaces", arguments: {} }, + { + type: "toolCall", + name: "switchWorkspace", + arguments: { name: "missing" }, + }, + { type: "text", text: "The missing Workspace was not found." }, + ]), + ), + deliverReply: async (reply) => { + delivered.push(reply); + }, + onToolResult: async (result) => { + results.push(result); + }, + }, + ); + + expect(results).toEqual([ + expect.objectContaining({ + ok: true, + toolCallId: expect.any(String), + toolName: "listWorkspaces", + params: {}, + result: { + active_workspace_id: null, + workspaces: [ + { + id: "workspace-1", + name: "sentry", + repos: [ + { + provider: "github", + repo: "getsentry/sentry", + checkout_path: "sentry", + is_primary: true, + }, + ], + }, + ], + }, + }), + expect.objectContaining({ + error: "Workspace not found: missing", + ok: false, + toolCallId: expect.any(String), + toolName: "switchWorkspace", + params: { name: "missing" }, + }), + ]); + expect(delivered).toEqual([ + { text: "The missing Workspace was not found." }, + ]); + }); +}); diff --git a/packages/junior/tests/unit/tools/workspaces.test.ts b/packages/junior/tests/unit/tools/workspaces.test.ts deleted file mode 100644 index ea6e5f5b36..0000000000 --- a/packages/junior/tests/unit/tools/workspaces.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -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", - 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" } }); - }); -}); From 9b7574c03893e07475299cb706d51cb48a7d2d12 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 20:17:32 +0000 Subject: [PATCH 29/34] fix(workspaces): Use fixed repos/{name} checkout paths Drop stored checkout_path from workspace recipes. Clone workspace and ad-hoc GitHub repositories under repos/{name} so setup scripts can rely on a stable layout, and reject short-name collisions. --- packages/junior-github/src/plugin.ts | 39 ++++++++++++-- .../src/tools/clone-repository.ts | 44 ++++++++++++--- .../tests/clone-repository.test.ts | 49 ++++++++++++----- .../junior-github/tests/github-plugin.test.ts | 54 +++++++++++++++++-- .../junior/src/chat/plugins/agent-hooks.ts | 20 +++++-- packages/junior/src/chat/sandbox/README.md | 2 + packages/junior/src/chat/sandbox/sandbox.ts | 3 +- .../src/chat/sandbox/snapshot/profile.ts | 7 +-- .../src/chat/workspaces/checkout-path.ts | 13 +++++ packages/junior/src/chat/workspaces/store.ts | 4 -- packages/junior/src/chat/workspaces/tools.ts | 3 +- packages/junior/src/chat/workspaces/types.ts | 1 - packages/junior/src/db/schema/workspaces.ts | 5 -- .../sandbox/snapshot/resolve.test.ts | 1 - .../tests/integration/workspace-tools.test.ts | 3 +- .../tests/unit/plugins/agent-hooks.test.ts | 34 ++++++++++-- .../unit/sandbox/snapshot/profile.test.ts | 5 -- .../unit/workspaces/checkout-path.test.ts | 18 +++++++ 18 files changed, 244 insertions(+), 61 deletions(-) create mode 100644 packages/junior/src/chat/workspaces/checkout-path.ts create mode 100644 packages/junior/tests/unit/workspaces/checkout-path.test.ts diff --git a/packages/junior-github/src/plugin.ts b/packages/junior-github/src/plugin.ts index aca12f8cb1..22a8fa7a75 100644 --- a/packages/junior-github/src/plugin.ts +++ b/packages/junior-github/src/plugin.ts @@ -843,17 +843,48 @@ export function githubPlugin( if (!owner || !name || rest.length > 0) { throw new Error(`Invalid GitHub repository: ${entry.repo}`); } + const segments = entry.path.split("/"); if ( - !/^[A-Za-z0-9._-]+$/.test(entry.path) || - entry.path === "." || - entry.path === ".." || - isReservedSandboxDirectory(entry.path) + segments.length === 0 || + segments.some( + (part) => + !part || + part === "." || + part === ".." || + !/^[A-Za-z0-9._-]+$/.test(part), + ) || + isReservedSandboxDirectory(segments[0]!) ) { throw new Error(`Invalid workspace checkout path: ${entry.path}`); } return { owner, name, path: entry.path, repo: entry.repo }; }); + const paths = new Set(); + for (const entry of repos) { + const key = entry.path.toLowerCase(); + if (paths.has(key)) { + throw new Error( + `Workspace checkout path collision: ${entry.path}`, + ); + } + paths.add(key); + } for (const { owner, name, path, repo } of repos) { + const parent = path.includes("/") + ? path.slice(0, path.lastIndexOf("/")) + : undefined; + if (parent) { + const mkdir = await ctx.sandbox.run({ + cmd: "mkdir", + args: ["-p", "--", parent], + cwd: ctx.sandbox.root, + }); + if (mkdir.exitCode !== 0) { + throw new Error( + `GitHub workspace checkout parent failed for ${repo}: ${mkdir.stderr.trim() || `exit ${mkdir.exitCode}`}`, + ); + } + } const result = await ctx.sandbox.run({ cmd: "git", args: [ diff --git a/packages/junior-github/src/tools/clone-repository.ts b/packages/junior-github/src/tools/clone-repository.ts index 9823a23dfa..381496e53c 100644 --- a/packages/junior-github/src/tools/clone-repository.ts +++ b/packages/junior-github/src/tools/clone-repository.ts @@ -13,11 +13,17 @@ const inputSchema = z repo: z.string().describe('Repository in "owner/name" format.'), directory: z .string() - .regex(/^[A-Za-z0-9._-]+$/) - .refine((value) => value !== "." && value !== "..", { - message: "Directory must be a single directory name.", - }) - .describe("Optional destination directory under the sandbox root.") + .regex(/^(?:[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*)$/) + .refine( + (value) => + !value.split("/").some((part) => part === "." || part === ".."), + { + message: "Directory must be a relative path without . or .. segments.", + }, + ) + .describe( + "Optional destination directory under the sandbox root. Defaults to repos/{name}.", + ) .optional(), }) .strict(); @@ -43,9 +49,9 @@ function parseRepo(value: string): { name: string; owner: string } { } function defaultDirectory(repoName: string): string { - return isReservedSandboxDirectory(repoName) - ? `${repoName}-repo` - : repoName; + // Keep ad-hoc clones under repos/ so they match Workspace layout and stay + // clear of reserved sandbox roots (skills, data, .junior). + return `repos/${repoName}`; } function commandSignal( @@ -112,7 +118,29 @@ export function createGitHubCloneRepositoryTool( async execute(input, options): Promise { const repo = parseRepo(input.repo); const directory = input.directory ?? defaultDirectory(repo.name); + const rootSegment = directory.split("/")[0] ?? directory; + if (isReservedSandboxDirectory(rootSegment)) { + throw new PluginToolInputError( + `Directory conflicts with a reserved sandbox path: ${directory}`, + ); + } const path = `${ctx.sandbox.root}/${directory}`; + const parentDirectory = directory.includes("/") + ? directory.slice(0, directory.lastIndexOf("/")) + : undefined; + if (parentDirectory) { + const mkdir = await ctx.sandbox.run({ + cmd: "mkdir", + args: ["-p", "--", `${ctx.sandbox.root}/${parentDirectory}`], + cwd: ctx.sandbox.root, + signal: commandSignal(options.signal, 30_000), + }); + if (mkdir.exitCode !== 0) { + throw new PluginToolInputError( + `Failed to create clone parent directory: ${parentDirectory}`, + ); + } + } const exists = await ctx.sandbox.run({ cmd: "bash", args: ["-c", `test -e "$1"`, "bash", path], diff --git a/packages/junior-github/tests/clone-repository.test.ts b/packages/junior-github/tests/clone-repository.test.ts index d23b4d75be..0688bac07c 100644 --- a/packages/junior-github/tests/clone-repository.test.ts +++ b/packages/junior-github/tests/clone-repository.test.ts @@ -27,11 +27,11 @@ describe("cloneRepository", () => { }); expect( tool.describeProposal?.({ - directory: "junior", + directory: "repos/junior", repo: "getsentry/junior", }), ).toBe( - "Shallow-clone getsentry/junior into the local sandbox at junior for inspection (no GitHub mutation).", + "Shallow-clone getsentry/junior into the local sandbox at repos/junior for inspection (no GitHub mutation).", ); }); @@ -39,16 +39,23 @@ describe("cloneRepository", () => { const signal = new AbortController().signal; const run = vi .fn() + .mockResolvedValueOnce({ exitCode: 0, stdout: "", stderr: "" }) .mockResolvedValueOnce({ exitCode: 1, stdout: "", stderr: "" }) .mockResolvedValueOnce({ exitCode: 0, stdout: "", stderr: "" }); const tool = createGitHubCloneRepositoryTool(context(run)); const result = await tool.execute!( - { repo: "getsentry/junior", directory: "junior" }, + { repo: "getsentry/junior", directory: "repos/junior" }, { signal }, ); - expect(run).toHaveBeenNthCalledWith(2, { + expect(run).toHaveBeenNthCalledWith(1, { + cmd: "mkdir", + args: ["-p", "--", "/vercel/sandbox/repos"], + cwd: "/vercel/sandbox", + signal: expect.any(AbortSignal), + }); + expect(run).toHaveBeenNthCalledWith(3, { cmd: "git", args: [ "clone", @@ -56,13 +63,13 @@ describe("cloneRepository", () => { "--depth=1", "--", "https://github.com/getsentry/junior.git", - "junior", + "repos/junior", ], cwd: "/vercel/sandbox", signal: expect.any(AbortSignal), }); expect(result).toMatchObject({ - path: "/vercel/sandbox/junior", + path: "/vercel/sandbox/repos/junior", repo: "getsentry/junior", }); }); @@ -70,13 +77,14 @@ describe("cloneRepository", () => { it("rejects an existing destination", async () => { const run = vi .fn() + .mockResolvedValueOnce({ exitCode: 0, stdout: "", stderr: "" }) .mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" }); const tool = createGitHubCloneRepositoryTool(context(run)); await expect( tool.execute!({ repo: "getsentry/junior" }, {} as never), ).rejects.toThrow("destination already exists"); - expect(run).toHaveBeenCalledTimes(1); + expect(run).toHaveBeenCalledTimes(2); }); it("rejects a parent destination", () => { @@ -84,12 +92,13 @@ describe("cloneRepository", () => { expect(() => tool.prepareArguments!({ repo: "getsentry/junior", directory: ".." }), - ).toThrow("Directory must be a single directory name"); + ).toThrow("Directory must be a relative path without . or .. segments"); }); - it("avoids reserved sandbox directories by default", async () => { + it("defaults reserved repo names under repos/", async () => { const run = vi .fn() + .mockResolvedValueOnce({ exitCode: 0, stdout: "", stderr: "" }) .mockResolvedValueOnce({ exitCode: 1, stdout: "", stderr: "" }) .mockResolvedValueOnce({ exitCode: 0, stdout: "", stderr: "" }); const tool = createGitHubCloneRepositoryTool(context(run)); @@ -100,18 +109,30 @@ describe("cloneRepository", () => { ); expect(run).toHaveBeenNthCalledWith( - 2, + 3, expect.objectContaining({ - args: expect.arrayContaining(["skills-repo"]), + args: expect.arrayContaining(["repos/skills"]), }), ); - expect(result).toMatchObject({ path: "/vercel/sandbox/skills-repo" }); + expect(result).toMatchObject({ path: "/vercel/sandbox/repos/skills" }); + }); + + it("rejects reserved root destination directories", async () => { + const tool = createGitHubCloneRepositoryTool(context(vi.fn())); + + await expect( + tool.execute!( + { repo: "getsentry/skills", directory: "skills" }, + {} as never, + ), + ).rejects.toThrow("Directory conflicts with a reserved sandbox path"); }); it("removes a partial clone before retrying authorization", async () => { const pause = new Error("authorization paused"); const run = vi .fn() + .mockResolvedValueOnce({ exitCode: 0, stdout: "", stderr: "" }) .mockResolvedValueOnce({ exitCode: 1, stdout: "", stderr: "" }) .mockRejectedValueOnce(pause) .mockResolvedValueOnce({ exitCode: 0, stdout: "", stderr: "" }); @@ -120,9 +141,9 @@ describe("cloneRepository", () => { await expect( tool.execute!({ repo: "getsentry/junior" }, {} as never), ).rejects.toBe(pause); - expect(run).toHaveBeenNthCalledWith(3, { + expect(run).toHaveBeenNthCalledWith(4, { cmd: "rm", - args: ["-rf", "--", "/vercel/sandbox/junior"], + args: ["-rf", "--", "/vercel/sandbox/repos/junior"], cwd: "/vercel/sandbox", signal: expect.any(AbortSignal), }); diff --git a/packages/junior-github/tests/github-plugin.test.ts b/packages/junior-github/tests/github-plugin.test.ts index 24b1962e47..748b89c024 100644 --- a/packages/junior-github/tests/github-plugin.test.ts +++ b/packages/junior-github/tests/github-plugin.test.ts @@ -2834,8 +2834,8 @@ Conversation: \`local:test:old-conversation\` log: pluginLog, plugin: { name: "github" }, repos: [ - { repo: "getsentry/sentry", path: "sentry" }, - { repo: "getsentry/junior", path: "junior" }, + { repo: "getsentry/sentry", path: "repos/sentry" }, + { repo: "getsentry/junior", path: "repos/junior" }, ], sandbox: { juniorRoot: "/vercel/sandbox/.junior", @@ -2853,8 +2853,27 @@ Conversation: \`local:test:old-conversation\` await githubPlugin().hooks?.workspacePrepare?.(ctx); - expect(runs.map((run) => run.args?.at(-1))).toEqual(["sentry", "junior"]); - expect(runs[0]?.env).toBeUndefined(); + expect(runs.map((run) => run.args)).toEqual([ + ["-p", "--", "repos"], + [ + "clone", + "--quiet", + "--depth=1", + "--", + "https://github.com/getsentry/sentry.git", + "repos/sentry", + ], + ["-p", "--", "repos"], + [ + "clone", + "--quiet", + "--depth=1", + "--", + "https://github.com/getsentry/junior.git", + "repos/junior", + ], + ]); + expect(runs.every((run) => run.env === undefined)).toBe(true); }); it("rejects reserved workspace checkout paths", async () => { @@ -2905,6 +2924,33 @@ Conversation: \`local:test:old-conversation\` ); }); + it("rejects colliding workspace checkout paths case-insensitively", async () => { + const ctx = { + db, + log: pluginLog, + plugin: { name: "github" }, + repos: [ + { repo: "getsentry/sentry", path: "repos/sentry" }, + { repo: "acme/sentry", path: "repos/Sentry" }, + ], + 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( + "Workspace checkout path collision: repos/Sentry", + ); + }); + 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/src/chat/plugins/agent-hooks.ts b/packages/junior/src/chat/plugins/agent-hooks.ts index 96fdb6a3fc..bcf527e068 100644 --- a/packages/junior/src/chat/plugins/agent-hooks.ts +++ b/packages/junior/src/chat/plugins/agent-hooks.ts @@ -50,6 +50,7 @@ import { createSlackDirectCredentialSubject } from "@/chat/credentials/subject"; import { resolveChannelCapabilities } from "@/chat/slack/tool-support/channel-capabilities"; import type { Actor } from "@/chat/actor"; import { z } from "zod"; +import { workspaceRepoCheckoutPath } from "@/chat/workspaces/checkout-path"; /** Signal that a plugin intentionally denied a tool execution. */ export class PluginHookDeniedError extends Error { @@ -97,7 +98,6 @@ export interface PluginHookRunner { repos: Array<{ provider: string; repo: string; - checkoutPath: string; }>, signal?: AbortSignal, ): Promise; @@ -1425,14 +1425,28 @@ export function createPluginHookRunner( ); } + const selectedRepos = repos.map((repo) => ({ + provider: repo.provider, + repo: repo.repo, + path: workspaceRepoCheckoutPath(repo.repo), + })); + const paths = new Set(); + for (const entry of selectedRepos) { + const key = entry.path.toLowerCase(); + if (paths.has(key)) { + throw new Error(`Workspace checkout path collision: ${entry.path}`); + } + paths.add(key); + } + const sandboxCapability = createSandboxCapability(sandbox, signal); for (const plugin of loaded) { const hook = plugin.hooks?.workspacePrepare; if (!hook) continue; - const selected = repos + const selected = selectedRepos .filter((repo) => repo.provider === plugin.manifest.name) .map((repo) => ({ - path: repo.checkoutPath, + path: repo.path, repo: repo.repo, })); if (selected.length === 0) continue; diff --git a/packages/junior/src/chat/sandbox/README.md b/packages/junior/src/chat/sandbox/README.md index 229cf97d68..659fb84876 100644 --- a/packages/junior/src/chat/sandbox/README.md +++ b/packages/junior/src/chat/sandbox/README.md @@ -50,6 +50,8 @@ traffic through verified host egress. - A Workspace recipe is part of the profile hash. One build installs runtime dependencies, prepares repositories, runs setup, and captures the complete snapshot. +- Workspace repositories clone to fixed `repos/{name}` paths so setup scripts + can find them without a stored checkout path. - Repository preparation uses host egress for provider credentials. Snapshot state and Sandbox commands do not receive real provider credentials. Setup runs after Junior removes the credential route from the build Sandbox. diff --git a/packages/junior/src/chat/sandbox/sandbox.ts b/packages/junior/src/chat/sandbox/sandbox.ts index 90d3d78dfc..ee964606b0 100644 --- a/packages/junior/src/chat/sandbox/sandbox.ts +++ b/packages/junior/src/chat/sandbox/sandbox.ts @@ -28,6 +28,7 @@ import { throwSandboxOperationError, } from "@/chat/sandbox/errors"; import { SANDBOX_WORKSPACE_ROOT } from "@/chat/sandbox/paths"; +import { workspaceRepoCheckoutPath } from "@/chat/workspaces/checkout-path"; import { findSingleRepositoryDirectory, resolveRepositoryInstructions, @@ -800,7 +801,7 @@ export function createSandbox(options: SandboxOptions): SandboxAccess { const { fs } = await runtime.tools(); const primary = activeWorkspace?.repos.find((repo) => repo.isPrimary); const selected = primary - ? `${SANDBOX_WORKSPACE_ROOT}/${primary.checkoutPath}` + ? `${SANDBOX_WORKSPACE_ROOT}/${workspaceRepoCheckoutPath(primary.repo)}` : await findSingleRepositoryDirectory(fs); if (!selected) return undefined; return await resolveRepositoryInstructions({ diff --git a/packages/junior/src/chat/sandbox/snapshot/profile.ts b/packages/junior/src/chat/sandbox/snapshot/profile.ts index 2b931156a0..348e100882 100644 --- a/packages/junior/src/chat/sandbox/snapshot/profile.ts +++ b/packages/junior/src/chat/sandbox/snapshot/profile.ts @@ -89,17 +89,14 @@ 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 }) => ({ + .map(({ provider, repo }) => ({ provider, repo, - checkoutPath, })) .sort((left, right) => { const provider = compareCodePoints(left.provider, right.provider); if (provider !== 0) return provider; - const repo = compareCodePoints(left.repo, right.repo); - if (repo !== 0) return repo; - return compareCodePoints(left.checkoutPath, right.checkoutPath); + return compareCodePoints(left.repo, right.repo); }); return { id: workspace.id, diff --git a/packages/junior/src/chat/workspaces/checkout-path.ts b/packages/junior/src/chat/workspaces/checkout-path.ts new file mode 100644 index 0000000000..33f3507045 --- /dev/null +++ b/packages/junior/src/chat/workspaces/checkout-path.ts @@ -0,0 +1,13 @@ +/** Derive the fixed sandbox checkout path for one repository. */ +export function workspaceRepoCheckoutPath(repo: string): string { + const name = repo.split("/").filter(Boolean).at(-1); + if ( + !name || + name === "." || + name === ".." || + !/^[A-Za-z0-9._-]+$/.test(name) + ) { + throw new Error(`Invalid repository name for checkout path: ${repo}`); + } + return `repos/${name}`; +} diff --git a/packages/junior/src/chat/workspaces/store.ts b/packages/junior/src/chat/workspaces/store.ts index d8dd09df90..53b81156d0 100644 --- a/packages/junior/src/chat/workspaces/store.ts +++ b/packages/junior/src/chat/workspaces/store.ts @@ -14,7 +14,6 @@ function workspaceFromRows( repos: repos.map((repo) => ({ provider: repo.provider, repo: repo.repo, - checkoutPath: repo.checkoutPath, isPrimary: repo.isPrimary, })), }; @@ -31,7 +30,6 @@ export async function listWorkspaces(db: JuniorDatabase): Promise { asc(juniorWorkspaceRepos.workspaceId), asc(juniorWorkspaceRepos.provider), asc(juniorWorkspaceRepos.repo), - asc(juniorWorkspaceRepos.checkoutPath), ), ]); return workspaces.map((workspace) => @@ -61,7 +59,6 @@ export async function getWorkspaceByName( .orderBy( asc(juniorWorkspaceRepos.provider), asc(juniorWorkspaceRepos.repo), - asc(juniorWorkspaceRepos.checkoutPath), ); return workspaceFromRows(workspace, repos); } @@ -85,7 +82,6 @@ export async function getWorkspace( .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 index 584d9f2425..5799dc74e1 100644 --- a/packages/junior/src/chat/workspaces/tools.ts +++ b/packages/junior/src/chat/workspaces/tools.ts @@ -5,6 +5,7 @@ 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 { workspaceRepoCheckoutPath } from "./checkout-path"; import { getWorkspaceByName, listWorkspaces } from "./store"; const repoSchema = z.object({ @@ -26,7 +27,7 @@ function view(workspace: Awaited>[number]) { repos: workspace.repos.map((repo) => ({ provider: repo.provider, repo: repo.repo, - checkout_path: repo.checkoutPath, + checkout_path: workspaceRepoCheckoutPath(repo.repo), is_primary: repo.isPrimary, })), }; diff --git a/packages/junior/src/chat/workspaces/types.ts b/packages/junior/src/chat/workspaces/types.ts index c7f5a74ea4..6eaf757ed0 100644 --- a/packages/junior/src/chat/workspaces/types.ts +++ b/packages/junior/src/chat/workspaces/types.ts @@ -1,7 +1,6 @@ export interface WorkspaceRepo { provider: string; repo: string; - checkoutPath: string; isPrimary: boolean; } diff --git a/packages/junior/src/db/schema/workspaces.ts b/packages/junior/src/db/schema/workspaces.ts index 779987aeb0..b3483ff664 100644 --- a/packages/junior/src/db/schema/workspaces.ts +++ b/packages/junior/src/db/schema/workspaces.ts @@ -30,15 +30,10 @@ export const juniorWorkspaceRepos = pgTable( .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/sandbox/snapshot/resolve.test.ts b/packages/junior/tests/component/sandbox/snapshot/resolve.test.ts index 7e81c7175a..f26f4249be 100644 --- a/packages/junior/tests/component/sandbox/snapshot/resolve.test.ts +++ b/packages/junior/tests/component/sandbox/snapshot/resolve.test.ts @@ -426,7 +426,6 @@ describe("snapshot resolution", () => { { provider: "github", repo: "getsentry/sentry", - checkoutPath: "sentry", isPrimary: true, }, ], diff --git a/packages/junior/tests/integration/workspace-tools.test.ts b/packages/junior/tests/integration/workspace-tools.test.ts index 0860eced65..a7ae05351b 100644 --- a/packages/junior/tests/integration/workspace-tools.test.ts +++ b/packages/junior/tests/integration/workspace-tools.test.ts @@ -21,7 +21,6 @@ describe("Workspace tools", () => { { provider: "github", repo: "getsentry/sentry", - checkoutPath: "sentry", isPrimary: true, }, ], @@ -88,7 +87,7 @@ describe("Workspace tools", () => { { provider: "github", repo: "getsentry/sentry", - checkout_path: "sentry", + checkout_path: "repos/sentry", is_primary: true, }, ], diff --git a/packages/junior/tests/unit/plugins/agent-hooks.test.ts b/packages/junior/tests/unit/plugins/agent-hooks.test.ts index 5c9ec220c2..9df26ac181 100644 --- a/packages/junior/tests/unit/plugins/agent-hooks.test.ts +++ b/packages/junior/tests/unit/plugins/agent-hooks.test.ts @@ -1726,7 +1726,6 @@ describe("agent plugin hooks", () => { { provider: "agent-demo", repo: "example/demo", - checkoutPath: "demo", }, ], controller.signal, @@ -1767,12 +1766,10 @@ describe("agent plugin hooks", () => { { provider: "agent-demo", repo: "example/demo", - checkoutPath: "demo", }, { provider: "missing-provider", repo: "example/missing", - checkoutPath: "missing", }, ]), ).rejects.toThrow( @@ -1784,6 +1781,37 @@ describe("agent plugin hooks", () => { } }); + it("rejects colliding Workspace checkout paths from short repository names", async () => { + const previous = setPlugins([ + defineJuniorPlugin({ + manifest: { + name: "github", + displayName: "GitHub", + description: "GitHub", + }, + hooks: { + async workspacePrepare() {}, + }, + }), + ]); + try { + await expect( + createPluginHookRunner().prepareWorkspace?.(fakeSandbox([]), [ + { + provider: "github", + repo: "getsentry/sentry", + }, + { + provider: "github", + repo: "acme/sentry", + }, + ]), + ).rejects.toThrow("Workspace checkout path collision: repos/sentry"); + } finally { + setPlugins(previous); + } + }); + it("materializes beforeToolExecute actors from the live actors getter per call", async () => { const seenActorSets: unknown[][] = []; const previous = setPlugins([ diff --git a/packages/junior/tests/unit/sandbox/snapshot/profile.test.ts b/packages/junior/tests/unit/sandbox/snapshot/profile.test.ts index 917e4dbba9..a54ee57dd0 100644 --- a/packages/junior/tests/unit/sandbox/snapshot/profile.test.ts +++ b/packages/junior/tests/unit/sandbox/snapshot/profile.test.ts @@ -86,7 +86,6 @@ describe("snapshot dependency profile", () => { { provider: "github", repo: "getsentry/sentry", - checkoutPath: "sentry", isPrimary: true, }, ], @@ -112,13 +111,11 @@ describe("snapshot dependency profile", () => { { provider: "github", repo: "getsentry/sentry", - checkoutPath: "sentry", isPrimary: true, }, { provider: "github", repo: "getsentry/relay", - checkoutPath: "relay", isPrimary: false, }, ]; @@ -147,13 +144,11 @@ describe("snapshot dependency profile", () => { { provider: "github", repo: "getsentry/Zulu", - checkoutPath: "Zulu", isPrimary: true, }, { provider: "github", repo: "getsentry/alpha", - checkoutPath: "alpha", isPrimary: false, }, ]; diff --git a/packages/junior/tests/unit/workspaces/checkout-path.test.ts b/packages/junior/tests/unit/workspaces/checkout-path.test.ts new file mode 100644 index 0000000000..115844809b --- /dev/null +++ b/packages/junior/tests/unit/workspaces/checkout-path.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { workspaceRepoCheckoutPath } from "@/chat/workspaces/checkout-path"; + +describe("workspaceRepoCheckoutPath", () => { + it("places repositories under repos/{name}", () => { + expect(workspaceRepoCheckoutPath("getsentry/sentry")).toBe("repos/sentry"); + expect(workspaceRepoCheckoutPath("getsentry/skills")).toBe("repos/skills"); + }); + + it("rejects invalid repository names", () => { + expect(() => workspaceRepoCheckoutPath("")).toThrow( + "Invalid repository name for checkout path", + ); + expect(() => workspaceRepoCheckoutPath("getsentry/..")).toThrow( + "Invalid repository name for checkout path", + ); + }); +}); From 5a81eb5980ab68afc2c2f728219d995cf3235468 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 20:23:59 +0000 Subject: [PATCH 30/34] feat(workspaces): Expose setup path environment --- packages/junior/src/chat/sandbox/README.md | 5 +++-- packages/junior/src/chat/sandbox/paths.ts | 1 + packages/junior/src/chat/sandbox/session.ts | 6 +++++- .../tests/component/misc/sandbox-executor.test.ts | 11 ++++++++++- 4 files changed, 19 insertions(+), 4 deletions(-) diff --git a/packages/junior/src/chat/sandbox/README.md b/packages/junior/src/chat/sandbox/README.md index 659fb84876..9b34cfb6fd 100644 --- a/packages/junior/src/chat/sandbox/README.md +++ b/packages/junior/src/chat/sandbox/README.md @@ -50,8 +50,9 @@ traffic through verified host egress. - A Workspace recipe is part of the profile hash. One build installs runtime dependencies, prepares repositories, runs setup, and captures the complete snapshot. -- Workspace repositories clone to fixed `repos/{name}` paths so setup scripts - can find them without a stored checkout path. +- Workspace repositories clone to fixed `repos/{name}` paths. Setup scripts + receive `JUNIOR_WORKSPACE_ROOT` and `JUNIOR_REPOS_ROOT` so they do not depend + on the provider's absolute Sandbox path. - Repository preparation uses host egress for provider credentials. Snapshot state and Sandbox commands do not receive real provider credentials. Setup runs after Junior removes the credential route from the build Sandbox. diff --git a/packages/junior/src/chat/sandbox/paths.ts b/packages/junior/src/chat/sandbox/paths.ts index 5838eb8cd3..c1b08f6724 100644 --- a/packages/junior/src/chat/sandbox/paths.ts +++ b/packages/junior/src/chat/sandbox/paths.ts @@ -11,6 +11,7 @@ function normalizeWorkspaceRoot(input: string | undefined): string { export const SANDBOX_WORKSPACE_ROOT = normalizeWorkspaceRoot( process.env.VERCEL_SANDBOX_WORKSPACE_DIR, ); +export const SANDBOX_REPOS_ROOT = `${SANDBOX_WORKSPACE_ROOT}/repos`; export const SANDBOX_SKILLS_ROOT = `${SANDBOX_WORKSPACE_ROOT}/skills`; export const SANDBOX_DATA_ROOT = `${SANDBOX_WORKSPACE_ROOT}/data`; diff --git a/packages/junior/src/chat/sandbox/session.ts b/packages/junior/src/chat/sandbox/session.ts index 83661afe2d..59ceacaea6 100644 --- a/packages/junior/src/chat/sandbox/session.ts +++ b/packages/junior/src/chat/sandbox/session.ts @@ -36,7 +36,7 @@ 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"; +import { SANDBOX_REPOS_ROOT, SANDBOX_WORKSPACE_ROOT } from "@/chat/sandbox/paths"; const DEFAULT_MAX_OUTPUT_LENGTH = 30_000; const DEFAULT_BASH_COMMAND_TIMEOUT_MS = 5 * 60 * 1000; @@ -486,6 +486,10 @@ export function createSandboxRuntime( cmd: "bash", args: ["-euo", "pipefail", "-c", workspace.setupScript], cwd: SANDBOX_WORKSPACE_ROOT, + env: { + JUNIOR_REPOS_ROOT: SANDBOX_REPOS_ROOT, + JUNIOR_WORKSPACE_ROOT: SANDBOX_WORKSPACE_ROOT, + }, signal, }); if (result.exitCode !== 0) { diff --git a/packages/junior/tests/component/misc/sandbox-executor.test.ts b/packages/junior/tests/component/misc/sandbox-executor.test.ts index cdd4c38903..17b11b0c63 100644 --- a/packages/junior/tests/component/misc/sandbox-executor.test.ts +++ b/packages/junior/tests/component/misc/sandbox-executor.test.ts @@ -2,7 +2,11 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { SANDBOX_WORKSPACE_ROOT, sandboxSkillDir } from "@/chat/sandbox/paths"; +import { + SANDBOX_REPOS_ROOT, + SANDBOX_WORKSPACE_ROOT, + sandboxSkillDir, +} from "@/chat/sandbox/paths"; import type { SandboxSession } from "@/chat/sandbox/workspace"; import type { SkillMetadata } from "@/chat/skills"; @@ -1227,9 +1231,14 @@ describe("createTestSandbox", () => { await setupStarted; const setupCommand = buildSandbox.runCommand.mock.calls[0]?.[0] as { cmd?: string; + env?: Record; signal?: AbortSignal; }; expect(setupCommand.cmd).toBe("bash"); + expect(setupCommand.env).toEqual({ + JUNIOR_REPOS_ROOT: SANDBOX_REPOS_ROOT, + JUNIOR_WORKSPACE_ROOT: SANDBOX_WORKSPACE_ROOT, + }); expect(setupCommand.signal).toBeInstanceOf(AbortSignal); expect(setupCommand.signal?.aborted).toBe(false); From b2b5b405effebbf5dc9495a11d6837ab7c43d038 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 20:26:42 +0000 Subject: [PATCH 31/34] fix(workspaces): Discover AGENTS.md under repos/ findSingleRepositoryDirectory now looks in repos/{name} so ad-hoc and workspace clones keep automatic AGENTS.md selection after the layout change. --- .../src/chat/repository-instructions.ts | 15 +++++++++---- .../unit/repository-instructions.test.ts | 22 ++++++++++++++----- 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/packages/junior/src/chat/repository-instructions.ts b/packages/junior/src/chat/repository-instructions.ts index 570c1b0fcf..722e9f6e3d 100644 --- a/packages/junior/src/chat/repository-instructions.ts +++ b/packages/junior/src/chat/repository-instructions.ts @@ -1,7 +1,10 @@ import { createHash } from "node:crypto"; import path from "node:path"; import type { PiMessage } from "@/chat/pi/messages"; -import { SANDBOX_WORKSPACE_ROOT } from "@/chat/sandbox/paths"; +import { + SANDBOX_REPOS_ROOT, + SANDBOX_WORKSPACE_ROOT, +} from "@/chat/sandbox/paths"; import type { SandboxFileSystem } from "@/chat/sandbox/workspace"; import { isMissingPathError } from "@/chat/tools/sandbox/file-utils"; @@ -79,14 +82,18 @@ async function findGitRoot( } } -/** Return the only direct-child Git worktree, without guessing when ambiguous. */ +/** Return the only Git worktree under repos/, without guessing when ambiguous. */ export async function findSingleRepositoryDirectory( fs: SandboxFileSystem, ): Promise { - const entries = await fs.readdir(SANDBOX_WORKSPACE_ROOT); + if (!(await directoryExists(fs, SANDBOX_REPOS_ROOT))) { + return undefined; + } + + const entries = await fs.readdir(SANDBOX_REPOS_ROOT); const repositories: string[] = []; for (const entry of entries) { - const candidate = path.posix.join(SANDBOX_WORKSPACE_ROOT, entry); + const candidate = path.posix.join(SANDBOX_REPOS_ROOT, entry); if ( (await directoryExists(fs, candidate)) && (await pathExists(fs, path.posix.join(candidate, ".git"))) diff --git a/packages/junior/tests/unit/repository-instructions.test.ts b/packages/junior/tests/unit/repository-instructions.test.ts index b18238936e..b7b16ada61 100644 --- a/packages/junior/tests/unit/repository-instructions.test.ts +++ b/packages/junior/tests/unit/repository-instructions.test.ts @@ -109,19 +109,29 @@ describe("repository instructions", () => { }); }); - it("selects only one direct-child Git worktree", async () => { + it("selects only one Git worktree under repos/", async () => { const fs = new MemoryFileSystem() .directory("/vercel/sandbox") - .directory("/vercel/sandbox/repo") - .file("/vercel/sandbox/repo/.git", "gitdir: elsewhere"); + .directory("/vercel/sandbox/repos") + .directory("/vercel/sandbox/repos/repo") + .file("/vercel/sandbox/repos/repo/.git", "gitdir: elsewhere"); expect(await findSingleRepositoryDirectory(fs)).toBe( - "/vercel/sandbox/repo", + "/vercel/sandbox/repos/repo", ); - fs.directory("/vercel/sandbox/other").directory( - "/vercel/sandbox/other/.git", + fs.directory("/vercel/sandbox/repos/other").directory( + "/vercel/sandbox/repos/other/.git", ); expect(await findSingleRepositoryDirectory(fs)).toBeUndefined(); }); + + it("ignores root-level clones outside repos/", async () => { + const fs = new MemoryFileSystem() + .directory("/vercel/sandbox") + .directory("/vercel/sandbox/repo") + .file("/vercel/sandbox/repo/.git", "gitdir: elsewhere"); + + expect(await findSingleRepositoryDirectory(fs)).toBeUndefined(); + }); }); From d37fb8291a8581a0ae90f6fee3f4213e3c27968e Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:35:17 +0000 Subject: [PATCH 32/34] fix(workspaces): Soft-fail malformed primary repo AGENTS paths Invalid workspace repo names no longer throw during AGENTS.md capture. Fall back to single-repo discovery or skip instructions instead of aborting the agent turn. --- packages/junior/src/chat/sandbox/sandbox.ts | 9 ++++++--- .../src/chat/workspaces/checkout-path.ts | 15 ++++++++++++--- .../unit/workspaces/checkout-path.test.ts | 19 ++++++++++++++++++- 3 files changed, 36 insertions(+), 7 deletions(-) diff --git a/packages/junior/src/chat/sandbox/sandbox.ts b/packages/junior/src/chat/sandbox/sandbox.ts index ee964606b0..dfefcee86a 100644 --- a/packages/junior/src/chat/sandbox/sandbox.ts +++ b/packages/junior/src/chat/sandbox/sandbox.ts @@ -28,7 +28,7 @@ import { throwSandboxOperationError, } from "@/chat/sandbox/errors"; import { SANDBOX_WORKSPACE_ROOT } from "@/chat/sandbox/paths"; -import { workspaceRepoCheckoutPath } from "@/chat/workspaces/checkout-path"; +import { tryWorkspaceRepoCheckoutPath } from "@/chat/workspaces/checkout-path"; import { findSingleRepositoryDirectory, resolveRepositoryInstructions, @@ -800,8 +800,11 @@ export function createSandbox(options: SandboxOptions): SandboxAccess { } const { fs } = await runtime.tools(); const primary = activeWorkspace?.repos.find((repo) => repo.isPrimary); - const selected = primary - ? `${SANDBOX_WORKSPACE_ROOT}/${workspaceRepoCheckoutPath(primary.repo)}` + const primaryPath = primary + ? tryWorkspaceRepoCheckoutPath(primary.repo) + : undefined; + const selected = primaryPath + ? `${SANDBOX_WORKSPACE_ROOT}/${primaryPath}` : await findSingleRepositoryDirectory(fs); if (!selected) return undefined; return await resolveRepositoryInstructions({ diff --git a/packages/junior/src/chat/workspaces/checkout-path.ts b/packages/junior/src/chat/workspaces/checkout-path.ts index 33f3507045..9444d730f3 100644 --- a/packages/junior/src/chat/workspaces/checkout-path.ts +++ b/packages/junior/src/chat/workspaces/checkout-path.ts @@ -1,5 +1,5 @@ -/** Derive the fixed sandbox checkout path for one repository. */ -export function workspaceRepoCheckoutPath(repo: string): string { +/** Derive the fixed sandbox checkout path for one repository, or undefined when invalid. */ +export function tryWorkspaceRepoCheckoutPath(repo: string): string | undefined { const name = repo.split("/").filter(Boolean).at(-1); if ( !name || @@ -7,7 +7,16 @@ export function workspaceRepoCheckoutPath(repo: string): string { name === ".." || !/^[A-Za-z0-9._-]+$/.test(name) ) { - throw new Error(`Invalid repository name for checkout path: ${repo}`); + return undefined; } return `repos/${name}`; } + +/** Derive the fixed sandbox checkout path for one repository. */ +export function workspaceRepoCheckoutPath(repo: string): string { + const path = tryWorkspaceRepoCheckoutPath(repo); + if (!path) { + throw new Error(`Invalid repository name for checkout path: ${repo}`); + } + return path; +} diff --git a/packages/junior/tests/unit/workspaces/checkout-path.test.ts b/packages/junior/tests/unit/workspaces/checkout-path.test.ts index 115844809b..681c8fde88 100644 --- a/packages/junior/tests/unit/workspaces/checkout-path.test.ts +++ b/packages/junior/tests/unit/workspaces/checkout-path.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { workspaceRepoCheckoutPath } from "@/chat/workspaces/checkout-path"; +import { + tryWorkspaceRepoCheckoutPath, + workspaceRepoCheckoutPath, +} from "@/chat/workspaces/checkout-path"; describe("workspaceRepoCheckoutPath", () => { it("places repositories under repos/{name}", () => { @@ -16,3 +19,17 @@ describe("workspaceRepoCheckoutPath", () => { ); }); }); + +describe("tryWorkspaceRepoCheckoutPath", () => { + it("returns undefined for malformed repository names", () => { + expect(tryWorkspaceRepoCheckoutPath("")).toBeUndefined(); + expect(tryWorkspaceRepoCheckoutPath("getsentry/..")).toBeUndefined(); + expect(tryWorkspaceRepoCheckoutPath("getsentry/bad name")).toBeUndefined(); + }); + + it("returns the fixed checkout path for valid names", () => { + expect(tryWorkspaceRepoCheckoutPath("getsentry/sentry")).toBe( + "repos/sentry", + ); + }); +}); From 214dfb3aadfedd4d4e94582c1713f57808130fef Mon Sep 17 00:00:00 2001 From: David Cramer Date: Thu, 13 Aug 2026 23:39:46 -0700 Subject: [PATCH 33/34] fix(workspaces): align rebased architecture --- TERMINOLOGY.md | 5 +- .../content/docs/operate/sandbox-snapshots.md | 2 +- packages/junior-github/src/plugin.ts | 70 +- .../junior-github/src/workspace-prepare.ts | 73 + packages/junior-plugin-api/src/tools.ts | 1 + .../migrations/0029_dizzy_barracuda.sql | 19 + .../junior/migrations/meta/0029_snapshot.json | 2709 +++++++++++++++++ packages/junior/migrations/meta/_journal.json | 9 +- .../junior/src/chat/agent-invocations/work.ts | 9 +- packages/junior/src/chat/agent/index.ts | 9 +- packages/junior/src/chat/agent/sandbox.ts | 6 +- packages/junior/src/chat/agent/tools.ts | 4 +- packages/junior/src/chat/agent/types.ts | 12 +- packages/junior/src/chat/api-turns/work.ts | 7 +- packages/junior/src/chat/local/runner.ts | 11 +- .../junior/src/chat/plugins/agent-hooks.ts | 2 +- packages/junior/src/chat/sandbox/README.md | 7 +- .../src/chat/sandbox/prepare-workspace.ts | 46 + packages/junior/src/chat/sandbox/session.ts | 73 +- .../junior/src/chat/services/turn-result.ts | 6 +- packages/junior/src/chat/workspaces/tools.ts | 3 +- packages/junior/src/chat/workspaces/types.ts | 1 + .../component/misc/sandbox-executor.test.ts | 10 +- .../component/runtime/thread-state.test.ts | 29 - .../component/scheduled-tasks-sql.test.ts | 2 +- .../tool-support/pi-tool-adapter.test.ts | 1 + .../tests/unit/plugins/agent-hooks.test.ts | 6 +- .../unit/sandbox/snapshot/profile.test.ts | 10 - 28 files changed, 2932 insertions(+), 210 deletions(-) create mode 100644 packages/junior-github/src/workspace-prepare.ts create mode 100644 packages/junior/migrations/0029_dizzy_barracuda.sql create mode 100644 packages/junior/migrations/meta/0029_snapshot.json create mode 100644 packages/junior/src/chat/sandbox/prepare-workspace.ts diff --git a/TERMINOLOGY.md b/TERMINOLOGY.md index ea238cdf22..5665dc026a 100644 --- a/TERMINOLOGY.md +++ b/TERMINOLOGY.md @@ -4,8 +4,9 @@ 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. +- **Workspace**: a named recipe that selects repositories and setup + instructions for a Sandbox snapshot. +- **Sandbox**: an isolated execution environment for a run or snapshot build. - **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/docs/src/content/docs/operate/sandbox-snapshots.md b/packages/docs/src/content/docs/operate/sandbox-snapshots.md index 52691eb94a..d65870c8a2 100644 --- a/packages/docs/src/content/docs/operate/sandbox-snapshots.md +++ b/packages/docs/src/content/docs/operate/sandbox-snapshots.md @@ -37,7 +37,7 @@ Junior computes the snapshot profile from its global baseline and loaded plugin | npm dependencies | Global and plugin `runtime-dependencies` entries with `type: npm`. | | system dependencies | Global and plugin `runtime-dependencies` entries with `type: system`. | | postinstall commands | Global and plugin `runtime-postinstall` entries. | -| Workspace recipe | Repository providers, names, checkout paths, and setup script. | +| Workspace recipe | Repository providers, identifiers, and setup script. | | manual rebuild epoch | `SANDBOX_SNAPSHOT_REBUILD_EPOCH`, when set. | Any change to those inputs produces a new profile hash and a new snapshot. diff --git a/packages/junior-github/src/plugin.ts b/packages/junior-github/src/plugin.ts index 22a8fa7a75..b8b909c4a1 100644 --- a/packages/junior-github/src/plugin.ts +++ b/packages/junior-github/src/plugin.ts @@ -53,7 +53,7 @@ import { prepareCommitMsgHook, } from "./git-config.js"; import { linkifyGitHubReferences } from "./reply-markdown.js"; -import { isReservedSandboxDirectory } from "./sandbox-paths.js"; +import { prepareWorkspace } from "./workspace-prepare.js"; import { CREATE_TOOL_ROUTING_GUIDANCE, GITHUB_APP_ID_ENV, @@ -837,73 +837,7 @@ 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}`); - } - const segments = entry.path.split("/"); - if ( - segments.length === 0 || - segments.some( - (part) => - !part || - part === "." || - part === ".." || - !/^[A-Za-z0-9._-]+$/.test(part), - ) || - isReservedSandboxDirectory(segments[0]!) - ) { - throw new Error(`Invalid workspace checkout path: ${entry.path}`); - } - return { owner, name, path: entry.path, repo: entry.repo }; - }); - const paths = new Set(); - for (const entry of repos) { - const key = entry.path.toLowerCase(); - if (paths.has(key)) { - throw new Error( - `Workspace checkout path collision: ${entry.path}`, - ); - } - paths.add(key); - } - for (const { owner, name, path, repo } of repos) { - const parent = path.includes("/") - ? path.slice(0, path.lastIndexOf("/")) - : undefined; - if (parent) { - const mkdir = await ctx.sandbox.run({ - cmd: "mkdir", - args: ["-p", "--", parent], - cwd: ctx.sandbox.root, - }); - if (mkdir.exitCode !== 0) { - throw new Error( - `GitHub workspace checkout parent failed for ${repo}: ${mkdir.stderr.trim() || `exit ${mkdir.exitCode}`}`, - ); - } - } - const result = await ctx.sandbox.run({ - cmd: "git", - args: [ - "clone", - "--quiet", - "--depth=1", - "--", - `https://github.com/${owner}/${name}.git`, - path, - ], - cwd: ctx.sandbox.root, - }); - if (result.exitCode !== 0) { - throw new Error( - `GitHub workspace clone failed for ${repo}: ${result.stderr.trim() || `exit ${result.exitCode}`}`, - ); - } - } - }, + workspacePrepare: prepareWorkspace, async sandboxPrepare(ctx) { const hooksPath = `${ctx.sandbox.juniorRoot}/git-hooks`; await ctx.sandbox.writeFile({ diff --git a/packages/junior-github/src/workspace-prepare.ts b/packages/junior-github/src/workspace-prepare.ts new file mode 100644 index 0000000000..4b8bcb7aac --- /dev/null +++ b/packages/junior-github/src/workspace-prepare.ts @@ -0,0 +1,73 @@ +import type { WorkspacePrepareHookContext } from "@sentry/junior-plugin-api"; +import { isReservedSandboxDirectory } from "./sandbox-paths.js"; + +/** Clone GitHub repositories into their host-selected Workspace paths. */ +export async function prepareWorkspace( + ctx: WorkspacePrepareHookContext, +): Promise { + 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}`); + } + const segments = entry.path.split("/"); + if ( + segments.length === 0 || + segments.some( + (part) => + !part || + part === "." || + part === ".." || + !/^[A-Za-z0-9._-]+$/.test(part), + ) || + isReservedSandboxDirectory(segments[0]!) + ) { + throw new Error(`Invalid workspace checkout path: ${entry.path}`); + } + return { owner, name, path: entry.path, repo: entry.repo }; + }); + + const paths = new Set(); + for (const entry of repos) { + const key = entry.path.toLowerCase(); + if (paths.has(key)) { + throw new Error(`Workspace checkout path collision: ${entry.path}`); + } + paths.add(key); + } + + for (const { owner, name, path, repo } of repos) { + const parent = path.includes("/") + ? path.slice(0, path.lastIndexOf("/")) + : undefined; + if (parent) { + const mkdir = await ctx.sandbox.run({ + cmd: "mkdir", + args: ["-p", "--", parent], + cwd: ctx.sandbox.root, + }); + if (mkdir.exitCode !== 0) { + throw new Error( + `GitHub workspace checkout parent failed for ${repo}: ${mkdir.stderr.trim() || `exit ${mkdir.exitCode}`}`, + ); + } + } + const result = await ctx.sandbox.run({ + cmd: "git", + args: [ + "clone", + "--quiet", + "--depth=1", + "--", + `https://github.com/${owner}/${name}.git`, + path, + ], + cwd: ctx.sandbox.root, + }); + if (result.exitCode !== 0) { + throw new Error( + `GitHub workspace clone failed for ${repo}: ${result.stderr.trim() || `exit ${result.exitCode}`}`, + ); + } + } +} diff --git a/packages/junior-plugin-api/src/tools.ts b/packages/junior-plugin-api/src/tools.ts index 49c53dda88..3431405fd9 100644 --- a/packages/junior-plugin-api/src/tools.ts +++ b/packages/junior-plugin-api/src/tools.ts @@ -140,6 +140,7 @@ export interface PluginMcp { prepare(): Promise<"authorization_pending" | "ready">; } +/** Provider-owned repository preparation for one Workspace snapshot build. */ export interface WorkspacePrepareHookContext extends PluginContext { repos: Array<{ path: string; diff --git a/packages/junior/migrations/0029_dizzy_barracuda.sql b/packages/junior/migrations/0029_dizzy_barracuda.sql new file mode 100644 index 0000000000..a9e1e4100d --- /dev/null +++ b/packages/junior/migrations/0029_dizzy_barracuda.sql @@ -0,0 +1,19 @@ +CREATE TABLE "junior_workspace_repos" ( + "workspace_id" text NOT NULL, + "provider" text NOT NULL, + "repo" 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_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/0029_snapshot.json b/packages/junior/migrations/meta/0029_snapshot.json new file mode 100644 index 0000000000..5b85e494ce --- /dev/null +++ b/packages/junior/migrations/meta/0029_snapshot.json @@ -0,0 +1,2709 @@ +{ + "id": "7868d07f-46bb-436d-9a71-dd4a34e40435", + "prevId": "963c3e75-9ad9-4851-ba96-50c87ef04855", + "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_artifacts": { + "name": "junior_artifacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ext": { + "name": "ext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "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 + }, + "public": { + "name": "public", + "type": "boolean", + "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_artifacts_conversation_sha_uidx": { + "name": "junior_artifacts_conversation_sha_uidx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sha256", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_artifacts_gc_idx": { + "name": "junior_artifacts_gc_idx", + "columns": [ + { + "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": {} + }, + "junior_artifacts_conversation_idx": { + "name": "junior_artifacts_conversation_idx", + "columns": [ + { + "expression": "conversation_id", + "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 + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "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": {} + } +} diff --git a/packages/junior/migrations/meta/_journal.json b/packages/junior/migrations/meta/_journal.json index 08a561a553..c757c5e7b0 100644 --- a/packages/junior/migrations/meta/_journal.json +++ b/packages/junior/migrations/meta/_journal.json @@ -204,6 +204,13 @@ "when": 1786662452154, "tag": "0028_artifacts", "breakpoints": true + }, + { + "idx": 29, + "version": "7", + "when": 1786688945040, + "tag": "0029_dizzy_barracuda", + "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 c9f66482f9..b19512b66a 100644 --- a/packages/junior/src/chat/agent-invocations/work.ts +++ b/packages/junior/src/chat/agent-invocations/work.ts @@ -290,7 +290,7 @@ export function createAgentInvocationWorker(options: { const lifecycle = new ConversationTurnLifecycleService( getConversationEventStore(), ); - let sandboxRef: SandboxRef | null | undefined; + let sandboxRef: SandboxRef | undefined; let history: PiMessage[]; try { if (invocation.childConversationId !== context.conversationId) { @@ -376,14 +376,12 @@ export function createAgentInvocationWorker(options: { disabledFeatures: ["handoff", "interactive-auth", "subagents"], reasoning: invocation.reasoningLevel, state: { - // Agent run state only tracks a live/absent ref, not an explicit clear. - sandboxRef: sandboxRef ?? undefined, + sandboxRef, }, durability: { onInputCommitted: acknowledge, shouldYield: context.shouldYield, onSandboxRefChanged: async (nextSandboxRef) => { - // Keep null so a failed inline persist still clears on final write. sandboxRef = nextSandboxRef; await persistThreadStateById(invocation.childConversationId, { sandboxRef, @@ -444,8 +442,7 @@ export function createAgentInvocationWorker(options: { const result = outcome.result; const failed = result.diagnostics.outcome !== "success"; await persistThreadStateById(invocation.childConversationId, { - sandboxRef: - result.sandboxRef !== undefined ? result.sandboxRef : sandboxRef, + sandboxRef: result.sandboxRef ?? sandboxRef, }); if (result.piMessages?.length) { await saveTurnCheckpoint({ diff --git a/packages/junior/src/chat/agent/index.ts b/packages/junior/src/chat/agent/index.ts index 4dc017968b..201ca98ab0 100644 --- a/packages/junior/src/chat/agent/index.ts +++ b/packages/junior/src/chat/agent/index.ts @@ -33,7 +33,6 @@ import { } from "@/chat/logging"; import { getConfigDefaults } from "@/chat/configuration/defaults"; import { SkillSandbox } from "@/chat/sandbox/skill-sandbox"; -import type { SandboxRef } from "@/chat/sandbox/ref"; import { findSkillByName, parseSkillInvocation, @@ -359,7 +358,7 @@ async function executeAgentRunInPrivacyContext( const turnTimeoutBudgetMs = Math.max(0, turnDeadlineAtMs - replyStartedAtMs); let resume: ResumeState | undefined; - let lastKnownSandboxRef: SandboxRef | null | undefined = state.sandboxRef; + let lastKnownSandboxRef = state.sandboxRef; let mcpToolManager: McpToolManager | undefined; let closeTools: (() => Promise) | undefined; let connectedMcpProviders = new Set(); @@ -1653,11 +1652,7 @@ async function executeAgentRunInPrivacyContext( newMessages, userInput, toolCalls, - // Prefer the durability hint so an explicit clear (null) survives result. - sandboxRef: - lastKnownSandboxRef !== undefined - ? lastKnownSandboxRef - : wiring.getSandboxRef(), + sandboxRef: wiring.getSandboxRef(), piMessages: [...agent.state.messages], durationMs: Date.now() - replyStartedAtMs, generatedFileCount: generatedFiles.length, diff --git a/packages/junior/src/chat/agent/sandbox.ts b/packages/junior/src/chat/agent/sandbox.ts index be9ce19355..5d00081d3d 100644 --- a/packages/junior/src/chat/agent/sandbox.ts +++ b/packages/junior/src/chat/agent/sandbox.ts @@ -46,9 +46,8 @@ export interface AgentSandboxOptions { recipe: Workspace, signal?: AbortSignal, ): Promise; - /** In-memory run hint. null means cleared; undefined means unknown/unchanged. */ - onSandboxRefChanged(sandboxRef: SandboxRef | null | undefined): void; - persistSandboxRef?(sandboxRef: SandboxRef | null): void | Promise; + onSandboxRefChanged(sandboxRef: SandboxRef): void; + persistSandboxRef?(sandboxRef: SandboxRef): void | Promise; } export interface AgentSandbox { @@ -158,7 +157,6 @@ export function createAgentSandbox(options: AgentSandboxOptions): AgentSandbox { prepare: options.prepareSandbox, prepareWorkspace: options.prepareWorkspace, onSandboxRefChanged: async (sandboxRef) => { - // Keep null as a clear signal for the final post-run persist fallback. options.onSandboxRefChanged(sandboxRef); await options.persistSandboxRef?.(sandboxRef); }, diff --git a/packages/junior/src/chat/agent/tools.ts b/packages/junior/src/chat/agent/tools.ts index 586a962744..79676c5daa 100644 --- a/packages/junior/src/chat/agent/tools.ts +++ b/packages/junior/src/chat/agent/tools.ts @@ -95,7 +95,7 @@ interface ToolWiringArgs { invokedSkill: SkillMetadata | null; onEvent?: (event: AgentEvent) => void | Promise; onFatalToolError(error: Error): void; - onSandboxRefChanged: (sandboxRef: SandboxRef | null | undefined) => void; + onSandboxRefChanged: (sandboxRef: SandboxRef) => void; preAgentPromptMessages: () => PiMessage[]; recordConnectedMcpProvider: (provider: string) => Promise; requestHandoff?: ToolRuntimeContext["handoff"]; @@ -240,7 +240,7 @@ export async function wireAgentTools( getActiveSkill: () => args.skillSandbox.getActiveSkill(), prepareSandbox: pluginHooks.prepareSandbox, prepareWorkspace: async (sandbox, recipe, signal) => - await pluginHooks.prepareWorkspace?.(sandbox, recipe.repos, signal), + await pluginHooks.prepareWorkspace(sandbox, recipe.repos, signal), onSandboxRefChanged: args.onSandboxRefChanged, persistSandboxRef: args.durability.onSandboxRefChanged, }); diff --git a/packages/junior/src/chat/agent/types.ts b/packages/junior/src/chat/agent/types.ts index 85b6047ee9..81cd17b59b 100644 --- a/packages/junior/src/chat/agent/types.ts +++ b/packages/junior/src/chat/agent/types.ts @@ -132,7 +132,9 @@ export type AgentRunState = { * The runner must commit the preceding agent boundary before invoking this * port; the accepted reply transaction appends only this message. */ -export type AgentDelivery = (message: AssistantMessage) => void | Promise; +export type AgentDelivery = ( + message: AssistantMessage, +) => void | Promise; /** Resume the agent turn after a transient or ambiguous delivery failure. */ export class RetryableDeliveryError extends Error { @@ -301,9 +303,7 @@ export function assertRunConsistency( switch (source.platform) { case "slack": { if (destination.platform !== "slack") { - throw new TypeError( - "Run source and destination platforms do not match", - ); + throw new TypeError("Run source and destination platforms do not match"); } if (source.teamId !== destination.teamId) { throw new TypeError("Slack source and destination teams do not match"); @@ -312,9 +312,7 @@ export function assertRunConsistency( } case "local": { if (destination.platform !== "local") { - throw new TypeError( - "Run source and destination platforms do not match", - ); + throw new TypeError("Run source and destination platforms do not match"); } if (source.conversationId !== destination.conversationId) { throw new TypeError( diff --git a/packages/junior/src/chat/api-turns/work.ts b/packages/junior/src/chat/api-turns/work.ts index 5b2828ed46..c616963b7c 100644 --- a/packages/junior/src/chat/api-turns/work.ts +++ b/packages/junior/src/chat/api-turns/work.ts @@ -621,7 +621,7 @@ export function createApiTurnWorker(options: { conversation, conversationId: context.conversationId, }); - let sandboxRef: SandboxRef | null | undefined = + let sandboxRef: SandboxRef | undefined = getPersistedSandboxState(persisted); const initialSandboxRef = sandboxRef; @@ -787,7 +787,7 @@ export function createApiTurnWorker(options: { }), state: { pendingAuth: conversation.processing.pendingAuth, - sandboxRef: sandboxRef ?? undefined, + sandboxRef, }, delivery: deliverAssistantMessage, durability: { @@ -849,8 +849,7 @@ export function createApiTurnWorker(options: { }); await persistThreadStateById(context.conversationId, { conversation: completedState.conversation, - sandboxRef: - reply.sandboxRef !== undefined ? reply.sandboxRef : sandboxRef, + sandboxRef: reply.sandboxRef ?? sandboxRef, }); if (reply.piMessages?.length) { // Prefer the live checkpoint slice after yield/resume; first diff --git a/packages/junior/src/chat/local/runner.ts b/packages/junior/src/chat/local/runner.ts index 396bbc1df2..5c9ca139a8 100644 --- a/packages/junior/src/chat/local/runner.ts +++ b/packages/junior/src/chat/local/runner.ts @@ -62,7 +62,6 @@ import { type OAuthAuthorization, } from "@/chat/oauth-authorization"; import type { SandboxEgressSignalTransport } from "@/chat/sandbox/egress/signals"; -import type { SandboxRef } from "@/chat/sandbox/ref"; const SENTRY_EVENT_ID_PATTERN = /^[a-f0-9]{32}$/i; @@ -216,8 +215,7 @@ async function runLocalAgentTurnInContext( conversation, conversationId: input.conversationId, }); - let sandboxRef: SandboxRef | null | undefined = - getPersistedSandboxState(persisted); + let sandboxRef = getPersistedSandboxState(persisted); const initialSandboxRef = sandboxRef; const turnId = localTurnId(); @@ -352,8 +350,7 @@ async function runLocalAgentTurnInContext( }, state: { pendingAuth: conversation.processing.pendingAuth, - // Agent run state only tracks a live/absent ref, not an explicit clear. - sandboxRef: sandboxRef ?? undefined, + sandboxRef, }, onEvent: async (event) => { if (event.type === "status") { @@ -374,7 +371,6 @@ async function runLocalAgentTurnInContext( delivery: deliverAssistantMessage, durability: { onSandboxRefChanged: async (nextSandboxRef) => { - // Keep null so a failed inline persist still clears on final write. sandboxRef = nextSandboxRef; await persistThreadStateById(input.conversationId, { conversation, @@ -508,8 +504,7 @@ async function runLocalAgentTurnInContext( try { await persistThreadStateById(input.conversationId, { conversation: completedState.conversation, - sandboxRef: - reply.sandboxRef !== undefined ? reply.sandboxRef : sandboxRef, + sandboxRef: reply.sandboxRef ?? sandboxRef, }); if (reply.piMessages?.length) { // Destination acceptance is the completion boundary: this first commits diff --git a/packages/junior/src/chat/plugins/agent-hooks.ts b/packages/junior/src/chat/plugins/agent-hooks.ts index bcf527e068..1d9025cce9 100644 --- a/packages/junior/src/chat/plugins/agent-hooks.ts +++ b/packages/junior/src/chat/plugins/agent-hooks.ts @@ -93,7 +93,7 @@ export interface PluginHookRunner { afterMcpTool(input: AfterMcpToolHookInput): Promise; beforeToolExecute(input: ToolHookInput): Promise; prepareSandbox(workspace: SandboxWorkspace): Promise; - prepareWorkspace?( + prepareWorkspace( workspace: SandboxWorkspace, repos: Array<{ provider: string; diff --git a/packages/junior/src/chat/sandbox/README.md b/packages/junior/src/chat/sandbox/README.md index 9b34cfb6fd..935ce84abf 100644 --- a/packages/junior/src/chat/sandbox/README.md +++ b/packages/junior/src/chat/sandbox/README.md @@ -19,9 +19,10 @@ traffic through verified host egress. to this module. - An unavailable session fails the current operation without replay, retains its sandbox identifier, and reacquires a session only on a later operation. -- New or replacement references are persisted before session preparation can - perform further asynchronous work. Reacquiring a VM session for the same - reference does not rewrite durable state. +- New base Sandbox references are persisted before session preparation can + perform further asynchronous work. A Workspace switch persists its prepared + candidate before replacing the live Sandbox. Reacquiring a VM session for + the same reference does not rewrite durable state. - Agent runs do not stop sandboxes when they finish. Explicit temporary owners, such as dependency snapshot creation, own their own stop lifecycle. - Do not treat the sandbox filesystem as product storage. diff --git a/packages/junior/src/chat/sandbox/prepare-workspace.ts b/packages/junior/src/chat/sandbox/prepare-workspace.ts new file mode 100644 index 0000000000..abd3a27b94 --- /dev/null +++ b/packages/junior/src/chat/sandbox/prepare-workspace.ts @@ -0,0 +1,46 @@ +import { + SANDBOX_REPOS_ROOT, + SANDBOX_WORKSPACE_ROOT, +} from "@/chat/sandbox/paths"; +import type { SandboxSession } from "@/chat/sandbox/workspace"; +import type { Workspace } from "@/chat/workspaces/types"; + +/** Prepare repositories and setup state before a Workspace snapshot is captured. */ +export async function prepareWorkspaceSnapshot(params: { + sandbox: SandboxSession; + workspace: Workspace; + signal?: AbortSignal; + applyNetworkPolicy(sandbox: SandboxSession): Promise; + prepareRepositories?( + sandbox: SandboxSession, + workspace: Workspace, + signal?: AbortSignal, + ): Promise; + removeCredentialRoute: boolean; +}): Promise { + const { sandbox, workspace, signal } = params; + signal?.throwIfAborted(); + await params.applyNetworkPolicy(sandbox); + await params.prepareRepositories?.(sandbox, workspace, signal); + // Provider preparation uses credential egress. Remove that route before the + // app-owned setup script runs and before the snapshot is captured. + if (params.removeCredentialRoute) { + await sandbox.update({ networkPolicy: "allow-all" }); + } + if (!workspace.setupScript.trim()) return; + const result = await sandbox.runCommand({ + cmd: "bash", + args: ["-euo", "pipefail", "-c", workspace.setupScript], + cwd: SANDBOX_WORKSPACE_ROOT, + env: { + JUNIOR_REPOS_ROOT: SANDBOX_REPOS_ROOT, + JUNIOR_WORKSPACE_ROOT: SANDBOX_WORKSPACE_ROOT, + }, + signal, + }); + if (result.exitCode !== 0) { + throw new Error( + `Workspace setup failed: ${result.stderr.trim() || `exit ${result.exitCode}`}`, + ); + } +} diff --git a/packages/junior/src/chat/sandbox/session.ts b/packages/junior/src/chat/sandbox/session.ts index 59ceacaea6..df4d3b9c06 100644 --- a/packages/junior/src/chat/sandbox/session.ts +++ b/packages/junior/src/chat/sandbox/session.ts @@ -17,6 +17,7 @@ import { wrapSandboxSetupError, } from "@/chat/sandbox/errors"; import { buildNonInteractiveShellScript } from "@/chat/sandbox/noninteractive-command"; +import { prepareWorkspaceSnapshot } from "@/chat/sandbox/prepare-workspace"; import { getSandboxResources } from "@/chat/sandbox/resources"; import { hash as profileHash } from "@/chat/sandbox/snapshot/profile"; import { @@ -36,7 +37,7 @@ 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_REPOS_ROOT, SANDBOX_WORKSPACE_ROOT } from "@/chat/sandbox/paths"; +import { SANDBOX_WORKSPACE_ROOT } from "@/chat/sandbox/paths"; const DEFAULT_MAX_OUTPUT_LENGTH = 30_000; const DEFAULT_BASH_COMMAND_TIMEOUT_MS = 5 * 60 * 1000; @@ -399,6 +400,7 @@ export function createSandboxRuntime( sandboxName: string; signal?: AbortSignal; workspace?: Workspace; + prepareWorkspace?: (sandbox: SandboxSession) => Promise; }): Promise => { const { runtime, @@ -407,6 +409,7 @@ export function createSandboxRuntime( sandboxName, signal, workspace, + prepareWorkspace, } = params; signal?.throwIfAborted(); @@ -449,10 +452,7 @@ export function createSandboxRuntime( staleSnapshotId: snapshot.snapshotId, signal, workspace, - prepareWorkspace: workspace - ? async (sandbox) => - await prepareWorkspaceSnapshot(sandbox, workspace, signal) - : undefined, + prepareWorkspace, }); if (!rebuiltSnapshot.snapshotId) { throw error; @@ -468,37 +468,6 @@ export function createSandboxRuntime( } }; - const prepareWorkspaceSnapshot = async ( - sandbox: SandboxSession, - workspace: Workspace, - signal?: AbortSignal, - ): Promise => { - signal?.throwIfAborted(); - await applyNetworkPolicy(sandbox); - await options.onWorkspacePrepare?.(sandbox, workspace, signal); - // The provider hook is trusted and runs through credential egress. Remove - // that route before the app-owned setup script runs and before capture. - if (options.createNetworkPolicy) { - await sandbox.update({ networkPolicy: "allow-all" }); - } - if (!workspace.setupScript.trim()) return; - const result = await sandbox.runCommand({ - cmd: "bash", - args: ["-euo", "pipefail", "-c", workspace.setupScript], - cwd: SANDBOX_WORKSPACE_ROOT, - env: { - JUNIOR_REPOS_ROOT: SANDBOX_REPOS_ROOT, - JUNIOR_WORKSPACE_ROOT: SANDBOX_WORKSPACE_ROOT, - }, - signal, - }); - if (result.exitCode !== 0) { - throw new Error( - `Workspace setup failed: ${result.stderr.trim() || `exit ${result.exitCode}`}`, - ); - } - }; - const createSandboxCandidate = async ( workspace: Workspace | undefined, hash: string | undefined, @@ -507,6 +476,17 @@ export function createSandboxRuntime( const runtime = SANDBOX_RUNTIME; const sandboxCredentials = getVercelSandboxCredentials(); const sandboxName = createSandboxName(); + const prepareWorkspace = workspace + ? async (sandbox: SandboxSession) => + await prepareWorkspaceSnapshot({ + sandbox, + workspace, + signal, + applyNetworkPolicy, + prepareRepositories: options.onWorkspacePrepare, + removeCredentialRoute: Boolean(options.createNetworkPolicy), + }) + : undefined; let createdSandbox: SandboxSession; try { @@ -524,10 +504,7 @@ export function createSandboxRuntime( timeoutMs, signal, workspace, - prepareWorkspace: workspace - ? async (sandbox) => - await prepareWorkspaceSnapshot(sandbox, workspace, signal) - : undefined, + prepareWorkspace, }); signal?.throwIfAborted(); setSnapshotAttributes(snapshot); @@ -538,6 +515,7 @@ export function createSandboxRuntime( sandboxName, signal, workspace, + prepareWorkspace, }); }, ); @@ -545,12 +523,23 @@ export function createSandboxRuntime( return failSetup(error); } + const ref = sandboxReference(createdSandbox, workspace, hash); + if (!workspace) { + try { + await persistSandboxRef(ref); + } catch (error) { + await stopSession(createdSandbox); + throw error; + } + } + let networkPolicyKey: string | undefined; try { networkPolicyKey = await applyNetworkPolicy(createdSandbox); await prepareSandbox(createdSandbox); } catch (error) { - await stopSession(createdSandbox); + // A Workspace candidate has no durable owner until preparation succeeds. + if (workspace) await stopSession(createdSandbox); return failSetup(error); } @@ -558,7 +547,7 @@ export function createSandboxRuntime( session: createdSandbox, networkPolicyKey, profileHash: hash, - ref: sandboxReference(createdSandbox, workspace, hash), + ref, workspace, }; }; diff --git a/packages/junior/src/chat/services/turn-result.ts b/packages/junior/src/chat/services/turn-result.ts index b1a92816a6..d753f43494 100644 --- a/packages/junior/src/chat/services/turn-result.ts +++ b/packages/junior/src/chat/services/turn-result.ts @@ -38,8 +38,7 @@ export interface AgentTurnDiagnostics { export interface AgentRunResult { /** Sanitized terminal text for diagnostics and failure fallback, not success delivery. */ text: string; - /** Latest sandbox ref; null means the durable reference was cleared. */ - sandboxRef?: SandboxRef | null; + sandboxRef?: SandboxRef; piMessages?: PiMessage[]; diagnostics: AgentTurnDiagnostics; } @@ -48,8 +47,7 @@ export interface TurnResultInput { newMessages: unknown[]; userInput: string; toolCalls: string[]; - /** Latest sandbox ref; null means the durable reference was cleared. */ - sandboxRef?: SandboxRef | null; + sandboxRef?: SandboxRef; piMessages?: PiMessage[]; durationMs?: number; generatedFileCount: number; diff --git a/packages/junior/src/chat/workspaces/tools.ts b/packages/junior/src/chat/workspaces/tools.ts index 5799dc74e1..f28f279429 100644 --- a/packages/junior/src/chat/workspaces/tools.ts +++ b/packages/junior/src/chat/workspaces/tools.ts @@ -7,6 +7,7 @@ import type { ToolRegistry } from "@/chat/tools/definition"; import type { ToolRuntimeContext } from "@/chat/tools/types"; import { workspaceRepoCheckoutPath } from "./checkout-path"; import { getWorkspaceByName, listWorkspaces } from "./store"; +import type { Workspace } from "./types"; const repoSchema = z.object({ provider: z.string(), @@ -20,7 +21,7 @@ const workspaceSchema = z.object({ repos: z.array(repoSchema), }); -function view(workspace: Awaited>[number]) { +function view(workspace: Workspace) { return { id: workspace.id, name: workspace.name, diff --git a/packages/junior/src/chat/workspaces/types.ts b/packages/junior/src/chat/workspaces/types.ts index 6eaf757ed0..4f98ff0beb 100644 --- a/packages/junior/src/chat/workspaces/types.ts +++ b/packages/junior/src/chat/workspaces/types.ts @@ -1,3 +1,4 @@ +/** Provider-owned repository included in one Workspace recipe. */ export interface WorkspaceRepo { provider: string; repo: string; diff --git a/packages/junior/tests/component/misc/sandbox-executor.test.ts b/packages/junior/tests/component/misc/sandbox-executor.test.ts index 17b11b0c63..bd6d654028 100644 --- a/packages/junior/tests/component/misc/sandbox-executor.test.ts +++ b/packages/junior/tests/component/misc/sandbox-executor.test.ts @@ -197,7 +197,6 @@ function createTestSandboxRuntime(options: SandboxFixtureOptions = {}) { createNetworkPolicy: options.createNetworkPolicy, onSandboxPrepare: options.onSandboxPrepare, onSandboxRefChanged: async (ref) => { - if (!ref) return; await options.onSandboxAcquired?.({ sandboxId: ref.id, ...(ref.profileHash @@ -251,7 +250,6 @@ function createTestSandbox(options: SandboxFixtureOptions = {}) { await options.agentHooks?.prepareSandbox(workspace) : undefined, onSandboxRefChanged: async (ref) => { - if (!ref) return; await options.onSandboxAcquired?.({ sandboxId: ref.id, ...(ref.profileHash @@ -598,7 +596,7 @@ describe("createTestSandbox", () => { expect(executor.getSandboxId()).toBe("sbx_stopped"); }); - it("reports a fresh sandbox reference only after preparation succeeds", async () => { + it("reports a fresh sandbox reference before session preparation can fail", async () => { const unavailable = createClosedStreamError(); const freshSandbox = makeSandbox("sbx_prepare_failure"); const callOrder: string[] = []; @@ -622,9 +620,9 @@ describe("createTestSandbox", () => { ToolInputError, ); - expect(callOrder).toEqual(["prepare"]); - expect(executor.getSandboxId()).toBeUndefined(); - expect(freshSandbox.stop).toHaveBeenCalledTimes(1); + expect(callOrder).toEqual(["reference", "prepare"]); + expect(executor.getSandboxId()).toBe("sbx_prepare_failure"); + expect(freshSandbox.stop).not.toHaveBeenCalled(); }); it("retries durable reference reporting after persistence fails", async () => { diff --git a/packages/junior/tests/component/runtime/thread-state.test.ts b/packages/junior/tests/component/runtime/thread-state.test.ts index 7c8262009b..4139b56838 100644 --- a/packages/junior/tests/component/runtime/thread-state.test.ts +++ b/packages/junior/tests/component/runtime/thread-state.test.ts @@ -58,35 +58,6 @@ describe("thread sandbox state", () => { expect(getPersistedSandboxState(state)).toBeUndefined(); }); - it("final fallback still clears when null is preserved after failed inline write", async () => { - // Mirrors durability adapters: keep null in the local variable so a later - // persist can clear even if an earlier onSandboxRefChanged write failed. - const conversationId = "local:test:thread-sandbox-null-fallback"; - await persistThreadStateById(conversationId, { - sandboxRef: { id: "sandbox-stale", profileHash: "profile-stale" }, - }); - - let sandboxRef: { id: string; profileHash?: string } | null | undefined = { - id: "sandbox-stale", - profileHash: "profile-stale", - }; - const resultSandboxRef: { id: string } | null | undefined = null; - - // Inline clear signal collapses only when coerced with ?? undefined. - sandboxRef = null; - await persistThreadStateById(conversationId, { - sandboxRef: - resultSandboxRef !== undefined ? resultSandboxRef : sandboxRef, - }); - - const state = await getPersistedThreadState(conversationId); - expect(getPersistedSandboxState(state)).toBeUndefined(); - expect(state).toMatchObject({ - app_sandbox_id: "", - app_sandbox_dependency_profile_hash: "", - }); - }); - it("writes thread scratch with Junior's 7-day TTL", async () => { const stateAdapter = getStateAdapter(); const set = vi.spyOn(stateAdapter, "set"); diff --git a/packages/junior/tests/component/scheduled-tasks-sql.test.ts b/packages/junior/tests/component/scheduled-tasks-sql.test.ts index a8b38845dd..66e7e2137a 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: 13, + migrated: 14, }); const [migrated] = await fixture.sql.query<{ creatorIdentityId: string | null; diff --git a/packages/junior/tests/component/tool-support/pi-tool-adapter.test.ts b/packages/junior/tests/component/tool-support/pi-tool-adapter.test.ts index 003dfc1534..c90440e80e 100644 --- a/packages/junior/tests/component/tool-support/pi-tool-adapter.test.ts +++ b/packages/junior/tests/component/tool-support/pi-tool-adapter.test.ts @@ -434,6 +434,7 @@ describe("Pi tool adapter", () => { env: { SECRET_TOKEN: "must-not-reach-guardian" }, })), prepareSandbox: vi.fn(), + prepareWorkspace: vi.fn(), } as PluginHookRunner; const [demoTool] = createPiAgentTools( { diff --git a/packages/junior/tests/unit/plugins/agent-hooks.test.ts b/packages/junior/tests/unit/plugins/agent-hooks.test.ts index 9df26ac181..0fa93cbdd0 100644 --- a/packages/junior/tests/unit/plugins/agent-hooks.test.ts +++ b/packages/junior/tests/unit/plugins/agent-hooks.test.ts @@ -1720,7 +1720,7 @@ describe("agent plugin hooks", () => { runCommand, }; - await createPluginHookRunner().prepareWorkspace?.( + await createPluginHookRunner().prepareWorkspace( sandbox, [ { @@ -1762,7 +1762,7 @@ describe("agent plugin hooks", () => { ]); try { await expect( - createPluginHookRunner().prepareWorkspace?.(fakeSandbox([]), [ + createPluginHookRunner().prepareWorkspace(fakeSandbox([]), [ { provider: "agent-demo", repo: "example/demo", @@ -1796,7 +1796,7 @@ describe("agent plugin hooks", () => { ]); try { await expect( - createPluginHookRunner().prepareWorkspace?.(fakeSandbox([]), [ + createPluginHookRunner().prepareWorkspace(fakeSandbox([]), [ { provider: "github", repo: "getsentry/sentry", diff --git a/packages/junior/tests/unit/sandbox/snapshot/profile.test.ts b/packages/junior/tests/unit/sandbox/snapshot/profile.test.ts index a54ee57dd0..24ee15b33a 100644 --- a/packages/junior/tests/unit/sandbox/snapshot/profile.test.ts +++ b/packages/junior/tests/unit/sandbox/snapshot/profile.test.ts @@ -138,7 +138,6 @@ describe("snapshot dependency profile", () => { }); it("keeps workspace profile hashes stable without localeCompare", () => { - const updatedAt = new Date("2026-03-10T00:00:00.000Z"); // Code-point order differs from some locales for mixed case / symbols. const reposA = [ { @@ -157,37 +156,31 @@ describe("snapshot dependency profile", () => { id: "workspace-1", name: "sentry", setupScript: "pnpm install", - updatedAt, repos: reposA, }); const second = create("node22", { id: "workspace-1", name: "sentry", setupScript: "pnpm install", - updatedAt, repos: reposB, }); 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, }, ], @@ -196,18 +189,15 @@ describe("snapshot dependency profile", () => { 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, }, ], From b195514cccf44b3d31544d0b40509d53d378bde9 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 06:55:34 +0000 Subject: [PATCH 34/34] fix(workspaces): omit unset workspace from base snapshot hashes Always including `workspace: null` changed every base dependency profile hash and would bust cached snapshots on deploy. Only include the workspace recipe when a workspace is selected. --- .../src/chat/sandbox/snapshot/profile.ts | 3 +- .../unit/sandbox/snapshot/profile.test.ts | 38 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/packages/junior/src/chat/sandbox/snapshot/profile.ts b/packages/junior/src/chat/sandbox/snapshot/profile.ts index 348e100882..a4369503a1 100644 --- a/packages/junior/src/chat/sandbox/snapshot/profile.ts +++ b/packages/junior/src/chat/sandbox/snapshot/profile.ts @@ -124,6 +124,7 @@ export function create(runtime: string, workspace?: Workspace): Profile | null { dependencies.some((dependency) => isFloating(dependency)) || pluginPostinstall.length > 0 || Boolean(workspace); + // Omit workspace when unset so base profiles keep pre-workspace hashes. const hash = createHash("sha256") .update( JSON.stringify({ @@ -132,7 +133,7 @@ export function create(runtime: string, workspace?: Workspace): Profile | null { rebuildEpoch, dependencies, postinstall, - workspace: workspace ? workspaceRecipe(workspace) : null, + ...(workspace ? { workspace: workspaceRecipe(workspace) } : {}), }), ) .digest("hex"); diff --git a/packages/junior/tests/unit/sandbox/snapshot/profile.test.ts b/packages/junior/tests/unit/sandbox/snapshot/profile.test.ts index 24ee15b33a..a41c7f5ae9 100644 --- a/packages/junior/tests/unit/sandbox/snapshot/profile.test.ts +++ b/packages/junior/tests/unit/sandbox/snapshot/profile.test.ts @@ -241,6 +241,44 @@ describe("snapshot dependency profile", () => { expect(first?.hash).not.toBe(second?.hash); }); + it("omits workspace from base profile hashes when unset", async () => { + const { createHash } = await import("node:crypto"); + dependenciesMock.mockReturnValue([ + { type: "npm", package: "example", version: "1.2.3" }, + ]); + + const profile = create("node22"); + const expected = createHash("sha256") + .update( + JSON.stringify({ + version: 1, + runtime: "node22", + rebuildEpoch: "", + dependencies: [{ type: "npm", package: "example", version: "1.2.3" }], + postinstall: [], + }), + ) + .digest("hex"); + + expect(profile?.hash).toBe(expected); + expect(profile?.hash).not.toBe( + createHash("sha256") + .update( + JSON.stringify({ + version: 1, + runtime: "node22", + rebuildEpoch: "", + dependencies: [ + { type: "npm", package: "example", version: "1.2.3" }, + ], + postinstall: [], + workspace: null, + }), + ) + .digest("hex"), + ); + }); + it("rejects conflicting npm versions", () => { dependenciesMock.mockReturnValue([ { type: "npm", package: "example", version: "1.2.3" },