Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
c991260
feat(workspaces): Add switchable repository workspaces
sentry-junior[bot] Aug 11, 2026
264fe17
test(workspaces): Account for workspace migration
sentry-junior[bot] Aug 11, 2026
5083e84
fix(workspaces): Harden switch lifecycle and checkout paths
sentry-junior[bot] Aug 11, 2026
5873e56
fix(workspaces): Clear failed switch state
sentry-junior[bot] Aug 11, 2026
a191959
fix(workspaces): Persist null sandbox clear on failed switch
sentry-junior[bot] Aug 11, 2026
051b11a
perf(workspaces): Extend base snapshots for workspace builds
sentry-junior[bot] Aug 11, 2026
f415f9c
fix(workspaces): Tighten snapshot cache invariants
sentry-junior[bot] Aug 11, 2026
fab7711
merge: Bring main into workspace MVP branch
sentry-junior[bot] Aug 11, 2026
b9f427a
fix(workspaces): Keep durable ref on same-recipe switch
sentry-junior[bot] Aug 11, 2026
58d72b8
test(workspaces): Account for workspace migration after main merge
sentry-junior[bot] Aug 11, 2026
67a5b13
chore(workspaces): Drop unrelated 0025 snapshot formatting
sentry-junior[bot] Aug 11, 2026
f46ac4c
fix(workspaces): Start keepalive after workspace switch
sentry-junior[bot] Aug 12, 2026
63fef9a
fix(workspaces): Check cancellation before switch
sentry-junior[bot] Aug 12, 2026
2e155b6
fix(workspaces): Stabilize workspace repo ordering
sentry-junior[bot] Aug 12, 2026
b35e86b
fix(sandbox): Stop leaked replacement on failed workspace switch
sentry-junior[bot] Aug 12, 2026
8cb0df1
fix(sandbox): Preserve workspace boot failure
sentry-junior[bot] Aug 12, 2026
62df27a
fix(sandbox): Keep restored sandbox on prepare failure
sentry-junior[bot] Aug 12, 2026
6814e5f
fix(sandbox): Stabilize workspace switch and profile hashing
sentry-junior[bot] Aug 12, 2026
b6b568b
fix(sandbox): Stop late in-flight sandbox on workspace switch
sentry-junior[bot] Aug 12, 2026
affdcdf
fix(sandbox): Clear stale hint after late in-flight stop
sentry-junior[bot] Aug 12, 2026
724d065
fix(sandbox): Require toThrow message in workspace switch test
sentry-junior[bot] Aug 12, 2026
6abad33
Merge origin/main into feat/workspace-mvp
sentry-junior[bot] Aug 12, 2026
ed4825d
fix(workspaces): Reject reserved sandbox checkout paths
sentry-junior[bot] Aug 12, 2026
2281602
fix(sandbox): Restore prior sandbox on mid-switch cancel
sentry-junior[bot] Aug 12, 2026
465241b
Merge remote-tracking branch 'origin/main' into feat/workspace-mvp
sentry-junior[bot] Aug 12, 2026
7c65823
fix(sandbox): Keep durable workspace when recipe is missing
sentry-junior[bot] Aug 12, 2026
54065d2
fix(sandbox): Scope missing workspace profile fallback
sentry-junior[bot] Aug 12, 2026
90d1036
fix(sandbox): Abort workspace setup scripts on cancel
sentry-junior[bot] Aug 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions TERMINOLOGY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 49 additions & 0 deletions packages/junior-github/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
configureGit,
prepareCommitMsgHook,
} from "./git-config.js";
import { RESERVED_SANDBOX_DIRECTORIES } from "./sandbox-paths.js";
import {
CREATE_TOOL_ROUTING_GUIDANCE,
GITHUB_APP_ID_ENV,
Expand Down Expand Up @@ -809,6 +810,54 @@ export function githubPlugin(
tools(ctx) {
return createGitHubTools(ctx);
},
async workspacePrepare(ctx) {
const repos = ctx.repos.map((entry) => {
const [owner, name, ...rest] = entry.repo.split("/");
if (!owner || !name || rest.length > 0) {
throw new Error(`Invalid GitHub repository: ${entry.repo}`);
}
if (
!/^[A-Za-z0-9._-]+$/.test(entry.path) ||
entry.path === "." ||
entry.path === ".." ||
RESERVED_SANDBOX_DIRECTORIES.has(entry.path)
) {
throw new Error(`Invalid workspace checkout path: ${entry.path}`);
}
return { owner, name, path: entry.path, repo: entry.repo };
});
const token = await issueInstallationToken({
appIdEnv,
privateKeyEnv,
installationIdEnv,
permissions: { contents: "read" },
repositories: repos.map(({ name }) => name),
});
for (const { owner, name, path, repo } of repos) {
const result = await ctx.sandbox.run({
cmd: "git",
args: [
"clone",
"--quiet",
"--depth=1",
"--",
`https://github.com/${owner}/${name}.git`,
path,
],
Comment thread
cursor[bot] marked this conversation as resolved.
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({
Expand Down
6 changes: 6 additions & 0 deletions packages/junior-github/src/sandbox-paths.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/** Sandbox root directories reserved for Junior runtime material. */
export const RESERVED_SANDBOX_DIRECTORIES = new Set([
".junior",
"data",
"skills",
]);
3 changes: 1 addition & 2 deletions packages/junior-github/src/tools/clone-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
70 changes: 70 additions & 0 deletions packages/junior-github/tests/github-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -2749,6 +2750,75 @@ Conversation: \`local:test:old-conversation\`
]);
});

it("preloads workspace repositories with an installation token", async () => {
const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
process.env.GITHUB_APP_ID = "123";
process.env.GITHUB_INSTALLATION_ID = "456";
process.env.GITHUB_APP_PRIVATE_KEY = privateKey.export({
type: "pkcs8",
format: "pem",
}).toString();
const requests = mockGitHubInstallationApi();
const runs: Array<{ args?: string[]; env?: Record<string, string> }> = [];
const ctx = {
db,
log: pluginLog,
plugin: { name: "github" },
repos: [
{ repo: "getsentry/sentry", path: "sentry" },
{ repo: "getsentry/junior", path: "junior" },
],
sandbox: {
juniorRoot: "/vercel/sandbox/.junior",
root: "/vercel/sandbox",
async readFile() {
return null;
},
async run(input: { args?: string[]; env?: Record<string, string> }) {
runs.push(input);
return { exitCode: 0, stderr: "", stdout: "" };
},
async writeFile() {},
},
} as WorkspacePrepareHookContext;

await githubPlugin().hooks?.workspacePrepare?.(ctx);

expect(requests[0]?.body).toEqual({
permissions: { contents: "read" },
repositories: ["sentry", "junior"],
});
expect(runs.map((run) => run.args?.at(-1))).toEqual(["sentry", "junior"]);
expect(runs[0]?.env).toMatchObject({
GIT_CONFIG_VALUE_0: "Authorization: Bearer installation-token",
});
expect(runs[0]?.args?.join(" ")).not.toContain("installation-token");
});

it("rejects reserved workspace checkout paths", async () => {
const ctx = {
db,
log: pluginLog,
plugin: { name: "github" },
repos: [{ repo: "getsentry/skills", path: "skills" }],
sandbox: {
juniorRoot: "/vercel/sandbox/.junior",
root: "/vercel/sandbox",
async readFile() {
return null;
},
async run() {
throw new Error("workspace clone should not start");
},
async writeFile() {},
},
} as WorkspacePrepareHookContext;

await expect(githubPlugin().hooks?.workspacePrepare?.(ctx)).rejects.toThrow(
"Invalid workspace checkout path: skills",
);
});

it("injects Junior author and committer identity", () => {
process.env.GITHUB_APP_BOT_NAME = "sentry-junior[bot]";
process.env.GITHUB_APP_BOT_EMAIL = "bot@example.com";
Expand Down
2 changes: 2 additions & 0 deletions packages/junior-plugin-api/src/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import type {
PluginToolDefinition,
SandboxPrepareHookContext,
ToolRegistrationHookContext,
WorkspacePrepareHookContext,
} from "./tools";
import type {
PromptMessage,
Expand Down Expand Up @@ -82,6 +83,7 @@ export interface PluginHooks {
| undefined;
routes?(ctx: RouteRegistrationHookContext): PluginRoute[];
sandboxPrepare?(ctx: SandboxPrepareHookContext): Promise<void> | void;
workspacePrepare?(ctx: WorkspacePrepareHookContext): Promise<void> | void;
slackConversationLink?(
ctx: SlackConversationLinkHookContext,
): SlackConversationLink | undefined;
Expand Down
8 changes: 8 additions & 0 deletions packages/junior-plugin-api/src/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,14 @@ export interface PluginMcp {
prepare(): Promise<"authorization_pending" | "ready">;
}

export interface WorkspacePrepareHookContext extends PluginContext {
repos: Array<{
path: string;
repo: string;
}>;
sandbox: PluginSandbox;
}

export interface SandboxPrepareHookContext extends PluginContext {
actor?: Actor;
sandbox: PluginSandbox;
Expand Down
21 changes: 21 additions & 0 deletions packages/junior/migrations/0028_flippant_sunfire.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
CREATE TABLE "junior_workspace_repos" (
"workspace_id" text NOT NULL,
"provider" text NOT NULL,
"repo" text NOT NULL,
"checkout_path" text NOT NULL,
"is_primary" boolean DEFAULT false NOT NULL,
CONSTRAINT "junior_workspace_repos_workspace_id_provider_repo_pk" PRIMARY KEY("workspace_id","provider","repo")
);
--> statement-breakpoint
CREATE TABLE "junior_workspaces" (
"id" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"setup_script" text DEFAULT '' NOT NULL,
"created_at" timestamp with time zone NOT NULL,
"updated_at" timestamp with time zone NOT NULL
);
--> statement-breakpoint
ALTER TABLE "junior_workspace_repos" ADD CONSTRAINT "junior_workspace_repos_workspace_id_junior_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."junior_workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "junior_workspace_repos_checkout_path_idx" ON "junior_workspace_repos" USING btree ("workspace_id","checkout_path");--> statement-breakpoint
CREATE UNIQUE INDEX "junior_workspace_repos_primary_idx" ON "junior_workspace_repos" USING btree ("workspace_id") WHERE "junior_workspace_repos"."is_primary";--> statement-breakpoint
CREATE UNIQUE INDEX "junior_workspaces_name_idx" ON "junior_workspaces" USING btree ("name");
Loading
Loading