mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-20 10:01:28 +00:00
factory: rename project and handoff actors
This commit is contained in:
parent
3022bce2ad
commit
ea7c36a8e7
147 changed files with 6313 additions and 14364 deletions
248
factory/packages/backend/test/app-state.test.ts
Normal file
248
factory/packages/backend/test/app-state.test.ts
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { setupTest } from "rivetkit/test";
|
||||
import { registry } from "../src/actors/index.js";
|
||||
import { workspaceKey } from "../src/actors/keys.js";
|
||||
import { APP_SHELL_WORKSPACE_ID } from "../src/actors/workspace/app-shell.js";
|
||||
import { createTestRuntimeContext } from "./helpers/test-context.js";
|
||||
import { createTestDriver } from "./helpers/test-driver.js";
|
||||
|
||||
function createGithubService(overrides?: Record<string, unknown>) {
|
||||
return {
|
||||
isAppConfigured: () => true,
|
||||
isWebhookConfigured: () => false,
|
||||
verifyWebhookEvent: () => { throw new Error("GitHub webhook not configured in test"); },
|
||||
buildAuthorizeUrl: (state: string) => `https://github.example/login/oauth/authorize?state=${encodeURIComponent(state)}`,
|
||||
exchangeCode: async () => ({
|
||||
accessToken: "gho_live",
|
||||
scopes: ["read:user", "user:email", "read:org"],
|
||||
}),
|
||||
getViewer: async () => ({
|
||||
id: "1001",
|
||||
login: "nathan",
|
||||
name: "Nathan",
|
||||
email: "nathan@acme.dev",
|
||||
}),
|
||||
listOrganizations: async () => [
|
||||
{
|
||||
id: "2001",
|
||||
login: "acme",
|
||||
name: "Acme",
|
||||
},
|
||||
],
|
||||
listInstallations: async () => [
|
||||
{
|
||||
id: 3001,
|
||||
accountLogin: "acme",
|
||||
},
|
||||
],
|
||||
listUserRepositories: async () => [
|
||||
{
|
||||
fullName: "nathan/personal-site",
|
||||
cloneUrl: "https://github.com/nathan/personal-site.git",
|
||||
private: false,
|
||||
},
|
||||
],
|
||||
listInstallationRepositories: async () => [
|
||||
{
|
||||
fullName: "acme/backend",
|
||||
cloneUrl: "https://github.com/acme/backend.git",
|
||||
private: true,
|
||||
},
|
||||
{
|
||||
fullName: "acme/frontend",
|
||||
cloneUrl: "https://github.com/acme/frontend.git",
|
||||
private: false,
|
||||
},
|
||||
],
|
||||
buildInstallationUrl: async () => "https://github.example/apps/sandbox/installations/new",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createStripeService(overrides?: Record<string, unknown>) {
|
||||
return {
|
||||
isConfigured: () => false,
|
||||
createCustomer: async () => ({ id: "cus_test" }),
|
||||
createCheckoutSession: async () => ({ id: "cs_test", url: "https://billing.example/checkout/cs_test" }),
|
||||
retrieveCheckoutCompletion: async () => ({
|
||||
customerId: "cus_test",
|
||||
subscriptionId: "sub_test",
|
||||
planId: "team" as const,
|
||||
paymentMethodLabel: "Visa ending in 4242",
|
||||
}),
|
||||
retrieveSubscription: async () => ({
|
||||
id: "sub_test",
|
||||
customerId: "cus_test",
|
||||
priceId: "price_team",
|
||||
status: "active",
|
||||
cancelAtPeriodEnd: false,
|
||||
currentPeriodEnd: 1741564800,
|
||||
trialEnd: null,
|
||||
defaultPaymentMethodLabel: "Visa ending in 4242",
|
||||
}),
|
||||
createPortalSession: async () => ({ url: "https://billing.example/portal" }),
|
||||
updateSubscriptionCancellation: async (_subscriptionId: string, cancelAtPeriodEnd: boolean) => ({
|
||||
id: "sub_test",
|
||||
customerId: "cus_test",
|
||||
priceId: "price_team",
|
||||
status: "active",
|
||||
cancelAtPeriodEnd,
|
||||
currentPeriodEnd: 1741564800,
|
||||
trialEnd: null,
|
||||
defaultPaymentMethodLabel: "Visa ending in 4242",
|
||||
}),
|
||||
verifyWebhookEvent: (payload: string) => JSON.parse(payload),
|
||||
planIdForPriceId: (priceId: string) => (priceId === "price_team" ? ("team" as const) : null),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function getAppWorkspace(client: any) {
|
||||
return await client.workspace.getOrCreate(workspaceKey(APP_SHELL_WORKSPACE_ID), {
|
||||
createWithInput: APP_SHELL_WORKSPACE_ID,
|
||||
});
|
||||
}
|
||||
|
||||
describe("app shell actors", () => {
|
||||
it("restores a GitHub session and imports repos into actor-owned workspace state", async (t) => {
|
||||
createTestRuntimeContext(createTestDriver(), undefined, {
|
||||
appUrl: "http://localhost:4173",
|
||||
github: createGithubService(),
|
||||
stripe: createStripeService(),
|
||||
});
|
||||
|
||||
const { client } = await setupTest(t, registry);
|
||||
const app = await getAppWorkspace(client);
|
||||
|
||||
const { sessionId } = await app.ensureAppSession({});
|
||||
const authStart = await app.startAppGithubAuth({ sessionId });
|
||||
const state = new URL(authStart.url).searchParams.get("state");
|
||||
expect(state).toBeTruthy();
|
||||
|
||||
const callback = await app.completeAppGithubAuth({
|
||||
code: "oauth-code",
|
||||
state,
|
||||
});
|
||||
expect(callback.redirectTo).toContain("factorySession=");
|
||||
|
||||
const snapshot = await app.selectAppOrganization({
|
||||
sessionId,
|
||||
organizationId: "acme",
|
||||
});
|
||||
|
||||
expect(snapshot.auth.status).toBe("signed_in");
|
||||
expect(snapshot.activeOrganizationId).toBe("acme");
|
||||
expect(snapshot.users[0]?.githubLogin).toBe("nathan");
|
||||
expect(snapshot.organizations.map((organization: any) => organization.id)).toEqual(["personal-nathan", "acme"]);
|
||||
|
||||
const acme = snapshot.organizations.find((organization: any) => organization.id === "acme");
|
||||
expect(acme.github.syncStatus).toBe("synced");
|
||||
expect(acme.github.installationStatus).toBe("connected");
|
||||
expect(acme.repoCatalog).toEqual(["acme/backend", "acme/frontend"]);
|
||||
|
||||
const orgWorkspace = await client.workspace.getOrCreate(workspaceKey("acme"), {
|
||||
createWithInput: "acme",
|
||||
});
|
||||
const repos = await orgWorkspace.listRepos({ workspaceId: "acme" });
|
||||
expect(repos.map((repo: any) => repo.remoteUrl).sort()).toEqual([
|
||||
"https://github.com/acme/backend.git",
|
||||
"https://github.com/acme/frontend.git",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps install-required orgs in actor state when the GitHub App installation is missing", async (t) => {
|
||||
createTestRuntimeContext(createTestDriver(), undefined, {
|
||||
appUrl: "http://localhost:4173",
|
||||
github: createGithubService({
|
||||
listInstallations: async () => [],
|
||||
}),
|
||||
stripe: createStripeService(),
|
||||
});
|
||||
|
||||
const { client } = await setupTest(t, registry);
|
||||
const app = await getAppWorkspace(client);
|
||||
|
||||
const { sessionId } = await app.ensureAppSession({});
|
||||
const authStart = await app.startAppGithubAuth({ sessionId });
|
||||
const state = new URL(authStart.url).searchParams.get("state");
|
||||
await app.completeAppGithubAuth({
|
||||
code: "oauth-code",
|
||||
state,
|
||||
});
|
||||
|
||||
const snapshot = await app.triggerAppRepoImport({
|
||||
sessionId,
|
||||
organizationId: "acme",
|
||||
});
|
||||
|
||||
const acme = snapshot.organizations.find((organization: any) => organization.id === "acme");
|
||||
expect(acme.github.installationStatus).toBe("install_required");
|
||||
expect(acme.github.syncStatus).toBe("error");
|
||||
expect(acme.github.lastSyncLabel).toContain("installation required");
|
||||
});
|
||||
|
||||
it("maps Stripe checkout and invoice events back into organization actors", async (t) => {
|
||||
createTestRuntimeContext(createTestDriver(), undefined, {
|
||||
appUrl: "http://localhost:4173",
|
||||
github: createGithubService({
|
||||
listInstallationRepositories: async () => [],
|
||||
}),
|
||||
stripe: createStripeService({
|
||||
isConfigured: () => true,
|
||||
}),
|
||||
});
|
||||
|
||||
const { client } = await setupTest(t, registry);
|
||||
const app = await getAppWorkspace(client);
|
||||
|
||||
const { sessionId } = await app.ensureAppSession({});
|
||||
const authStart = await app.startAppGithubAuth({ sessionId });
|
||||
const state = new URL(authStart.url).searchParams.get("state");
|
||||
await app.completeAppGithubAuth({
|
||||
code: "oauth-code",
|
||||
state,
|
||||
});
|
||||
|
||||
const checkout = await app.createAppCheckoutSession({
|
||||
sessionId,
|
||||
organizationId: "acme",
|
||||
planId: "team",
|
||||
});
|
||||
expect(checkout.url).toBe("https://billing.example/checkout/cs_test");
|
||||
|
||||
const completion = await app.finalizeAppCheckoutSession({
|
||||
sessionId,
|
||||
organizationId: "acme",
|
||||
checkoutSessionId: "cs_test",
|
||||
});
|
||||
expect(completion.redirectTo).toContain("/organizations/acme/billing");
|
||||
|
||||
await app.handleAppStripeWebhook({
|
||||
payload: JSON.stringify({
|
||||
id: "evt_1",
|
||||
type: "invoice.paid",
|
||||
data: {
|
||||
object: {
|
||||
id: "in_1",
|
||||
customer: "cus_test",
|
||||
number: "0001",
|
||||
amount_paid: 24000,
|
||||
created: 1741564800,
|
||||
},
|
||||
},
|
||||
}),
|
||||
signatureHeader: "sig",
|
||||
});
|
||||
|
||||
const snapshot = await app.getAppSnapshot({ sessionId });
|
||||
const acme = snapshot.organizations.find((organization: any) => organization.id === "acme");
|
||||
expect(acme.billing.planId).toBe("team");
|
||||
expect(acme.billing.status).toBe("active");
|
||||
expect(acme.billing.paymentMethodLabel).toBe("Visa ending in 4242");
|
||||
expect(acme.billing.invoices[0]).toMatchObject({
|
||||
id: "in_1",
|
||||
amountUsd: 240,
|
||||
status: "paid",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { applyDevelopmentEnvDefaults, loadDevelopmentEnvFiles } from "../src/config/env.js";
|
||||
|
|
@ -10,6 +10,7 @@ const ENV_KEYS = [
|
|||
"BETTER_AUTH_URL",
|
||||
"BETTER_AUTH_SECRET",
|
||||
"GITHUB_REDIRECT_URI",
|
||||
"GITHUB_APP_PRIVATE_KEY",
|
||||
] as const;
|
||||
|
||||
const ORIGINAL_ENV = new Map<string, string | undefined>(
|
||||
|
|
@ -45,6 +46,21 @@ describe("development env loading", () => {
|
|||
expect(process.env.APP_URL).toBe("http://localhost:4999");
|
||||
});
|
||||
|
||||
test("walks parent directories to find repo-level development env files", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "factory-env-"));
|
||||
const nested = join(dir, "factory", "packages", "backend");
|
||||
mkdirSync(nested, { recursive: true });
|
||||
writeFileSync(join(dir, ".env.development.local"), "APP_URL=http://localhost:4888\n", "utf8");
|
||||
|
||||
process.env.NODE_ENV = "development";
|
||||
delete process.env.APP_URL;
|
||||
|
||||
const loaded = loadDevelopmentEnvFiles(nested);
|
||||
|
||||
expect(loaded).toContain(join(dir, ".env.development.local"));
|
||||
expect(process.env.APP_URL).toBe("http://localhost:4888");
|
||||
});
|
||||
|
||||
test("skips dotenv files outside development", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "factory-env-"));
|
||||
writeFileSync(join(dir, ".env.development"), "APP_URL=http://localhost:4999\n", "utf8");
|
||||
|
|
@ -72,4 +88,20 @@ describe("development env loading", () => {
|
|||
expect(process.env.BETTER_AUTH_SECRET).toBe("sandbox-agent-factory-development-only-change-me");
|
||||
expect(process.env.GITHUB_REDIRECT_URI).toBe("http://localhost:4173/api/rivet/app/auth/github/callback");
|
||||
});
|
||||
|
||||
test("decodes escaped newlines for quoted env values", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "factory-env-"));
|
||||
writeFileSync(
|
||||
join(dir, ".env.development"),
|
||||
'GITHUB_APP_PRIVATE_KEY="line-1\\nline-2\\n"\n',
|
||||
"utf8",
|
||||
);
|
||||
|
||||
process.env.NODE_ENV = "development";
|
||||
delete process.env.GITHUB_APP_PRIVATE_KEY;
|
||||
|
||||
loadDevelopmentEnvFiles(dir);
|
||||
|
||||
expect(process.env.GITHUB_APP_PRIVATE_KEY).toBe("line-1\nline-2\n");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ describe("create flow decision", () => {
|
|||
const resolved = resolveCreateFlowDecision({
|
||||
task: "Add auth",
|
||||
localBranches: ["feat-add-auth"],
|
||||
handoffBranches: ["feat-add-auth-2"]
|
||||
taskBranches: ["feat-add-auth-2"]
|
||||
});
|
||||
|
||||
expect(resolved.title).toBe("feat: Add auth");
|
||||
|
|
@ -38,7 +38,7 @@ describe("create flow decision", () => {
|
|||
task: "new task",
|
||||
explicitBranchName: "existing-branch",
|
||||
localBranches: ["existing-branch"],
|
||||
handoffBranches: []
|
||||
taskBranches: []
|
||||
})
|
||||
).toThrow("already exists");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ describe("daytona provider snapshot image behavior", () => {
|
|||
repoId: "repo-1",
|
||||
repoRemote: "https://github.com/acme/repo.git",
|
||||
branchName: "feature/test",
|
||||
handoffId: "handoff-1",
|
||||
taskId: "task-1",
|
||||
});
|
||||
|
||||
expect(client.createSandboxCalls).toHaveLength(1);
|
||||
|
|
@ -94,7 +94,7 @@ describe("daytona provider snapshot image behavior", () => {
|
|||
|
||||
expect(handle.metadata.snapshot).toBe("snapshot-factory");
|
||||
expect(handle.metadata.image).toBe("ubuntu:24.04");
|
||||
expect(handle.metadata.cwd).toBe("/home/daytona/sandbox-agent-factory/default/repo-1/handoff-1/repo");
|
||||
expect(handle.metadata.cwd).toBe("/home/daytona/sandbox-agent-factory/default/repo-1/task-1/repo");
|
||||
expect(client.executedCommands.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
|
|
@ -154,7 +154,7 @@ describe("daytona provider snapshot image behavior", () => {
|
|||
repoId: "repo-1",
|
||||
repoRemote: "https://github.com/acme/repo.git",
|
||||
branchName: "feature/test",
|
||||
handoffId: "handoff-timeout",
|
||||
taskId: "task-timeout",
|
||||
})).rejects.toThrow("daytona create sandbox timed out after 120ms");
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { ConfigSchema, type AppConfig } from "@sandbox-agent/factory-shared";
|
|||
import type { BackendDriver } from "../../src/driver.js";
|
||||
import { initActorRuntimeContext } from "../../src/actors/context.js";
|
||||
import { createProviderRegistry } from "../../src/providers/index.js";
|
||||
import { createDefaultAppShellServices, type AppShellServices } from "../../src/services/app-shell-runtime.js";
|
||||
|
||||
export function createTestConfig(overrides?: Partial<AppConfig>): AppConfig {
|
||||
return ConfigSchema.parse({
|
||||
|
|
@ -31,10 +32,21 @@ export function createTestConfig(overrides?: Partial<AppConfig>): AppConfig {
|
|||
|
||||
export function createTestRuntimeContext(
|
||||
driver: BackendDriver,
|
||||
configOverrides?: Partial<AppConfig>
|
||||
configOverrides?: Partial<AppConfig>,
|
||||
appShellOverrides?: Partial<AppShellServices>
|
||||
): { config: AppConfig } {
|
||||
const config = createTestConfig(configOverrides);
|
||||
const providers = createProviderRegistry(config, driver);
|
||||
initActorRuntimeContext(config, providers, undefined, driver);
|
||||
initActorRuntimeContext(
|
||||
config,
|
||||
providers,
|
||||
undefined,
|
||||
driver,
|
||||
createDefaultAppShellServices({
|
||||
appUrl: appShellOverrides?.appUrl,
|
||||
github: appShellOverrides?.github,
|
||||
stripe: appShellOverrides?.stripe,
|
||||
}),
|
||||
);
|
||||
return { config };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
handoffKey,
|
||||
handoffStatusSyncKey,
|
||||
taskKey,
|
||||
taskStatusSyncKey,
|
||||
historyKey,
|
||||
projectBranchSyncKey,
|
||||
projectKey,
|
||||
projectPrSyncKey,
|
||||
repoBranchSyncKey,
|
||||
repoKey,
|
||||
repoPrSyncKey,
|
||||
sandboxInstanceKey,
|
||||
workspaceKey
|
||||
} from "../src/actors/keys.js";
|
||||
|
|
@ -14,13 +14,13 @@ describe("actor keys", () => {
|
|||
it("prefixes every key with workspace namespace", () => {
|
||||
const keys = [
|
||||
workspaceKey("default"),
|
||||
projectKey("default", "repo"),
|
||||
handoffKey("default", "repo", "handoff"),
|
||||
repoKey("default", "repo"),
|
||||
taskKey("default", "task"),
|
||||
sandboxInstanceKey("default", "daytona", "sbx"),
|
||||
historyKey("default", "repo"),
|
||||
projectPrSyncKey("default", "repo"),
|
||||
projectBranchSyncKey("default", "repo"),
|
||||
handoffStatusSyncKey("default", "repo", "handoff", "sandbox-1", "session-1")
|
||||
repoPrSyncKey("default", "repo"),
|
||||
repoBranchSyncKey("default", "repo"),
|
||||
taskStatusSyncKey("default", "repo", "task", "sandbox-1", "session-1")
|
||||
];
|
||||
|
||||
for (const key of keys) {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ function makeConfig(): AppConfig {
|
|||
backend: {
|
||||
host: "127.0.0.1",
|
||||
port: 7741,
|
||||
dbPath: "~/.local/share/sandbox-agent-factory/handoff.db",
|
||||
dbPath: "~/.local/share/sandbox-agent-factory/task.db",
|
||||
opencode_poll_interval: 2,
|
||||
github_poll_interval: 30,
|
||||
backup_interval_secs: 3600,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import {
|
|||
normalizeParentBranch,
|
||||
parentLookupFromStack,
|
||||
sortBranchesForOverview,
|
||||
} from "../src/actors/project/stack-model.js";
|
||||
} from "../src/actors/repo/stack-model.js";
|
||||
|
||||
describe("stack-model", () => {
|
||||
it("normalizes self-parent references to null", () => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { shouldMarkSessionUnreadForStatus } from "../src/actors/handoff/workbench.js";
|
||||
import { shouldMarkSessionUnreadForStatus } from "../src/actors/task/workbench.js";
|
||||
|
||||
describe("workbench unread status transitions", () => {
|
||||
it("marks unread when a running session first becomes idle", () => {
|
||||
|
|
|
|||
|
|
@ -30,18 +30,18 @@ async function waitForWorkspaceRows(
|
|||
expectedCount: number
|
||||
) {
|
||||
for (let attempt = 0; attempt < 40; attempt += 1) {
|
||||
const rows = await ws.listHandoffs({ workspaceId });
|
||||
const rows = await ws.listTasks({ workspaceId });
|
||||
if (rows.length >= expectedCount) {
|
||||
return rows;
|
||||
}
|
||||
await delay(50);
|
||||
}
|
||||
return ws.listHandoffs({ workspaceId });
|
||||
return ws.listTasks({ workspaceId });
|
||||
}
|
||||
|
||||
describe("workspace isolation", () => {
|
||||
it.skipIf(!runActorIntegration)(
|
||||
"keeps handoff lists isolated by workspace",
|
||||
"keeps task lists isolated by workspace",
|
||||
async (t) => {
|
||||
const testDriver = createTestDriver();
|
||||
createTestRuntimeContext(testDriver);
|
||||
|
|
@ -58,7 +58,7 @@ describe("workspace isolation", () => {
|
|||
const repoA = await wsA.addRepo({ workspaceId: "alpha", remoteUrl: repoPath });
|
||||
const repoB = await wsB.addRepo({ workspaceId: "beta", remoteUrl: repoPath });
|
||||
|
||||
await wsA.createHandoff({
|
||||
await wsA.createTask({
|
||||
workspaceId: "alpha",
|
||||
repoId: repoA.repoId,
|
||||
task: "task A",
|
||||
|
|
@ -67,7 +67,7 @@ describe("workspace isolation", () => {
|
|||
explicitTitle: "A"
|
||||
});
|
||||
|
||||
await wsB.createHandoff({
|
||||
await wsB.createTask({
|
||||
workspaceId: "beta",
|
||||
repoId: repoB.repoId,
|
||||
task: "task B",
|
||||
|
|
@ -83,7 +83,7 @@ describe("workspace isolation", () => {
|
|||
expect(bRows.length).toBe(1);
|
||||
expect(aRows[0]?.workspaceId).toBe("alpha");
|
||||
expect(bRows[0]?.workspaceId).toBe("beta");
|
||||
expect(aRows[0]?.handoffId).not.toBe(bRows[0]?.handoffId);
|
||||
expect(aRows[0]?.taskId).not.toBe(bRows[0]?.taskId);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue