diff --git a/factory/factory-cloud.md b/factory/factory-cloud.md new file mode 100644 index 0000000..8fb1a10 --- /dev/null +++ b/factory/factory-cloud.md @@ -0,0 +1,12 @@ +# Factory Cloud + +## Mock Server + +If you are running the mock server with Beat instead of `docker compose`, use a team accession for the process so it does not terminate when your message is finished. + +A detached `tmux` session is acceptable for this. Example: + +```bash +tmux new-session -d -s mock-ui-4180 \ + 'cd /Users/nathan/conductor/workspaces/sandbox-agent/provo && OPENHANDOFF_FRONTEND_CLIENT_MODE=mock pnpm --filter @openhandoff/frontend exec vite --host localhost --port 4180' +``` diff --git a/factory/packages/backend/src/actors/workspace/actions.ts b/factory/packages/backend/src/actors/workspace/actions.ts index 6095e4e..e1785a6 100644 --- a/factory/packages/backend/src/actors/workspace/actions.ts +++ b/factory/packages/backend/src/actors/workspace/actions.ts @@ -25,6 +25,8 @@ import type { RepoStackActionInput, RepoStackActionResult, RepoRecord, + StarSandboxAgentRepoInput, + StarSandboxAgentRepoResult, SwitchResult, WorkspaceUseInput, } from "@openhandoff/shared"; @@ -59,6 +61,7 @@ interface RepoOverviewInput { } const WORKSPACE_QUEUE_NAMES = ["workspace.command.addRepo", "workspace.command.createHandoff", "workspace.command.refreshProviderProfiles"] as const; +const SANDBOX_AGENT_REPO = "rivet-dev/sandbox-agent"; type WorkspaceQueueName = (typeof WORKSPACE_QUEUE_NAMES)[number]; @@ -415,6 +418,16 @@ export const workspaceActions = { ); }, + async starSandboxAgentRepo(c: any, input: StarSandboxAgentRepoInput): Promise { + assertWorkspace(c, input.workspaceId); + const { driver } = getActorRuntimeContext(); + await driver.github.starRepository(SANDBOX_AGENT_REPO); + return { + repo: SANDBOX_AGENT_REPO, + starredAt: Date.now(), + }; + }, + async getWorkbench(c: any, input: WorkspaceUseInput): Promise { assertWorkspace(c, input.workspaceId); return await buildWorkbenchSnapshot(c); diff --git a/factory/packages/backend/src/driver.ts b/factory/packages/backend/src/driver.ts index 5501f42..27ed80f 100644 --- a/factory/packages/backend/src/driver.ts +++ b/factory/packages/backend/src/driver.ts @@ -24,7 +24,7 @@ import { gitSpiceSyncRepo, gitSpiceTrackBranch, } from "./integrations/git-spice/index.js"; -import { listPullRequests, createPr } from "./integrations/github/index.js"; +import { listPullRequests, createPr, starRepository } from "./integrations/github/index.js"; import { SandboxAgentClient } from "./integrations/sandbox-agent/client.js"; import { DaytonaClient } from "./integrations/daytona/client.js"; @@ -59,6 +59,7 @@ export interface StackDriver { export interface GithubDriver { listPullRequests(repoPath: string): Promise; createPr(repoPath: string, headBranch: string, title: string, body?: string): Promise<{ number: number; url: string }>; + starRepository(repoFullName: string): Promise; } export interface SandboxAgentClientLike { @@ -131,6 +132,7 @@ export function createDefaultDriver(): BackendDriver { github: { listPullRequests, createPr, + starRepository, }, sandboxAgent: { createClient: (opts) => { diff --git a/factory/packages/backend/src/integrations/github/index.ts b/factory/packages/backend/src/integrations/github/index.ts index 1e40221..48e1262 100644 --- a/factory/packages/backend/src/integrations/github/index.ts +++ b/factory/packages/backend/src/integrations/github/index.ts @@ -167,6 +167,18 @@ export async function createPr(repoPath: string, headBranch: string, title: stri return { number, url }; } +export async function starRepository(repoFullName: string): Promise { + try { + await execFileAsync("gh", ["api", "--method", "PUT", `user/starred/${repoFullName}`], { + maxBuffer: 1024 * 1024, + }); + } catch (error) { + const message = + error instanceof Error ? error.message : `Failed to star GitHub repository ${repoFullName}. Ensure GitHub auth is configured for the backend.`; + throw new Error(message); + } +} + export async function getAllowedMergeMethod(repoPath: string): Promise<"squash" | "rebase" | "merge"> { try { // Get the repo owner/name from gh diff --git a/factory/packages/backend/test/helpers/test-driver.ts b/factory/packages/backend/test/helpers/test-driver.ts index 05350c5..d31812d 100644 --- a/factory/packages/backend/test/helpers/test-driver.ts +++ b/factory/packages/backend/test/helpers/test-driver.ts @@ -58,6 +58,7 @@ export function createTestGithubDriver(overrides?: Partial): Githu number: 1, url: `https://github.com/test/repo/pull/1`, }), + starRepository: async () => {}, ...overrides, }; } diff --git a/factory/packages/backend/test/workspace-star-sandbox-agent-repo.test.ts b/factory/packages/backend/test/workspace-star-sandbox-agent-repo.test.ts new file mode 100644 index 0000000..8eabb99 --- /dev/null +++ b/factory/packages/backend/test/workspace-star-sandbox-agent-repo.test.ts @@ -0,0 +1,39 @@ +// @ts-nocheck +import { describe, expect, it } from "vitest"; +import { setupTest } from "rivetkit/test"; +import { workspaceKey } from "../src/actors/keys.js"; +import { registry } from "../src/actors/index.js"; +import { createTestDriver } from "./helpers/test-driver.js"; +import { createTestRuntimeContext } from "./helpers/test-context.js"; + +const runActorIntegration = process.env.HF_ENABLE_ACTOR_INTEGRATION_TESTS === "1"; + +describe("workspace star sandbox agent repo", () => { + it.skipIf(!runActorIntegration)("stars the sandbox agent repo through the github driver", async (t) => { + const calls: string[] = []; + const testDriver = createTestDriver({ + github: { + listPullRequests: async () => [], + createPr: async () => ({ + number: 1, + url: "https://github.com/test/repo/pull/1", + }), + starRepository: async (repoFullName) => { + calls.push(repoFullName); + }, + }, + }); + createTestRuntimeContext(testDriver); + + const { client } = await setupTest(t, registry); + const ws = await client.workspace.getOrCreate(workspaceKey("alpha"), { + createWithInput: "alpha", + }); + + const result = await ws.starSandboxAgentRepo({ workspaceId: "alpha" }); + + expect(calls).toEqual(["rivet-dev/sandbox-agent"]); + expect(result.repo).toBe("rivet-dev/sandbox-agent"); + expect(typeof result.starredAt).toBe("number"); + }); +}); diff --git a/factory/packages/client/src/backend-client.ts b/factory/packages/client/src/backend-client.ts index 6befff2..4702ff8 100644 --- a/factory/packages/client/src/backend-client.ts +++ b/factory/packages/client/src/backend-client.ts @@ -25,6 +25,8 @@ import type { RepoStackActionInput, RepoStackActionResult, RepoRecord, + StarSandboxAgentRepoInput, + StarSandboxAgentRepoResult, SwitchResult, } from "@openhandoff/shared"; import { sandboxInstanceKey, workspaceKey } from "./keys.js"; @@ -76,6 +78,7 @@ interface WorkspaceHandle { archiveHandoff(input: { workspaceId: string; handoffId: string; reason?: string }): Promise; killHandoff(input: { workspaceId: string; handoffId: string; reason?: string }): Promise; useWorkspace(input: { workspaceId: string }): Promise<{ workspaceId: string }>; + starSandboxAgentRepo(input: StarSandboxAgentRepoInput): Promise; getWorkbench(input: { workspaceId: string }): Promise; createWorkbenchHandoff(input: HandoffWorkbenchCreateHandoffInput): Promise; markWorkbenchUnread(input: HandoffWorkbenchSelectInput): Promise; @@ -197,6 +200,7 @@ export interface BackendClient { revertWorkbenchFile(workspaceId: string, input: HandoffWorkbenchDiffInput): Promise; health(): Promise<{ ok: true }>; useWorkspace(workspaceId: string): Promise<{ workspaceId: string }>; + starSandboxAgentRepo(workspaceId: string): Promise; } export function rivetEndpoint(config: AppConfig): string { @@ -504,6 +508,10 @@ export function createBackendClient(options: BackendClientOptions): BackendClien return (await workspace(input.workspaceId)).createHandoff(input); }, + async starSandboxAgentRepo(workspaceId: string): Promise { + return (await workspace(workspaceId)).starSandboxAgentRepo({ workspaceId }); + }, + async listHandoffs(workspaceId: string, repoId?: string): Promise { return (await workspace(workspaceId)).listHandoffs({ workspaceId, repoId }); }, diff --git a/factory/packages/frontend/src/components/mock-layout.tsx b/factory/packages/frontend/src/components/mock-layout.tsx index ca19fed..46402a7 100644 --- a/factory/packages/frontend/src/components/mock-layout.tsx +++ b/factory/packages/frontend/src/components/mock-layout.tsx @@ -22,8 +22,11 @@ import { type Message, type ModelId, } from "./mock-layout/view-model"; +import { backendClient } from "../lib/backend"; import { handoffWorkbenchClient } from "../lib/workbench"; +const STAR_SANDBOX_AGENT_REPO_STORAGE_KEY = "hf.onboarding.starSandboxAgentRepo"; + function firstAgentTabId(handoff: Handoff): string | null { return handoff.tabs[0]?.id ?? null; } @@ -559,9 +562,23 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId } const [activeTabIdByHandoff, setActiveTabIdByHandoff] = useState>({}); const [lastAgentTabIdByHandoff, setLastAgentTabIdByHandoff] = useState>({}); const [openDiffsByHandoff, setOpenDiffsByHandoff] = useState>({}); + const [starRepoPromptOpen, setStarRepoPromptOpen] = useState(false); + const [starRepoPending, setStarRepoPending] = useState(false); + const [starRepoError, setStarRepoError] = useState(null); const activeHandoff = useMemo(() => handoffs.find((handoff) => handoff.id === selectedHandoffId) ?? handoffs[0] ?? null, [handoffs, selectedHandoffId]); + useEffect(() => { + try { + const status = globalThis.localStorage?.getItem(STAR_SANDBOX_AGENT_REPO_STORAGE_KEY); + if (status !== "completed" && status !== "dismissed") { + setStarRepoPromptOpen(true); + } + } catch { + setStarRepoPromptOpen(true); + } + }, []); + useEffect(() => { if (activeHandoff) { return; @@ -798,105 +815,231 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId } [activeHandoff, lastAgentTabIdByHandoff], ); + const dismissStarRepoPrompt = useCallback(() => { + setStarRepoError(null); + try { + globalThis.localStorage?.setItem(STAR_SANDBOX_AGENT_REPO_STORAGE_KEY, "dismissed"); + } catch { + // ignore storage failures + } + setStarRepoPromptOpen(false); + }, []); + + const starSandboxAgentRepo = useCallback(() => { + setStarRepoPending(true); + setStarRepoError(null); + void backendClient + .starSandboxAgentRepo(workspaceId) + .then(() => { + try { + globalThis.localStorage?.setItem(STAR_SANDBOX_AGENT_REPO_STORAGE_KEY, "completed"); + } catch { + // ignore storage failures + } + setStarRepoPromptOpen(false); + }) + .catch((error) => { + setStarRepoError(error instanceof Error ? error.message : String(error)); + }) + .finally(() => { + setStarRepoPending(false); + }); + }, [workspaceId]); + + const starRepoPrompt = starRepoPromptOpen ? ( +
+
+
+
Onboarding
+

Give us support for sandbox agent

+

+ Before you keep going, give us support for sandbox agent and star the repo right here in the app. +

+
+ + {starRepoError ? ( +
+ {starRepoError} +
+ ) : null} + +
+ + +
+
+
+ ) : null; + if (!activeHandoff) { return ( + <> + + + + +
+
+

Create your first handoff

+

+ {viewModel.repos.length > 0 + ? "Start from the sidebar to create a handoff on the first available repo." + : "No repos are available in this workspace yet."} +

+ +
+
+
+
+ +
+ {starRepoPrompt} + + ); + } + + return ( + <> - - -
-
-

Create your first handoff

-

- {viewModel.repos.length > 0 - ? "Start from the sidebar to create a handoff on the first available repo." - : "No repos are available in this workspace yet."} -

- -
-
-
-
- + { + setActiveTabIdByHandoff((current) => ({ ...current, [activeHandoff.id]: tabId })); + }} + onSetLastAgentTabId={(tabId) => { + setLastAgentTabIdByHandoff((current) => ({ ...current, [activeHandoff.id]: tabId })); + }} + onSetOpenDiffs={(paths) => { + setOpenDiffsByHandoff((current) => ({ ...current, [activeHandoff.id]: paths })); + }} + /> +
- ); - } - - return ( - - - { - setActiveTabIdByHandoff((current) => ({ ...current, [activeHandoff.id]: tabId })); - }} - onSetLastAgentTabId={(tabId) => { - setLastAgentTabIdByHandoff((current) => ({ ...current, [activeHandoff.id]: tabId })); - }} - onSetOpenDiffs={(paths) => { - setOpenDiffsByHandoff((current) => ({ ...current, [activeHandoff.id]: paths })); - }} - /> - - + {starRepoPrompt} + ); } diff --git a/factory/packages/shared/src/contracts.ts b/factory/packages/shared/src/contracts.ts index 899bb40..a020674 100644 --- a/factory/packages/shared/src/contracts.ts +++ b/factory/packages/shared/src/contracts.ts @@ -201,6 +201,17 @@ export const WorkspaceUseInputSchema = z.object({ }); export type WorkspaceUseInput = z.infer; +export const StarSandboxAgentRepoInputSchema = z.object({ + workspaceId: WorkspaceIdSchema, +}); +export type StarSandboxAgentRepoInput = z.infer; + +export const StarSandboxAgentRepoResultSchema = z.object({ + repo: z.string().min(1), + starredAt: z.number().int(), +}); +export type StarSandboxAgentRepoResult = z.infer; + export const HistoryQueryInputSchema = z.object({ workspaceId: WorkspaceIdSchema, limit: z.number().int().positive().max(500).optional(),