factory: rename project and handoff actors

This commit is contained in:
Nathan Flurry 2026-03-10 21:55:30 -07:00
parent 3022bce2ad
commit ea7c36a8e7
147 changed files with 6313 additions and 14364 deletions

View 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",
});
});
});

View file

@ -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");
});
});

View file

@ -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");
});

View file

@ -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) {

View file

@ -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 };
}

View file

@ -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) {

View file

@ -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,

View file

@ -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", () => {

View file

@ -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", () => {

View file

@ -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);
}
);
});