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

@ -2,22 +2,26 @@ import type { AppConfig } from "@sandbox-agent/factory-shared";
import type { BackendDriver } from "../driver.js";
import type { NotificationService } from "../notifications/index.js";
import type { ProviderRegistry } from "../providers/index.js";
import type { AppShellServices } from "../services/app-shell-runtime.js";
let runtimeConfig: AppConfig | null = null;
let providerRegistry: ProviderRegistry | null = null;
let notificationService: NotificationService | null = null;
let runtimeDriver: BackendDriver | null = null;
let appShellServices: AppShellServices | null = null;
export function initActorRuntimeContext(
config: AppConfig,
providers: ProviderRegistry,
notifications?: NotificationService,
driver?: BackendDriver
driver?: BackendDriver,
appShell?: AppShellServices
): void {
runtimeConfig = config;
providerRegistry = providers;
notificationService = notifications ?? null;
runtimeDriver = driver ?? null;
appShellServices = appShell ?? null;
}
export function getActorRuntimeContext(): {
@ -25,6 +29,7 @@ export function getActorRuntimeContext(): {
providers: ProviderRegistry;
notifications: NotificationService | null;
driver: BackendDriver;
appShell: AppShellServices;
} {
if (!runtimeConfig || !providerRegistry) {
throw new Error("Actor runtime context not initialized");
@ -34,10 +39,15 @@ export function getActorRuntimeContext(): {
throw new Error("Actor runtime context missing driver");
}
if (!appShellServices) {
throw new Error("Actor runtime context missing app shell services");
}
return {
config: runtimeConfig,
providers: providerRegistry,
notifications: notificationService,
driver: runtimeDriver,
appShell: appShellServices,
};
}

View file

@ -1,23 +1,23 @@
import type { HandoffStatus, ProviderId } from "@sandbox-agent/factory-shared";
import type { TaskStatus, ProviderId } from "@sandbox-agent/factory-shared";
export interface HandoffCreatedEvent {
export interface TaskCreatedEvent {
workspaceId: string;
repoId: string;
handoffId: string;
taskId: string;
providerId: ProviderId;
branchName: string;
title: string;
}
export interface HandoffStatusEvent {
export interface TaskStatusEvent {
workspaceId: string;
repoId: string;
handoffId: string;
status: HandoffStatus;
taskId: string;
status: TaskStatus;
message: string;
}
export interface ProjectSnapshotEvent {
export interface RepoSnapshotEvent {
workspaceId: string;
repoId: string;
updatedAt: number;
@ -26,28 +26,28 @@ export interface ProjectSnapshotEvent {
export interface AgentStartedEvent {
workspaceId: string;
repoId: string;
handoffId: string;
taskId: string;
sessionId: string;
}
export interface AgentIdleEvent {
workspaceId: string;
repoId: string;
handoffId: string;
taskId: string;
sessionId: string;
}
export interface AgentErrorEvent {
workspaceId: string;
repoId: string;
handoffId: string;
taskId: string;
message: string;
}
export interface PrCreatedEvent {
workspaceId: string;
repoId: string;
handoffId: string;
taskId: string;
prNumber: number;
url: string;
}
@ -55,7 +55,7 @@ export interface PrCreatedEvent {
export interface PrClosedEvent {
workspaceId: string;
repoId: string;
handoffId: string;
taskId: string;
prNumber: number;
merged: boolean;
}
@ -63,7 +63,7 @@ export interface PrClosedEvent {
export interface PrReviewEvent {
workspaceId: string;
repoId: string;
handoffId: string;
taskId: string;
prNumber: number;
reviewer: string;
status: string;
@ -72,41 +72,41 @@ export interface PrReviewEvent {
export interface CiStatusChangedEvent {
workspaceId: string;
repoId: string;
handoffId: string;
taskId: string;
prNumber: number;
status: string;
}
export type HandoffStepName = "auto_commit" | "push" | "pr_submit";
export type HandoffStepStatus = "started" | "completed" | "skipped" | "failed";
export type TaskStepName = "auto_commit" | "push" | "pr_submit";
export type TaskStepStatus = "started" | "completed" | "skipped" | "failed";
export interface HandoffStepEvent {
export interface TaskStepEvent {
workspaceId: string;
repoId: string;
handoffId: string;
step: HandoffStepName;
status: HandoffStepStatus;
taskId: string;
step: TaskStepName;
status: TaskStepStatus;
message: string;
}
export interface BranchSwitchedEvent {
workspaceId: string;
repoId: string;
handoffId: string;
taskId: string;
branchName: string;
}
export interface SessionAttachedEvent {
workspaceId: string;
repoId: string;
handoffId: string;
taskId: string;
sessionId: string;
}
export interface BranchSyncedEvent {
workspaceId: string;
repoId: string;
handoffId: string;
taskId: string;
branchName: string;
strategy: string;
}

View file

@ -1,12 +1,12 @@
import {
handoffKey,
handoffStatusSyncKey,
historyKey,
projectBranchSyncKey,
projectKey,
projectPrSyncKey,
repoBranchSyncKey,
repoKey,
repoPrSyncKey,
sandboxInstanceKey,
workspaceKey
taskKey,
taskStatusSyncKey,
workspaceKey,
} from "./keys.js";
import type { ProviderId } from "@sandbox-agent/factory-shared";
@ -16,37 +16,36 @@ export function actorClient(c: any) {
export async function getOrCreateWorkspace(c: any, workspaceId: string) {
return await actorClient(c).workspace.getOrCreate(workspaceKey(workspaceId), {
createWithInput: workspaceId
createWithInput: workspaceId,
});
}
export async function getOrCreateProject(c: any, workspaceId: string, repoId: string, remoteUrl: string) {
return await actorClient(c).project.getOrCreate(projectKey(workspaceId, repoId), {
export async function getOrCreateRepo(c: any, workspaceId: string, repoId: string, remoteUrl: string) {
return await actorClient(c).repo.getOrCreate(repoKey(workspaceId, repoId), {
createWithInput: {
workspaceId,
repoId,
remoteUrl
}
remoteUrl,
},
});
}
export function getProject(c: any, workspaceId: string, repoId: string) {
return actorClient(c).project.get(projectKey(workspaceId, repoId));
export function getRepo(c: any, workspaceId: string, repoId: string) {
return actorClient(c).repo.get(repoKey(workspaceId, repoId));
}
export function getHandoff(c: any, workspaceId: string, repoId: string, handoffId: string) {
return actorClient(c).handoff.get(handoffKey(workspaceId, repoId, handoffId));
export function getTask(c: any, workspaceId: string, taskId: string) {
return actorClient(c).task.get(taskKey(workspaceId, taskId));
}
export async function getOrCreateHandoff(
export async function getOrCreateTask(
c: any,
workspaceId: string,
repoId: string,
handoffId: string,
createWithInput: Record<string, unknown>
taskId: string,
createWithInput: Record<string, unknown>,
) {
return await actorClient(c).handoff.getOrCreate(handoffKey(workspaceId, repoId, handoffId), {
createWithInput
return await actorClient(c).task.getOrCreate(taskKey(workspaceId, taskId), {
createWithInput,
});
}
@ -54,42 +53,42 @@ export async function getOrCreateHistory(c: any, workspaceId: string, repoId: st
return await actorClient(c).history.getOrCreate(historyKey(workspaceId, repoId), {
createWithInput: {
workspaceId,
repoId
}
repoId,
},
});
}
export async function getOrCreateProjectPrSync(
export async function getOrCreateRepoPrSync(
c: any,
workspaceId: string,
repoId: string,
repoPath: string,
intervalMs: number
intervalMs: number,
) {
return await actorClient(c).projectPrSync.getOrCreate(projectPrSyncKey(workspaceId, repoId), {
return await actorClient(c).repoPrSync.getOrCreate(repoPrSyncKey(workspaceId, repoId), {
createWithInput: {
workspaceId,
repoId,
repoPath,
intervalMs
}
intervalMs,
},
});
}
export async function getOrCreateProjectBranchSync(
export async function getOrCreateRepoBranchSync(
c: any,
workspaceId: string,
repoId: string,
repoPath: string,
intervalMs: number
intervalMs: number,
) {
return await actorClient(c).projectBranchSync.getOrCreate(projectBranchSyncKey(workspaceId, repoId), {
return await actorClient(c).repoBranchSync.getOrCreate(repoBranchSyncKey(workspaceId, repoId), {
createWithInput: {
workspaceId,
repoId,
repoPath,
intervalMs
}
intervalMs,
},
});
}
@ -102,57 +101,57 @@ export async function getOrCreateSandboxInstance(
workspaceId: string,
providerId: ProviderId,
sandboxId: string,
createWithInput: Record<string, unknown>
createWithInput: Record<string, unknown>,
) {
return await actorClient(c).sandboxInstance.getOrCreate(
sandboxInstanceKey(workspaceId, providerId, sandboxId),
{ createWithInput }
{ createWithInput },
);
}
export async function getOrCreateHandoffStatusSync(
export async function getOrCreateTaskStatusSync(
c: any,
workspaceId: string,
repoId: string,
handoffId: string,
taskId: string,
sandboxId: string,
sessionId: string,
createWithInput: Record<string, unknown>
createWithInput: Record<string, unknown>,
) {
return await actorClient(c).handoffStatusSync.getOrCreate(
handoffStatusSyncKey(workspaceId, repoId, handoffId, sandboxId, sessionId),
return await actorClient(c).taskStatusSync.getOrCreate(
taskStatusSyncKey(workspaceId, repoId, taskId, sandboxId, sessionId),
{
createWithInput
}
createWithInput,
},
);
}
export function selfProjectPrSync(c: any) {
return actorClient(c).projectPrSync.getForId(c.actorId);
export function selfRepoPrSync(c: any) {
return actorClient(c).repoPrSync.getForId(c.actorId);
}
export function selfProjectBranchSync(c: any) {
return actorClient(c).projectBranchSync.getForId(c.actorId);
export function selfRepoBranchSync(c: any) {
return actorClient(c).repoBranchSync.getForId(c.actorId);
}
export function selfHandoffStatusSync(c: any) {
return actorClient(c).handoffStatusSync.getForId(c.actorId);
export function selfTaskStatusSync(c: any) {
return actorClient(c).taskStatusSync.getForId(c.actorId);
}
export function selfHistory(c: any) {
return actorClient(c).history.getForId(c.actorId);
}
export function selfHandoff(c: any) {
return actorClient(c).handoff.getForId(c.actorId);
export function selfTask(c: any) {
return actorClient(c).task.getForId(c.actorId);
}
export function selfWorkspace(c: any) {
return actorClient(c).workspace.getForId(c.actorId);
}
export function selfProject(c: any) {
return actorClient(c).project.getForId(c.actorId);
export function selfRepo(c: any) {
return actorClient(c).repo.getForId(c.actorId);
}
export function selfSandboxInstance(c: any) {

View file

@ -1,7 +0,0 @@
import { defineConfig } from "rivetkit/db/drizzle";
export default defineConfig({
out: "./src/actors/handoff/db/drizzle",
schema: "./src/actors/handoff/db/schema.ts",
});

View file

@ -1,399 +0,0 @@
import { actor, queue } from "rivetkit";
import { workflow } from "rivetkit/workflow";
import type {
AgentType,
HandoffRecord,
HandoffWorkbenchChangeModelInput,
HandoffWorkbenchRenameInput,
HandoffWorkbenchRenameSessionInput,
HandoffWorkbenchSetSessionUnreadInput,
HandoffWorkbenchSendMessageInput,
HandoffWorkbenchUpdateDraftInput,
ProviderId
} from "@sandbox-agent/factory-shared";
import { expectQueueResponse } from "../../services/queue.js";
import { selfHandoff } from "../handles.js";
import { handoffDb } from "./db/db.js";
import { getCurrentRecord } from "./workflow/common.js";
import {
changeWorkbenchModel,
closeWorkbenchSession,
createWorkbenchSession,
getWorkbenchHandoff,
markWorkbenchUnread,
publishWorkbenchPr,
renameWorkbenchBranch,
renameWorkbenchHandoff,
renameWorkbenchSession,
revertWorkbenchFile,
sendWorkbenchMessage,
syncWorkbenchSessionStatus,
setWorkbenchSessionUnread,
stopWorkbenchSession,
updateWorkbenchDraft
} from "./workbench.js";
import {
HANDOFF_QUEUE_NAMES,
handoffWorkflowQueueName,
runHandoffWorkflow
} from "./workflow/index.js";
export interface HandoffInput {
workspaceId: string;
repoId: string;
handoffId: string;
repoRemote: string;
repoLocalPath: string;
branchName: string | null;
title: string | null;
task: string;
providerId: ProviderId;
agentType: AgentType | null;
explicitTitle: string | null;
explicitBranchName: string | null;
}
interface InitializeCommand {
providerId?: ProviderId;
}
interface HandoffActionCommand {
reason?: string;
}
interface HandoffTabCommand {
tabId: string;
}
interface HandoffStatusSyncCommand {
sessionId: string;
status: "running" | "idle" | "error";
at: number;
}
interface HandoffWorkbenchValueCommand {
value: string;
}
interface HandoffWorkbenchSessionTitleCommand {
sessionId: string;
title: string;
}
interface HandoffWorkbenchSessionUnreadCommand {
sessionId: string;
unread: boolean;
}
interface HandoffWorkbenchUpdateDraftCommand {
sessionId: string;
text: string;
attachments: Array<any>;
}
interface HandoffWorkbenchChangeModelCommand {
sessionId: string;
model: string;
}
interface HandoffWorkbenchSendMessageCommand {
sessionId: string;
text: string;
attachments: Array<any>;
}
interface HandoffWorkbenchCreateSessionCommand {
model?: string;
}
interface HandoffWorkbenchSessionCommand {
sessionId: string;
}
export const handoff = actor({
db: handoffDb,
queues: Object.fromEntries(HANDOFF_QUEUE_NAMES.map((name) => [name, queue()])),
options: {
actionTimeout: 5 * 60_000
},
createState: (_c, input: HandoffInput) => ({
workspaceId: input.workspaceId,
repoId: input.repoId,
handoffId: input.handoffId,
repoRemote: input.repoRemote,
repoLocalPath: input.repoLocalPath,
branchName: input.branchName,
title: input.title,
task: input.task,
providerId: input.providerId,
agentType: input.agentType,
explicitTitle: input.explicitTitle,
explicitBranchName: input.explicitBranchName,
initialized: false,
previousStatus: null as string | null,
}),
actions: {
async initialize(c, cmd: InitializeCommand): Promise<HandoffRecord> {
const self = selfHandoff(c);
const result = await self.send(handoffWorkflowQueueName("handoff.command.initialize"), cmd ?? {}, {
wait: true,
timeout: 60_000,
});
return expectQueueResponse<HandoffRecord>(result);
},
async provision(c, cmd: InitializeCommand): Promise<{ ok: true }> {
const self = selfHandoff(c);
await self.send(handoffWorkflowQueueName("handoff.command.provision"), cmd ?? {}, {
wait: true,
timeout: 30 * 60_000,
});
return { ok: true };
},
async attach(c, cmd?: HandoffActionCommand): Promise<{ target: string; sessionId: string | null }> {
const self = selfHandoff(c);
const result = await self.send(handoffWorkflowQueueName("handoff.command.attach"), cmd ?? {}, {
wait: true,
timeout: 20_000
});
return expectQueueResponse<{ target: string; sessionId: string | null }>(result);
},
async switch(c): Promise<{ switchTarget: string }> {
const self = selfHandoff(c);
const result = await self.send(handoffWorkflowQueueName("handoff.command.switch"), {}, {
wait: true,
timeout: 20_000
});
return expectQueueResponse<{ switchTarget: string }>(result);
},
async push(c, cmd?: HandoffActionCommand): Promise<void> {
const self = selfHandoff(c);
await self.send(handoffWorkflowQueueName("handoff.command.push"), cmd ?? {}, {
wait: true,
timeout: 180_000
});
},
async sync(c, cmd?: HandoffActionCommand): Promise<void> {
const self = selfHandoff(c);
await self.send(handoffWorkflowQueueName("handoff.command.sync"), cmd ?? {}, {
wait: true,
timeout: 30_000
});
},
async merge(c, cmd?: HandoffActionCommand): Promise<void> {
const self = selfHandoff(c);
await self.send(handoffWorkflowQueueName("handoff.command.merge"), cmd ?? {}, {
wait: true,
timeout: 30_000
});
},
async archive(c, cmd?: HandoffActionCommand): Promise<void> {
const self = selfHandoff(c);
void self
.send(handoffWorkflowQueueName("handoff.command.archive"), cmd ?? {}, {
wait: true,
timeout: 60_000,
})
.catch((error: unknown) => {
c.log.warn({
msg: "archive command failed",
error: error instanceof Error ? error.message : String(error),
});
});
},
async kill(c, cmd?: HandoffActionCommand): Promise<void> {
const self = selfHandoff(c);
await self.send(handoffWorkflowQueueName("handoff.command.kill"), cmd ?? {}, {
wait: true,
timeout: 60_000
});
},
async get(c): Promise<HandoffRecord> {
return await getCurrentRecord({ db: c.db, state: c.state });
},
async getWorkbench(c) {
return await getWorkbenchHandoff(c);
},
async markWorkbenchUnread(c): Promise<void> {
const self = selfHandoff(c);
await self.send(handoffWorkflowQueueName("handoff.command.workbench.mark_unread"), {}, {
wait: true,
timeout: 20_000,
});
},
async renameWorkbenchHandoff(c, input: HandoffWorkbenchRenameInput): Promise<void> {
const self = selfHandoff(c);
await self.send(
handoffWorkflowQueueName("handoff.command.workbench.rename_handoff"),
{ value: input.value } satisfies HandoffWorkbenchValueCommand,
{
wait: true,
timeout: 20_000,
},
);
},
async renameWorkbenchBranch(c, input: HandoffWorkbenchRenameInput): Promise<void> {
const self = selfHandoff(c);
await self.send(
handoffWorkflowQueueName("handoff.command.workbench.rename_branch"),
{ value: input.value } satisfies HandoffWorkbenchValueCommand,
{
wait: true,
timeout: 5 * 60_000,
},
);
},
async createWorkbenchSession(c, input?: { model?: string }): Promise<{ tabId: string }> {
const self = selfHandoff(c);
const result = await self.send(
handoffWorkflowQueueName("handoff.command.workbench.create_session"),
{ ...(input?.model ? { model: input.model } : {}) } satisfies HandoffWorkbenchCreateSessionCommand,
{
wait: true,
timeout: 5 * 60_000,
},
);
return expectQueueResponse<{ tabId: string }>(result);
},
async renameWorkbenchSession(c, input: HandoffWorkbenchRenameSessionInput): Promise<void> {
const self = selfHandoff(c);
await self.send(
handoffWorkflowQueueName("handoff.command.workbench.rename_session"),
{ sessionId: input.tabId, title: input.title } satisfies HandoffWorkbenchSessionTitleCommand,
{
wait: true,
timeout: 20_000,
},
);
},
async setWorkbenchSessionUnread(c, input: HandoffWorkbenchSetSessionUnreadInput): Promise<void> {
const self = selfHandoff(c);
await self.send(
handoffWorkflowQueueName("handoff.command.workbench.set_session_unread"),
{ sessionId: input.tabId, unread: input.unread } satisfies HandoffWorkbenchSessionUnreadCommand,
{
wait: true,
timeout: 20_000,
},
);
},
async updateWorkbenchDraft(c, input: HandoffWorkbenchUpdateDraftInput): Promise<void> {
const self = selfHandoff(c);
await self.send(
handoffWorkflowQueueName("handoff.command.workbench.update_draft"),
{
sessionId: input.tabId,
text: input.text,
attachments: input.attachments,
} satisfies HandoffWorkbenchUpdateDraftCommand,
{
wait: true,
timeout: 20_000,
},
);
},
async changeWorkbenchModel(c, input: HandoffWorkbenchChangeModelInput): Promise<void> {
const self = selfHandoff(c);
await self.send(
handoffWorkflowQueueName("handoff.command.workbench.change_model"),
{ sessionId: input.tabId, model: input.model } satisfies HandoffWorkbenchChangeModelCommand,
{
wait: true,
timeout: 20_000,
},
);
},
async sendWorkbenchMessage(c, input: HandoffWorkbenchSendMessageInput): Promise<void> {
const self = selfHandoff(c);
await self.send(
handoffWorkflowQueueName("handoff.command.workbench.send_message"),
{
sessionId: input.tabId,
text: input.text,
attachments: input.attachments,
} satisfies HandoffWorkbenchSendMessageCommand,
{
wait: true,
timeout: 10 * 60_000,
},
);
},
async stopWorkbenchSession(c, input: HandoffTabCommand): Promise<void> {
const self = selfHandoff(c);
await self.send(
handoffWorkflowQueueName("handoff.command.workbench.stop_session"),
{ sessionId: input.tabId } satisfies HandoffWorkbenchSessionCommand,
{
wait: true,
timeout: 5 * 60_000,
},
);
},
async syncWorkbenchSessionStatus(c, input: HandoffStatusSyncCommand): Promise<void> {
const self = selfHandoff(c);
await self.send(
handoffWorkflowQueueName("handoff.command.workbench.sync_session_status"),
input,
{
wait: true,
timeout: 20_000,
},
);
},
async closeWorkbenchSession(c, input: HandoffTabCommand): Promise<void> {
const self = selfHandoff(c);
await self.send(
handoffWorkflowQueueName("handoff.command.workbench.close_session"),
{ sessionId: input.tabId } satisfies HandoffWorkbenchSessionCommand,
{
wait: true,
timeout: 5 * 60_000,
},
);
},
async publishWorkbenchPr(c): Promise<void> {
const self = selfHandoff(c);
await self.send(handoffWorkflowQueueName("handoff.command.workbench.publish_pr"), {}, {
wait: true,
timeout: 10 * 60_000,
});
},
async revertWorkbenchFile(c, input: { path: string }): Promise<void> {
const self = selfHandoff(c);
await self.send(
handoffWorkflowQueueName("handoff.command.workbench.revert_file"),
input,
{
wait: true,
timeout: 5 * 60_000,
},
);
}
},
run: workflow(runHandoffWorkflow)
});
export { HANDOFF_QUEUE_NAMES };

View file

@ -1,31 +0,0 @@
export const HANDOFF_QUEUE_NAMES = [
"handoff.command.initialize",
"handoff.command.provision",
"handoff.command.attach",
"handoff.command.switch",
"handoff.command.push",
"handoff.command.sync",
"handoff.command.merge",
"handoff.command.archive",
"handoff.command.kill",
"handoff.command.get",
"handoff.command.workbench.mark_unread",
"handoff.command.workbench.rename_handoff",
"handoff.command.workbench.rename_branch",
"handoff.command.workbench.create_session",
"handoff.command.workbench.rename_session",
"handoff.command.workbench.set_session_unread",
"handoff.command.workbench.update_draft",
"handoff.command.workbench.change_model",
"handoff.command.workbench.send_message",
"handoff.command.workbench.stop_session",
"handoff.command.workbench.sync_session_status",
"handoff.command.workbench.close_session",
"handoff.command.workbench.publish_pr",
"handoff.command.workbench.revert_file",
"handoff.status_sync.result"
] as const;
export function handoffWorkflowQueueName(name: string): string {
return name;
}

View file

@ -18,7 +18,7 @@ export default {
migrations: {
m0000: `CREATE TABLE \`events\` (
\`id\` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
\`handoff_id\` text,
\`task_id\` text,
\`branch_name\` text,
\`kind\` text NOT NULL,
\`payload_json\` text NOT NULL,

View file

@ -2,7 +2,7 @@ import { integer, sqliteTable, text } from "rivetkit/db/drizzle";
export const events = sqliteTable("events", {
id: integer("id").primaryKey({ autoIncrement: true }),
handoffId: text("handoff_id"),
taskId: text("task_id"),
branchName: text("branch_name"),
kind: text("kind").notNull(),
payloadJson: text("payload_json").notNull(),

View file

@ -14,14 +14,14 @@ export interface HistoryInput {
export interface AppendHistoryCommand {
kind: string;
handoffId?: string;
taskId?: string;
branchName?: string;
payload: Record<string, unknown>;
}
export interface ListHistoryParams {
branch?: string;
handoffId?: string;
taskId?: string;
limit?: number;
}
@ -32,7 +32,7 @@ async function appendHistoryRow(loopCtx: any, body: AppendHistoryCommand): Promi
await loopCtx.db
.insert(events)
.values({
handoffId: body.handoffId ?? null,
taskId: body.taskId ?? null,
branchName: body.branchName ?? null,
kind: body.kind,
payloadJson: JSON.stringify(body.payload),
@ -77,8 +77,8 @@ export const history = actor({
async list(c, params?: ListHistoryParams): Promise<HistoryEvent[]> {
const whereParts = [];
if (params?.handoffId) {
whereParts.push(eq(events.handoffId, params.handoffId));
if (params?.taskId) {
whereParts.push(eq(events.taskId, params.taskId));
}
if (params?.branch) {
whereParts.push(eq(events.branchName, params.branch));
@ -87,7 +87,7 @@ export const history = actor({
const base = c.db
.select({
id: events.id,
handoffId: events.handoffId,
taskId: events.taskId,
branchName: events.branchName,
kind: events.kind,
payloadJson: events.payloadJson,

View file

@ -1,10 +1,10 @@
import { setup } from "rivetkit";
import { handoffStatusSync } from "./handoff-status-sync/index.js";
import { handoff } from "./handoff/index.js";
import { taskStatusSync } from "./task-status-sync/index.js";
import { task } from "./task/index.js";
import { history } from "./history/index.js";
import { projectBranchSync } from "./project-branch-sync/index.js";
import { projectPrSync } from "./project-pr-sync/index.js";
import { project } from "./project/index.js";
import { repoBranchSync } from "./repo-branch-sync/index.js";
import { repoPrSync } from "./repo-pr-sync/index.js";
import { repo } from "./repo/index.js";
import { sandboxInstance } from "./sandbox-instance/index.js";
import { workspace } from "./workspace/index.js";
@ -29,13 +29,13 @@ export function resolveManagerHost(): string {
export const registry = setup({
use: {
workspace,
project,
handoff,
repo,
task,
sandboxInstance,
history,
projectPrSync,
projectBranchSync,
handoffStatusSync
repoPrSync,
repoBranchSync,
taskStatusSync
},
managerPort: resolveManagerPort(),
managerHost: resolveManagerHost()
@ -43,12 +43,12 @@ export const registry = setup({
export * from "./context.js";
export * from "./events.js";
export * from "./handoff-status-sync/index.js";
export * from "./handoff/index.js";
export * from "./task-status-sync/index.js";
export * from "./task/index.js";
export * from "./history/index.js";
export * from "./keys.js";
export * from "./project-branch-sync/index.js";
export * from "./project-pr-sync/index.js";
export * from "./project/index.js";
export * from "./repo-branch-sync/index.js";
export * from "./repo-pr-sync/index.js";
export * from "./repo/index.js";
export * from "./sandbox-instance/index.js";
export * from "./workspace/index.js";

View file

@ -4,41 +4,41 @@ export function workspaceKey(workspaceId: string): ActorKey {
return ["ws", workspaceId];
}
export function projectKey(workspaceId: string, repoId: string): ActorKey {
return ["ws", workspaceId, "project", repoId];
export function repoKey(workspaceId: string, repoId: string): ActorKey {
return ["ws", workspaceId, "repo", repoId];
}
export function handoffKey(workspaceId: string, repoId: string, handoffId: string): ActorKey {
return ["ws", workspaceId, "project", repoId, "handoff", handoffId];
export function taskKey(workspaceId: string, taskId: string): ActorKey {
return ["ws", workspaceId, "task", taskId];
}
export function sandboxInstanceKey(
workspaceId: string,
providerId: string,
sandboxId: string
sandboxId: string,
): ActorKey {
return ["ws", workspaceId, "provider", providerId, "sandbox", sandboxId];
}
export function historyKey(workspaceId: string, repoId: string): ActorKey {
return ["ws", workspaceId, "project", repoId, "history"];
return ["ws", workspaceId, "repo", repoId, "history"];
}
export function projectPrSyncKey(workspaceId: string, repoId: string): ActorKey {
return ["ws", workspaceId, "project", repoId, "pr-sync"];
export function repoPrSyncKey(workspaceId: string, repoId: string): ActorKey {
return ["ws", workspaceId, "repo", repoId, "pr-sync"];
}
export function projectBranchSyncKey(workspaceId: string, repoId: string): ActorKey {
return ["ws", workspaceId, "project", repoId, "branch-sync"];
export function repoBranchSyncKey(workspaceId: string, repoId: string): ActorKey {
return ["ws", workspaceId, "repo", repoId, "branch-sync"];
}
export function handoffStatusSyncKey(
export function taskStatusSyncKey(
workspaceId: string,
repoId: string,
handoffId: string,
taskId: string,
sandboxId: string,
sessionId: string
sessionId: string,
): ActorKey {
// Include sandbox + session so multiple sandboxes/sessions can be tracked per handoff.
return ["ws", workspaceId, "project", repoId, "handoff", handoffId, "status-sync", sandboxId, sessionId];
// Include sandbox + session so multiple sandboxes/sessions can be tracked per task.
return ["ws", workspaceId, "task", taskId, "status-sync", repoId, sandboxId, sessionId];
}

View file

@ -1,7 +0,0 @@
import { defineConfig } from "rivetkit/db/drizzle";
export default defineConfig({
out: "./src/actors/project/db/drizzle",
schema: "./src/actors/project/db/schema.ts",
});

View file

@ -1,28 +0,0 @@
import { actor, queue } from "rivetkit";
import { workflow } from "rivetkit/workflow";
import { projectDb } from "./db/db.js";
import { PROJECT_QUEUE_NAMES, projectActions, runProjectWorkflow } from "./actions.js";
export interface ProjectInput {
workspaceId: string;
repoId: string;
remoteUrl: string;
}
export const project = actor({
db: projectDb,
queues: Object.fromEntries(PROJECT_QUEUE_NAMES.map((name) => [name, queue()])),
options: {
actionTimeout: 5 * 60_000,
},
createState: (_c, input: ProjectInput) => ({
workspaceId: input.workspaceId,
repoId: input.repoId,
remoteUrl: input.remoteUrl,
localPath: null as string | null,
syncActorsStarted: false,
handoffIndexHydrated: false
}),
actions: projectActions,
run: workflow(runProjectWorkflow),
});

View file

@ -2,13 +2,13 @@ import { actor, queue } from "rivetkit";
import { workflow } from "rivetkit/workflow";
import type { GitDriver } from "../../driver.js";
import { getActorRuntimeContext } from "../context.js";
import { getProject, selfProjectBranchSync } from "../handles.js";
import { getRepo, selfRepoBranchSync } from "../handles.js";
import { logActorWarning, resolveErrorMessage, resolveErrorStack } from "../logging.js";
import { type PollingControlState, runWorkflowPollingLoop } from "../polling.js";
import { parentLookupFromStack } from "../project/stack-model.js";
import { parentLookupFromStack } from "../repo/stack-model.js";
import { withRepoGitLock } from "../../services/repo-git-lock.js";
export interface ProjectBranchSyncInput {
export interface RepoBranchSyncInput {
workspaceId: string;
repoId: string;
repoPath: string;
@ -29,17 +29,17 @@ interface EnrichedBranchSnapshot {
conflictsWithMain: boolean;
}
interface ProjectBranchSyncState extends PollingControlState {
interface RepoBranchSyncState extends PollingControlState {
workspaceId: string;
repoId: string;
repoPath: string;
}
const CONTROL = {
start: "project.branch_sync.control.start",
stop: "project.branch_sync.control.stop",
setInterval: "project.branch_sync.control.set_interval",
force: "project.branch_sync.control.force"
start: "repo.branch_sync.control.start",
stop: "repo.branch_sync.control.stop",
setInterval: "repo.branch_sync.control.set_interval",
force: "repo.branch_sync.control.force"
} as const;
async function enrichBranches(
@ -67,7 +67,7 @@ async function enrichBranches(
try {
branchDiffStat = await git.diffStatForBranch(repoPath, branch.branchName);
} catch (error) {
logActorWarning("project-branch-sync", "diffStatForBranch failed", {
logActorWarning("repo-branch-sync", "diffStatForBranch failed", {
workspaceId,
repoId,
branchName: branch.branchName,
@ -80,7 +80,7 @@ async function enrichBranches(
const headSha = await git.revParse(repoPath, `origin/${branch.branchName}`);
branchHasUnpushed = Boolean(baseSha && headSha && headSha !== baseSha);
} catch (error) {
logActorWarning("project-branch-sync", "revParse failed", {
logActorWarning("repo-branch-sync", "revParse failed", {
workspaceId,
repoId,
branchName: branch.branchName,
@ -92,7 +92,7 @@ async function enrichBranches(
try {
branchConflicts = await git.conflictsWithMain(repoPath, branch.branchName);
} catch (error) {
logActorWarning("project-branch-sync", "conflictsWithMain failed", {
logActorWarning("repo-branch-sync", "conflictsWithMain failed", {
workspaceId,
repoId,
branchName: branch.branchName,
@ -116,14 +116,14 @@ async function enrichBranches(
});
}
async function pollBranches(c: { state: ProjectBranchSyncState }): Promise<void> {
async function pollBranches(c: { state: RepoBranchSyncState }): Promise<void> {
const { driver } = getActorRuntimeContext();
const enrichedItems = await enrichBranches(c.state.workspaceId, c.state.repoId, c.state.repoPath, driver.git);
const parent = getProject(c, c.state.workspaceId, c.state.repoId);
const parent = getRepo(c, c.state.workspaceId, c.state.repoId);
await parent.applyBranchSyncResult({ items: enrichedItems, at: Date.now() });
}
export const projectBranchSync = actor({
export const repoBranchSync = actor({
queues: {
[CONTROL.start]: queue(),
[CONTROL.stop]: queue(),
@ -134,7 +134,7 @@ export const projectBranchSync = actor({
// Polling actors rely on timer-based wakeups; sleeping would pause the timer and stop polling.
noSleep: true
},
createState: (_c, input: ProjectBranchSyncInput): ProjectBranchSyncState => ({
createState: (_c, input: RepoBranchSyncInput): RepoBranchSyncState => ({
workspaceId: input.workspaceId,
repoId: input.repoId,
repoPath: input.repoPath,
@ -143,34 +143,34 @@ export const projectBranchSync = actor({
}),
actions: {
async start(c): Promise<void> {
const self = selfProjectBranchSync(c);
const self = selfRepoBranchSync(c);
await self.send(CONTROL.start, {}, { wait: true, timeout: 15_000 });
},
async stop(c): Promise<void> {
const self = selfProjectBranchSync(c);
const self = selfRepoBranchSync(c);
await self.send(CONTROL.stop, {}, { wait: true, timeout: 15_000 });
},
async setIntervalMs(c, payload: SetIntervalCommand): Promise<void> {
const self = selfProjectBranchSync(c);
const self = selfRepoBranchSync(c);
await self.send(CONTROL.setInterval, payload, { wait: true, timeout: 15_000 });
},
async force(c): Promise<void> {
const self = selfProjectBranchSync(c);
const self = selfRepoBranchSync(c);
await self.send(CONTROL.force, {}, { wait: true, timeout: 5 * 60_000 });
}
},
run: workflow(async (ctx) => {
await runWorkflowPollingLoop<ProjectBranchSyncState>(ctx, {
loopName: "project-branch-sync-loop",
await runWorkflowPollingLoop<RepoBranchSyncState>(ctx, {
loopName: "repo-branch-sync-loop",
control: CONTROL,
onPoll: async (loopCtx) => {
try {
await pollBranches(loopCtx);
} catch (error) {
logActorWarning("project-branch-sync", "poll failed", {
logActorWarning("repo-branch-sync", "poll failed", {
error: resolveErrorMessage(error),
stack: resolveErrorStack(error)
});

View file

@ -1,11 +1,11 @@
import { actor, queue } from "rivetkit";
import { workflow } from "rivetkit/workflow";
import { getActorRuntimeContext } from "../context.js";
import { getProject, selfProjectPrSync } from "../handles.js";
import { getRepo, selfRepoPrSync } from "../handles.js";
import { logActorWarning, resolveErrorMessage, resolveErrorStack } from "../logging.js";
import { type PollingControlState, runWorkflowPollingLoop } from "../polling.js";
export interface ProjectPrSyncInput {
export interface RepoPrSyncInput {
workspaceId: string;
repoId: string;
repoPath: string;
@ -16,27 +16,27 @@ interface SetIntervalCommand {
intervalMs: number;
}
interface ProjectPrSyncState extends PollingControlState {
interface RepoPrSyncState extends PollingControlState {
workspaceId: string;
repoId: string;
repoPath: string;
}
const CONTROL = {
start: "project.pr_sync.control.start",
stop: "project.pr_sync.control.stop",
setInterval: "project.pr_sync.control.set_interval",
force: "project.pr_sync.control.force"
start: "repo.pr_sync.control.start",
stop: "repo.pr_sync.control.stop",
setInterval: "repo.pr_sync.control.set_interval",
force: "repo.pr_sync.control.force"
} as const;
async function pollPrs(c: { state: ProjectPrSyncState }): Promise<void> {
async function pollPrs(c: { state: RepoPrSyncState }): Promise<void> {
const { driver } = getActorRuntimeContext();
const items = await driver.github.listPullRequests(c.state.repoPath);
const parent = getProject(c, c.state.workspaceId, c.state.repoId);
const parent = getRepo(c, c.state.workspaceId, c.state.repoId);
await parent.applyPrSyncResult({ items, at: Date.now() });
}
export const projectPrSync = actor({
export const repoPrSync = actor({
queues: {
[CONTROL.start]: queue(),
[CONTROL.stop]: queue(),
@ -47,7 +47,7 @@ export const projectPrSync = actor({
// Polling actors rely on timer-based wakeups; sleeping would pause the timer and stop polling.
noSleep: true
},
createState: (_c, input: ProjectPrSyncInput): ProjectPrSyncState => ({
createState: (_c, input: RepoPrSyncInput): RepoPrSyncState => ({
workspaceId: input.workspaceId,
repoId: input.repoId,
repoPath: input.repoPath,
@ -56,34 +56,34 @@ export const projectPrSync = actor({
}),
actions: {
async start(c): Promise<void> {
const self = selfProjectPrSync(c);
const self = selfRepoPrSync(c);
await self.send(CONTROL.start, {}, { wait: true, timeout: 15_000 });
},
async stop(c): Promise<void> {
const self = selfProjectPrSync(c);
const self = selfRepoPrSync(c);
await self.send(CONTROL.stop, {}, { wait: true, timeout: 15_000 });
},
async setIntervalMs(c, payload: SetIntervalCommand): Promise<void> {
const self = selfProjectPrSync(c);
const self = selfRepoPrSync(c);
await self.send(CONTROL.setInterval, payload, { wait: true, timeout: 15_000 });
},
async force(c): Promise<void> {
const self = selfProjectPrSync(c);
const self = selfRepoPrSync(c);
await self.send(CONTROL.force, {}, { wait: true, timeout: 5 * 60_000 });
}
},
run: workflow(async (ctx) => {
await runWorkflowPollingLoop<ProjectPrSyncState>(ctx, {
loopName: "project-pr-sync-loop",
await runWorkflowPollingLoop<RepoPrSyncState>(ctx, {
loopName: "repo-pr-sync-loop",
control: CONTROL,
onPoll: async (loopCtx) => {
try {
await pollPrs(loopCtx);
} catch (error) {
logActorWarning("project-pr-sync", "poll failed", {
logActorWarning("repo-pr-sync", "poll failed", {
error: resolveErrorMessage(error),
stack: resolveErrorStack(error)
});

View file

@ -2,8 +2,8 @@ import { actorSqliteDb } from "../../../db/actor-sqlite.js";
import * as schema from "./schema.js";
import migrations from "./migrations.js";
export const projectDb = actorSqliteDb({
actorName: "project",
export const repoDb = actorSqliteDb({
actorName: "repo",
schema,
migrations,
migrationsFolderUrl: new URL("./drizzle/", import.meta.url),

View file

@ -0,0 +1,7 @@
import { defineConfig } from "rivetkit/db/drizzle";
export default defineConfig({
out: "./src/actors/repo/db/drizzle",
schema: "./src/actors/repo/db/schema.ts",
});

View file

@ -1,4 +1,4 @@
CREATE TABLE `handoff_index` (
CREATE TABLE `task_index` (
`handoff_id` text PRIMARY KEY NOT NULL,
`branch_name` text,
`created_at` integer NOT NULL,

View file

@ -69,8 +69,8 @@ CREATE TABLE \`pr_cache\` (
);
--> statement-breakpoint
ALTER TABLE \`branches\` DROP COLUMN \`worktree_path\`;`,
m0002: `CREATE TABLE \`handoff_index\` (
\`handoff_id\` text PRIMARY KEY NOT NULL,
m0002: `CREATE TABLE \`task_index\` (
\`task_id\` text PRIMARY KEY NOT NULL,
\`branch_name\` text,
\`created_at\` integer NOT NULL,
\`updated_at\` integer NOT NULL

View file

@ -1,6 +1,6 @@
import { integer, sqliteTable, text } from "rivetkit/db/drizzle";
// SQLite is per project actor instance (workspaceId+repoId), so no workspaceId/repoId columns needed.
// SQLite is per repo actor instance (workspaceId+repoId), so no workspaceId/repoId columns needed.
export const branches = sqliteTable("branches", {
branchName: text("branch_name").notNull().primaryKey(),
@ -36,8 +36,8 @@ export const prCache = sqliteTable("pr_cache", {
updatedAt: integer("updated_at").notNull(),
});
export const handoffIndex = sqliteTable("handoff_index", {
handoffId: text("handoff_id").notNull().primaryKey(),
export const taskIndex = sqliteTable("task_index", {
taskId: text("task_id").notNull().primaryKey(),
branchName: text("branch_name"),
createdAt: integer("created_at").notNull(),
updatedAt: integer("updated_at").notNull()

View file

@ -0,0 +1,28 @@
import { actor, queue } from "rivetkit";
import { workflow } from "rivetkit/workflow";
import { repoDb } from "./db/db.js";
import { REPO_QUEUE_NAMES, repoActions, runRepoWorkflow } from "./actions.js";
export interface RepoInput {
workspaceId: string;
repoId: string;
remoteUrl: string;
}
export const repo = actor({
db: repoDb,
queues: Object.fromEntries(REPO_QUEUE_NAMES.map((name) => [name, queue()])),
options: {
actionTimeout: 5 * 60_000,
},
createState: (_c, input: RepoInput) => ({
workspaceId: input.workspaceId,
repoId: input.repoId,
remoteUrl: input.remoteUrl,
localPath: null as string | null,
syncActorsStarted: false,
taskIndexHydrated: false
}),
actions: repoActions,
run: workflow(runRepoWorkflow),
});

View file

@ -1,14 +1,14 @@
import { actor, queue } from "rivetkit";
import { workflow } from "rivetkit/workflow";
import type { ProviderId } from "@sandbox-agent/factory-shared";
import { getHandoff, getSandboxInstance, selfHandoffStatusSync } from "../handles.js";
import { getTask, getSandboxInstance, selfTaskStatusSync } from "../handles.js";
import { logActorWarning, resolveErrorMessage, resolveErrorStack } from "../logging.js";
import { type PollingControlState, runWorkflowPollingLoop } from "../polling.js";
export interface HandoffStatusSyncInput {
export interface TaskStatusSyncInput {
workspaceId: string;
repoId: string;
handoffId: string;
taskId: string;
providerId: ProviderId;
sandboxId: string;
sessionId: string;
@ -19,27 +19,27 @@ interface SetIntervalCommand {
intervalMs: number;
}
interface HandoffStatusSyncState extends PollingControlState {
interface TaskStatusSyncState extends PollingControlState {
workspaceId: string;
repoId: string;
handoffId: string;
taskId: string;
providerId: ProviderId;
sandboxId: string;
sessionId: string;
}
const CONTROL = {
start: "handoff.status_sync.control.start",
stop: "handoff.status_sync.control.stop",
setInterval: "handoff.status_sync.control.set_interval",
force: "handoff.status_sync.control.force"
start: "task.status_sync.control.start",
stop: "task.status_sync.control.stop",
setInterval: "task.status_sync.control.set_interval",
force: "task.status_sync.control.force"
} as const;
async function pollSessionStatus(c: { state: HandoffStatusSyncState }): Promise<void> {
async function pollSessionStatus(c: { state: TaskStatusSyncState }): Promise<void> {
const sandboxInstance = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, c.state.sandboxId);
const status = await sandboxInstance.sessionStatus({ sessionId: c.state.sessionId });
const parent = getHandoff(c, c.state.workspaceId, c.state.repoId, c.state.handoffId);
const parent = getTask(c, c.state.workspaceId, c.state.taskId);
await parent.syncWorkbenchSessionStatus({
sessionId: c.state.sessionId,
status: status.status,
@ -47,7 +47,7 @@ async function pollSessionStatus(c: { state: HandoffStatusSyncState }): Promise<
});
}
export const handoffStatusSync = actor({
export const taskStatusSync = actor({
queues: {
[CONTROL.start]: queue(),
[CONTROL.stop]: queue(),
@ -58,10 +58,10 @@ export const handoffStatusSync = actor({
// Polling actors rely on timer-based wakeups; sleeping would pause the timer and stop polling.
noSleep: true
},
createState: (_c, input: HandoffStatusSyncInput): HandoffStatusSyncState => ({
createState: (_c, input: TaskStatusSyncInput): TaskStatusSyncState => ({
workspaceId: input.workspaceId,
repoId: input.repoId,
handoffId: input.handoffId,
taskId: input.taskId,
providerId: input.providerId,
sandboxId: input.sandboxId,
sessionId: input.sessionId,
@ -70,34 +70,34 @@ export const handoffStatusSync = actor({
}),
actions: {
async start(c): Promise<void> {
const self = selfHandoffStatusSync(c);
const self = selfTaskStatusSync(c);
await self.send(CONTROL.start, {}, { wait: true, timeout: 15_000 });
},
async stop(c): Promise<void> {
const self = selfHandoffStatusSync(c);
const self = selfTaskStatusSync(c);
await self.send(CONTROL.stop, {}, { wait: true, timeout: 15_000 });
},
async setIntervalMs(c, payload: SetIntervalCommand): Promise<void> {
const self = selfHandoffStatusSync(c);
const self = selfTaskStatusSync(c);
await self.send(CONTROL.setInterval, payload, { wait: true, timeout: 15_000 });
},
async force(c): Promise<void> {
const self = selfHandoffStatusSync(c);
const self = selfTaskStatusSync(c);
await self.send(CONTROL.force, {}, { wait: true, timeout: 5 * 60_000 });
}
},
run: workflow(async (ctx) => {
await runWorkflowPollingLoop<HandoffStatusSyncState>(ctx, {
loopName: "handoff-status-sync-loop",
await runWorkflowPollingLoop<TaskStatusSyncState>(ctx, {
loopName: "task-status-sync-loop",
control: CONTROL,
onPoll: async (loopCtx) => {
try {
await pollSessionStatus(loopCtx);
} catch (error) {
logActorWarning("handoff-status-sync", "poll failed", {
logActorWarning("task-status-sync", "poll failed", {
error: resolveErrorMessage(error),
stack: resolveErrorStack(error)
});

View file

@ -2,8 +2,8 @@ import { actorSqliteDb } from "../../../db/actor-sqlite.js";
import * as schema from "./schema.js";
import migrations from "./migrations.js";
export const handoffDb = actorSqliteDb({
actorName: "handoff",
export const taskDb = actorSqliteDb({
actorName: "task",
schema,
migrations,
migrationsFolderUrl: new URL("./drizzle/", import.meta.url),

View file

@ -0,0 +1,7 @@
import { defineConfig } from "rivetkit/db/drizzle";
export default defineConfig({
out: "./src/actors/task/db/drizzle",
schema: "./src/actors/task/db/schema.ts",
});

View file

@ -52,7 +52,7 @@ const journal = {
export default {
journal,
migrations: {
m0000: `CREATE TABLE \`handoff\` (
m0000: `CREATE TABLE \`task\` (
\`id\` integer PRIMARY KEY NOT NULL,
\`branch_name\` text NOT NULL,
\`title\` text NOT NULL,
@ -68,7 +68,7 @@ export default {
\`updated_at\` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE \`handoff_runtime\` (
CREATE TABLE \`task_runtime\` (
\`id\` integer PRIMARY KEY NOT NULL,
\`sandbox_id\` text,
\`session_id\` text,
@ -77,13 +77,13 @@ CREATE TABLE \`handoff_runtime\` (
\`updated_at\` integer NOT NULL
);
`,
m0001: `ALTER TABLE \`handoff\` DROP COLUMN \`auto_committed\`;--> statement-breakpoint
ALTER TABLE \`handoff\` DROP COLUMN \`pushed\`;--> statement-breakpoint
ALTER TABLE \`handoff\` DROP COLUMN \`needs_push\`;`,
m0002: `ALTER TABLE \`handoff_runtime\` RENAME COLUMN "sandbox_id" TO "active_sandbox_id";--> statement-breakpoint
ALTER TABLE \`handoff_runtime\` RENAME COLUMN "session_id" TO "active_session_id";--> statement-breakpoint
ALTER TABLE \`handoff_runtime\` RENAME COLUMN "switch_target" TO "active_switch_target";--> statement-breakpoint
CREATE TABLE \`handoff_sandboxes\` (
m0001: `ALTER TABLE \`task\` DROP COLUMN \`auto_committed\`;--> statement-breakpoint
ALTER TABLE \`task\` DROP COLUMN \`pushed\`;--> statement-breakpoint
ALTER TABLE \`task\` DROP COLUMN \`needs_push\`;`,
m0002: `ALTER TABLE \`task_runtime\` RENAME COLUMN "sandbox_id" TO "active_sandbox_id";--> statement-breakpoint
ALTER TABLE \`task_runtime\` RENAME COLUMN "session_id" TO "active_session_id";--> statement-breakpoint
ALTER TABLE \`task_runtime\` RENAME COLUMN "switch_target" TO "active_switch_target";--> statement-breakpoint
CREATE TABLE \`task_sandboxes\` (
\`sandbox_id\` text PRIMARY KEY NOT NULL,
\`provider_id\` text NOT NULL,
\`switch_target\` text NOT NULL,
@ -93,9 +93,9 @@ CREATE TABLE \`handoff_sandboxes\` (
\`updated_at\` integer NOT NULL
);
--> statement-breakpoint
ALTER TABLE \`handoff_runtime\` ADD \`active_cwd\` text;
ALTER TABLE \`task_runtime\` ADD \`active_cwd\` text;
--> statement-breakpoint
INSERT INTO \`handoff_sandboxes\` (
INSERT INTO \`task_sandboxes\` (
\`sandbox_id\`,
\`provider_id\`,
\`switch_target\`,
@ -106,25 +106,25 @@ INSERT INTO \`handoff_sandboxes\` (
)
SELECT
r.\`active_sandbox_id\`,
(SELECT h.\`provider_id\` FROM \`handoff\` h WHERE h.\`id\` = 1),
(SELECT h.\`provider_id\` FROM \`task\` h WHERE h.\`id\` = 1),
r.\`active_switch_target\`,
r.\`active_cwd\`,
r.\`status_message\`,
COALESCE((SELECT h.\`created_at\` FROM \`handoff\` h WHERE h.\`id\` = 1), r.\`updated_at\`),
COALESCE((SELECT h.\`created_at\` FROM \`task\` h WHERE h.\`id\` = 1), r.\`updated_at\`),
r.\`updated_at\`
FROM \`handoff_runtime\` r
FROM \`task_runtime\` r
WHERE
r.\`id\` = 1
AND r.\`active_sandbox_id\` IS NOT NULL
AND r.\`active_switch_target\` IS NOT NULL
ON CONFLICT(\`sandbox_id\`) DO NOTHING;
`,
m0003: `-- Allow handoffs to exist before their branch/title are determined.
m0003: `-- Allow tasks to exist before their branch/title are determined.
-- Drizzle doesn't support altering column nullability in SQLite directly, so rebuild the table.
PRAGMA foreign_keys=off;
CREATE TABLE \`handoff__new\` (
CREATE TABLE \`task__new\` (
\`id\` integer PRIMARY KEY NOT NULL,
\`branch_name\` text,
\`title\` text,
@ -137,7 +137,7 @@ CREATE TABLE \`handoff__new\` (
\`updated_at\` integer NOT NULL
);
INSERT INTO \`handoff__new\` (
INSERT INTO \`task__new\` (
\`id\`,
\`branch_name\`,
\`title\`,
@ -160,10 +160,10 @@ SELECT
\`pr_submitted\`,
\`created_at\`,
\`updated_at\`
FROM \`handoff\`;
FROM \`task\`;
DROP TABLE \`handoff\`;
ALTER TABLE \`handoff__new\` RENAME TO \`handoff\`;
DROP TABLE \`task\`;
ALTER TABLE \`task__new\` RENAME TO \`task\`;
PRAGMA foreign_keys=on;
@ -175,10 +175,10 @@ PRAGMA foreign_keys=on;
PRAGMA foreign_keys=off;
--> statement-breakpoint
DROP TABLE IF EXISTS \`handoff__new\`;
DROP TABLE IF EXISTS \`task__new\`;
--> statement-breakpoint
CREATE TABLE \`handoff__new\` (
CREATE TABLE \`task__new\` (
\`id\` integer PRIMARY KEY NOT NULL,
\`branch_name\` text,
\`title\` text,
@ -192,7 +192,7 @@ CREATE TABLE \`handoff__new\` (
);
--> statement-breakpoint
INSERT INTO \`handoff__new\` (
INSERT INTO \`task__new\` (
\`id\`,
\`branch_name\`,
\`title\`,
@ -215,19 +215,19 @@ SELECT
\`pr_submitted\`,
\`created_at\`,
\`updated_at\`
FROM \`handoff\`;
FROM \`task\`;
--> statement-breakpoint
DROP TABLE \`handoff\`;
DROP TABLE \`task\`;
--> statement-breakpoint
ALTER TABLE \`handoff__new\` RENAME TO \`handoff\`;
ALTER TABLE \`task__new\` RENAME TO \`task\`;
--> statement-breakpoint
PRAGMA foreign_keys=on;
`,
m0005: `ALTER TABLE \`handoff_sandboxes\` ADD \`sandbox_actor_id\` text;`,
m0006: `CREATE TABLE \`handoff_workbench_sessions\` (
m0005: `ALTER TABLE \`task_sandboxes\` ADD \`sandbox_actor_id\` text;`,
m0006: `CREATE TABLE \`task_workbench_sessions\` (
\`session_id\` text PRIMARY KEY NOT NULL,
\`session_name\` text NOT NULL,
\`model\` text NOT NULL,

View file

@ -1,7 +1,7 @@
import { integer, sqliteTable, text } from "rivetkit/db/drizzle";
// SQLite is per handoff actor instance, so these tables only ever store one row (id=1).
export const handoff = sqliteTable("handoff", {
// SQLite is per task actor instance, so these tables only ever store one row (id=1).
export const task = sqliteTable("task", {
id: integer("id").primaryKey(),
branchName: text("branch_name"),
title: text("title"),
@ -14,7 +14,7 @@ export const handoff = sqliteTable("handoff", {
updatedAt: integer("updated_at").notNull(),
});
export const handoffRuntime = sqliteTable("handoff_runtime", {
export const taskRuntime = sqliteTable("task_runtime", {
id: integer("id").primaryKey(),
activeSandboxId: text("active_sandbox_id"),
activeSessionId: text("active_session_id"),
@ -24,7 +24,7 @@ export const handoffRuntime = sqliteTable("handoff_runtime", {
updatedAt: integer("updated_at").notNull(),
});
export const handoffSandboxes = sqliteTable("handoff_sandboxes", {
export const taskSandboxes = sqliteTable("task_sandboxes", {
sandboxId: text("sandbox_id").notNull().primaryKey(),
providerId: text("provider_id").notNull(),
sandboxActorId: text("sandbox_actor_id"),
@ -35,7 +35,7 @@ export const handoffSandboxes = sqliteTable("handoff_sandboxes", {
updatedAt: integer("updated_at").notNull(),
});
export const handoffWorkbenchSessions = sqliteTable("handoff_workbench_sessions", {
export const taskWorkbenchSessions = sqliteTable("task_workbench_sessions", {
sessionId: text("session_id").notNull().primaryKey(),
sessionName: text("session_name").notNull(),
model: text("model").notNull(),

View file

@ -0,0 +1,400 @@
import { actor, queue } from "rivetkit";
import { workflow } from "rivetkit/workflow";
import type {
AgentType,
TaskRecord,
TaskWorkbenchChangeModelInput,
TaskWorkbenchRenameInput,
TaskWorkbenchRenameSessionInput,
TaskWorkbenchSetSessionUnreadInput,
TaskWorkbenchSendMessageInput,
TaskWorkbenchUpdateDraftInput,
ProviderId
} from "@sandbox-agent/factory-shared";
import { expectQueueResponse } from "../../services/queue.js";
import { selfTask } from "../handles.js";
import { taskDb } from "./db/db.js";
import { getCurrentRecord } from "./workflow/common.js";
import {
changeWorkbenchModel,
closeWorkbenchSession,
createWorkbenchSession,
getWorkbenchTask,
markWorkbenchUnread,
publishWorkbenchPr,
renameWorkbenchBranch,
renameWorkbenchTask,
renameWorkbenchSession,
revertWorkbenchFile,
sendWorkbenchMessage,
syncWorkbenchSessionStatus,
setWorkbenchSessionUnread,
stopWorkbenchSession,
updateWorkbenchDraft
} from "./workbench.js";
import {
TASK_QUEUE_NAMES,
taskWorkflowQueueName,
runTaskWorkflow
} from "./workflow/index.js";
export interface TaskInput {
workspaceId: string;
repoId: string;
repoIds?: string[];
taskId: string;
repoRemote: string;
repoLocalPath: string;
branchName: string | null;
title: string | null;
task: string;
providerId: ProviderId;
agentType: AgentType | null;
explicitTitle: string | null;
explicitBranchName: string | null;
}
interface InitializeCommand {
providerId?: ProviderId;
}
interface TaskActionCommand {
reason?: string;
}
interface TaskTabCommand {
tabId: string;
}
interface TaskStatusSyncCommand {
sessionId: string;
status: "running" | "idle" | "error";
at: number;
}
interface TaskWorkbenchValueCommand {
value: string;
}
interface TaskWorkbenchSessionTitleCommand {
sessionId: string;
title: string;
}
interface TaskWorkbenchSessionUnreadCommand {
sessionId: string;
unread: boolean;
}
interface TaskWorkbenchUpdateDraftCommand {
sessionId: string;
text: string;
attachments: Array<any>;
}
interface TaskWorkbenchChangeModelCommand {
sessionId: string;
model: string;
}
interface TaskWorkbenchSendMessageCommand {
sessionId: string;
text: string;
attachments: Array<any>;
}
interface TaskWorkbenchCreateSessionCommand {
model?: string;
}
interface TaskWorkbenchSessionCommand {
sessionId: string;
}
export const task = actor({
db: taskDb,
queues: Object.fromEntries(TASK_QUEUE_NAMES.map((name) => [name, queue()])),
options: {
actionTimeout: 5 * 60_000
},
createState: (_c, input: TaskInput) => ({
workspaceId: input.workspaceId,
repoId: input.repoId,
repoIds: input.repoIds?.length ? [...new Set(input.repoIds)] : [input.repoId],
taskId: input.taskId,
repoRemote: input.repoRemote,
repoLocalPath: input.repoLocalPath,
branchName: input.branchName,
title: input.title,
task: input.task,
providerId: input.providerId,
agentType: input.agentType,
explicitTitle: input.explicitTitle,
explicitBranchName: input.explicitBranchName,
initialized: false,
previousStatus: null as string | null,
}),
actions: {
async initialize(c, cmd: InitializeCommand): Promise<TaskRecord> {
const self = selfTask(c);
const result = await self.send(taskWorkflowQueueName("task.command.initialize"), cmd ?? {}, {
wait: true,
timeout: 60_000,
});
return expectQueueResponse<TaskRecord>(result);
},
async provision(c, cmd: InitializeCommand): Promise<{ ok: true }> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.provision"), cmd ?? {}, {
wait: true,
timeout: 30 * 60_000,
});
return { ok: true };
},
async attach(c, cmd?: TaskActionCommand): Promise<{ target: string; sessionId: string | null }> {
const self = selfTask(c);
const result = await self.send(taskWorkflowQueueName("task.command.attach"), cmd ?? {}, {
wait: true,
timeout: 20_000
});
return expectQueueResponse<{ target: string; sessionId: string | null }>(result);
},
async switch(c): Promise<{ switchTarget: string }> {
const self = selfTask(c);
const result = await self.send(taskWorkflowQueueName("task.command.switch"), {}, {
wait: true,
timeout: 20_000
});
return expectQueueResponse<{ switchTarget: string }>(result);
},
async push(c, cmd?: TaskActionCommand): Promise<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.push"), cmd ?? {}, {
wait: true,
timeout: 180_000
});
},
async sync(c, cmd?: TaskActionCommand): Promise<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.sync"), cmd ?? {}, {
wait: true,
timeout: 30_000
});
},
async merge(c, cmd?: TaskActionCommand): Promise<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.merge"), cmd ?? {}, {
wait: true,
timeout: 30_000
});
},
async archive(c, cmd?: TaskActionCommand): Promise<void> {
const self = selfTask(c);
void self
.send(taskWorkflowQueueName("task.command.archive"), cmd ?? {}, {
wait: true,
timeout: 60_000,
})
.catch((error: unknown) => {
c.log.warn({
msg: "archive command failed",
error: error instanceof Error ? error.message : String(error),
});
});
},
async kill(c, cmd?: TaskActionCommand): Promise<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.kill"), cmd ?? {}, {
wait: true,
timeout: 60_000
});
},
async get(c): Promise<TaskRecord> {
return await getCurrentRecord({ db: c.db, state: c.state });
},
async getWorkbench(c) {
return await getWorkbenchTask(c);
},
async markWorkbenchUnread(c): Promise<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.workbench.mark_unread"), {}, {
wait: true,
timeout: 20_000,
});
},
async renameWorkbenchTask(c, input: TaskWorkbenchRenameInput): Promise<void> {
const self = selfTask(c);
await self.send(
taskWorkflowQueueName("task.command.workbench.rename_task"),
{ value: input.value } satisfies TaskWorkbenchValueCommand,
{
wait: true,
timeout: 20_000,
},
);
},
async renameWorkbenchBranch(c, input: TaskWorkbenchRenameInput): Promise<void> {
const self = selfTask(c);
await self.send(
taskWorkflowQueueName("task.command.workbench.rename_branch"),
{ value: input.value } satisfies TaskWorkbenchValueCommand,
{
wait: true,
timeout: 5 * 60_000,
},
);
},
async createWorkbenchSession(c, input?: { model?: string }): Promise<{ tabId: string }> {
const self = selfTask(c);
const result = await self.send(
taskWorkflowQueueName("task.command.workbench.create_session"),
{ ...(input?.model ? { model: input.model } : {}) } satisfies TaskWorkbenchCreateSessionCommand,
{
wait: true,
timeout: 5 * 60_000,
},
);
return expectQueueResponse<{ tabId: string }>(result);
},
async renameWorkbenchSession(c, input: TaskWorkbenchRenameSessionInput): Promise<void> {
const self = selfTask(c);
await self.send(
taskWorkflowQueueName("task.command.workbench.rename_session"),
{ sessionId: input.tabId, title: input.title } satisfies TaskWorkbenchSessionTitleCommand,
{
wait: true,
timeout: 20_000,
},
);
},
async setWorkbenchSessionUnread(c, input: TaskWorkbenchSetSessionUnreadInput): Promise<void> {
const self = selfTask(c);
await self.send(
taskWorkflowQueueName("task.command.workbench.set_session_unread"),
{ sessionId: input.tabId, unread: input.unread } satisfies TaskWorkbenchSessionUnreadCommand,
{
wait: true,
timeout: 20_000,
},
);
},
async updateWorkbenchDraft(c, input: TaskWorkbenchUpdateDraftInput): Promise<void> {
const self = selfTask(c);
await self.send(
taskWorkflowQueueName("task.command.workbench.update_draft"),
{
sessionId: input.tabId,
text: input.text,
attachments: input.attachments,
} satisfies TaskWorkbenchUpdateDraftCommand,
{
wait: true,
timeout: 20_000,
},
);
},
async changeWorkbenchModel(c, input: TaskWorkbenchChangeModelInput): Promise<void> {
const self = selfTask(c);
await self.send(
taskWorkflowQueueName("task.command.workbench.change_model"),
{ sessionId: input.tabId, model: input.model } satisfies TaskWorkbenchChangeModelCommand,
{
wait: true,
timeout: 20_000,
},
);
},
async sendWorkbenchMessage(c, input: TaskWorkbenchSendMessageInput): Promise<void> {
const self = selfTask(c);
await self.send(
taskWorkflowQueueName("task.command.workbench.send_message"),
{
sessionId: input.tabId,
text: input.text,
attachments: input.attachments,
} satisfies TaskWorkbenchSendMessageCommand,
{
wait: true,
timeout: 10 * 60_000,
},
);
},
async stopWorkbenchSession(c, input: TaskTabCommand): Promise<void> {
const self = selfTask(c);
await self.send(
taskWorkflowQueueName("task.command.workbench.stop_session"),
{ sessionId: input.tabId } satisfies TaskWorkbenchSessionCommand,
{
wait: true,
timeout: 5 * 60_000,
},
);
},
async syncWorkbenchSessionStatus(c, input: TaskStatusSyncCommand): Promise<void> {
const self = selfTask(c);
await self.send(
taskWorkflowQueueName("task.command.workbench.sync_session_status"),
input,
{
wait: true,
timeout: 20_000,
},
);
},
async closeWorkbenchSession(c, input: TaskTabCommand): Promise<void> {
const self = selfTask(c);
await self.send(
taskWorkflowQueueName("task.command.workbench.close_session"),
{ sessionId: input.tabId } satisfies TaskWorkbenchSessionCommand,
{
wait: true,
timeout: 5 * 60_000,
},
);
},
async publishWorkbenchPr(c): Promise<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.workbench.publish_pr"), {}, {
wait: true,
timeout: 10 * 60_000,
});
},
async revertWorkbenchFile(c, input: { path: string }): Promise<void> {
const self = selfTask(c);
await self.send(
taskWorkflowQueueName("task.command.workbench.revert_file"),
input,
{
wait: true,
timeout: 5 * 60_000,
},
);
}
},
run: workflow(runTaskWorkflow)
});
export { TASK_QUEUE_NAMES };

View file

@ -3,19 +3,19 @@ import { asc, eq } from "drizzle-orm";
import { getActorRuntimeContext } from "../context.js";
import { repoLabelFromRemote } from "../../services/repo.js";
import {
getOrCreateHandoffStatusSync,
getOrCreateProject,
getOrCreateTaskStatusSync,
getOrCreateRepo,
getOrCreateWorkspace,
getSandboxInstance,
} from "../handles.js";
import { handoff as handoffTable, handoffRuntime, handoffWorkbenchSessions } from "./db/schema.js";
import { task as taskTable, taskRuntime, taskWorkbenchSessions } from "./db/schema.js";
import { getCurrentRecord } from "./workflow/common.js";
const STATUS_SYNC_INTERVAL_MS = 1_000;
async function ensureWorkbenchSessionTable(c: any): Promise<void> {
await c.db.execute(`
CREATE TABLE IF NOT EXISTS handoff_workbench_sessions (
CREATE TABLE IF NOT EXISTS task_workbench_sessions (
session_id text PRIMARY KEY NOT NULL,
session_name text NOT NULL,
model text NOT NULL,
@ -77,8 +77,8 @@ async function listSessionMetaRows(c: any, options?: { includeClosed?: boolean }
await ensureWorkbenchSessionTable(c);
const rows = await c.db
.select()
.from(handoffWorkbenchSessions)
.orderBy(asc(handoffWorkbenchSessions.createdAt))
.from(taskWorkbenchSessions)
.orderBy(asc(taskWorkbenchSessions.createdAt))
.all();
const mapped = rows.map((row: any) => ({
...row,
@ -107,8 +107,8 @@ async function readSessionMeta(c: any, sessionId: string): Promise<any | null> {
await ensureWorkbenchSessionTable(c);
const row = await c.db
.select()
.from(handoffWorkbenchSessions)
.where(eq(handoffWorkbenchSessions.sessionId, sessionId))
.from(taskWorkbenchSessions)
.where(eq(taskWorkbenchSessions.sessionId, sessionId))
.get();
if (!row) {
@ -145,7 +145,7 @@ async function ensureSessionMeta(c: any, params: {
const unread = params.unread ?? false;
await c.db
.insert(handoffWorkbenchSessions)
.insert(taskWorkbenchSessions)
.values({
sessionId: params.sessionId,
sessionName,
@ -168,12 +168,12 @@ async function ensureSessionMeta(c: any, params: {
async function updateSessionMeta(c: any, sessionId: string, values: Record<string, unknown>): Promise<any> {
await ensureSessionMeta(c, { sessionId });
await c.db
.update(handoffWorkbenchSessions)
.update(taskWorkbenchSessions)
.set({
...values,
updatedAt: Date.now(),
})
.where(eq(handoffWorkbenchSessions.sessionId, sessionId))
.where(eq(taskWorkbenchSessions.sessionId, sessionId))
.run();
return await readSessionMeta(c, sessionId);
}
@ -408,13 +408,13 @@ async function readPullRequestSummary(c: any, branchName: string | null) {
}
try {
const project = await getOrCreateProject(
const repo = await getOrCreateRepo(
c,
c.state.workspaceId,
c.state.repoId,
c.state.repoRemote,
);
return await project.getPullRequestForBranch({ branchName });
return await repo.getPullRequestForBranch({ branchName });
} catch {
return null;
}
@ -432,7 +432,7 @@ export async function ensureWorkbenchSeeded(c: any): Promise<any> {
return record;
}
export async function getWorkbenchHandoff(c: any): Promise<any> {
export async function getWorkbenchTask(c: any): Promise<any> {
const record = await ensureWorkbenchSeeded(c);
const gitState = await collectWorkbenchGitState(c, record);
const sessions = await listSessionMetaRows(c);
@ -467,9 +467,10 @@ export async function getWorkbenchHandoff(c: any): Promise<any> {
}
return {
id: c.state.handoffId,
id: c.state.taskId,
repoId: c.state.repoId,
title: record.title ?? "New Handoff",
repoIds: c.state.repoIds?.length ? [...c.state.repoIds] : [c.state.repoId],
title: record.title ?? "New Task",
status: record.status === "archived" ? "archived" : record.status === "running" ? "running" : record.status === "idle" ? "idle" : "new",
repoName: repoLabelFromRemote(c.state.repoRemote),
updatedAtMs: record.updatedAt,
@ -482,19 +483,19 @@ export async function getWorkbenchHandoff(c: any): Promise<any> {
};
}
export async function renameWorkbenchHandoff(c: any, value: string): Promise<void> {
export async function renameWorkbenchTask(c: any, value: string): Promise<void> {
const nextTitle = value.trim();
if (!nextTitle) {
throw new Error("handoff title is required");
throw new Error("task title is required");
}
await c.db
.update(handoffTable)
.update(taskTable)
.set({
title: nextTitle,
updatedAt: Date.now(),
})
.where(eq(handoffTable.id, 1))
.where(eq(taskTable.id, 1))
.run();
c.state.title = nextTitle;
await notifyWorkbenchUpdated(c);
@ -508,7 +509,7 @@ export async function renameWorkbenchBranch(c: any, value: string): Promise<void
const record = await ensureWorkbenchSeeded(c);
if (!record.branchName) {
throw new Error("cannot rename branch before handoff branch exists");
throw new Error("cannot rename branch before task branch exists");
}
if (!record.activeSandboxId) {
throw new Error("cannot rename branch without an active sandbox");
@ -535,18 +536,18 @@ export async function renameWorkbenchBranch(c: any, value: string): Promise<void
}
await c.db
.update(handoffTable)
.update(taskTable)
.set({
branchName: nextBranch,
updatedAt: Date.now(),
})
.where(eq(handoffTable.id, 1))
.where(eq(taskTable.id, 1))
.run();
c.state.branchName = nextBranch;
const project = await getOrCreateProject(c, c.state.workspaceId, c.state.repoId, c.state.repoRemote);
await project.registerHandoffBranch({
handoffId: c.state.handoffId,
const repo = await getOrCreateRepo(c, c.state.workspaceId, c.state.repoId, c.state.repoRemote);
await repo.registerTaskBranch({
taskId: c.state.taskId,
branchName: nextBranch,
});
await notifyWorkbenchUpdated(c);
@ -650,25 +651,25 @@ export async function sendWorkbenchMessage(c: any, sessionId: string, text: stri
});
await c.db
.update(handoffRuntime)
.update(taskRuntime)
.set({
activeSessionId: sessionId,
updatedAt: Date.now(),
})
.where(eq(handoffRuntime.id, 1))
.where(eq(taskRuntime.id, 1))
.run();
const sync = await getOrCreateHandoffStatusSync(
const sync = await getOrCreateTaskStatusSync(
c,
c.state.workspaceId,
c.state.repoId,
c.state.handoffId,
c.state.taskId,
record.activeSandboxId,
sessionId,
{
workspaceId: c.state.workspaceId,
repoId: c.state.repoId,
handoffId: c.state.handoffId,
taskId: c.state.taskId,
providerId: c.state.providerId,
sandboxId: record.activeSandboxId,
sessionId,
@ -708,12 +709,12 @@ export async function syncWorkbenchSessionStatus(
const mappedStatus = status === "running" ? "running" : status === "error" ? "error" : "idle";
if (record.status !== mappedStatus) {
await c.db
.update(handoffTable)
.update(taskTable)
.set({
status: mappedStatus,
updatedAt: at,
})
.where(eq(handoffTable.id, 1))
.where(eq(taskTable.id, 1))
.run();
changed = true;
}
@ -721,12 +722,12 @@ export async function syncWorkbenchSessionStatus(
const statusMessage = `session:${status}`;
if (record.statusMessage !== statusMessage) {
await c.db
.update(handoffRuntime)
.update(taskRuntime)
.set({
statusMessage,
updatedAt: at,
})
.where(eq(handoffRuntime.id, 1))
.where(eq(taskRuntime.id, 1))
.run();
changed = true;
}
@ -777,12 +778,12 @@ export async function closeWorkbenchSession(c: any, sessionId: string): Promise<
});
if (record.activeSessionId === sessionId) {
await c.db
.update(handoffRuntime)
.update(taskRuntime)
.set({
activeSessionId: null,
updatedAt: Date.now(),
})
.where(eq(handoffRuntime.id, 1))
.where(eq(taskRuntime.id, 1))
.run();
}
await notifyWorkbenchUpdated(c);
@ -812,12 +813,12 @@ export async function publishWorkbenchPr(c: any): Promise<void> {
record.title ?? c.state.task,
);
await c.db
.update(handoffTable)
.update(taskTable)
.set({
prSubmitted: 1,
updatedAt: Date.now(),
})
.where(eq(handoffTable.id, 1))
.where(eq(taskTable.id, 1))
.run();
await notifyWorkbenchUpdated(c);
}

View file

@ -1,10 +1,10 @@
// @ts-nocheck
import { eq } from "drizzle-orm";
import { getActorRuntimeContext } from "../../context.js";
import { getOrCreateHandoffStatusSync } from "../../handles.js";
import { getOrCreateTaskStatusSync } from "../../handles.js";
import { logActorWarning, resolveErrorMessage } from "../../logging.js";
import { handoff as handoffTable, handoffRuntime } from "../db/schema.js";
import { HANDOFF_ROW_ID, appendHistory, getCurrentRecord, setHandoffState } from "./common.js";
import { task as taskTable, taskRuntime } from "../db/schema.js";
import { HANDOFF_ROW_ID, appendHistory, getCurrentRecord, setTaskState } from "./common.js";
import { pushActiveBranchActivity } from "./push.js";
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
@ -36,7 +36,7 @@ export async function handleAttachActivity(loopCtx: any, msg: any): Promise<void
sandboxId: record.activeSandboxId ?? ""
});
await appendHistory(loopCtx, "handoff.attach", {
await appendHistory(loopCtx, "task.attach", {
target: target.target,
sessionId: record.activeSessionId
});
@ -50,9 +50,9 @@ export async function handleAttachActivity(loopCtx: any, msg: any): Promise<void
export async function handleSwitchActivity(loopCtx: any, msg: any): Promise<void> {
const db = loopCtx.db;
const runtime = await db
.select({ switchTarget: handoffRuntime.activeSwitchTarget })
.from(handoffRuntime)
.where(eq(handoffRuntime.id, HANDOFF_ROW_ID))
.select({ switchTarget: taskRuntime.activeSwitchTarget })
.from(taskRuntime)
.where(eq(taskRuntime.id, HANDOFF_ROW_ID))
.get();
await msg.complete({ switchTarget: runtime?.switchTarget ?? "" });
@ -61,7 +61,7 @@ export async function handleSwitchActivity(loopCtx: any, msg: any): Promise<void
export async function handlePushActivity(loopCtx: any, msg: any): Promise<void> {
await pushActiveBranchActivity(loopCtx, {
reason: msg.body?.reason ?? null,
historyKind: "handoff.push"
historyKind: "task.push"
});
await msg.complete({ ok: true });
}
@ -74,9 +74,9 @@ export async function handleSimpleCommandActivity(
): Promise<void> {
const db = loopCtx.db;
await db
.update(handoffRuntime)
.update(taskRuntime)
.set({ statusMessage, updatedAt: Date.now() })
.where(eq(handoffRuntime.id, HANDOFF_ROW_ID))
.where(eq(taskRuntime.id, HANDOFF_ROW_ID))
.run();
await appendHistory(loopCtx, historyKind, { reason: msg.body?.reason ?? null });
@ -84,34 +84,34 @@ export async function handleSimpleCommandActivity(
}
export async function handleArchiveActivity(loopCtx: any, msg: any): Promise<void> {
await setHandoffState(loopCtx, "archive_stop_status_sync", "stopping status sync");
await setTaskState(loopCtx, "archive_stop_status_sync", "stopping status sync");
const record = await getCurrentRecord(loopCtx);
if (record.activeSandboxId && record.activeSessionId) {
try {
const sync = await getOrCreateHandoffStatusSync(
const sync = await getOrCreateTaskStatusSync(
loopCtx,
loopCtx.state.workspaceId,
loopCtx.state.repoId,
loopCtx.state.handoffId,
loopCtx.state.taskId,
record.activeSandboxId,
record.activeSessionId,
{
workspaceId: loopCtx.state.workspaceId,
repoId: loopCtx.state.repoId,
handoffId: loopCtx.state.handoffId,
taskId: loopCtx.state.taskId,
providerId: record.providerId,
sandboxId: record.activeSandboxId,
sessionId: record.activeSessionId,
intervalMs: 2_000
}
);
await withTimeout(sync.stop(), 15_000, "handoff status sync stop");
await withTimeout(sync.stop(), 15_000, "task status sync stop");
} catch (error) {
logActorWarning("handoff.commands", "failed to stop status sync during archive", {
logActorWarning("task.commands", "failed to stop status sync during archive", {
workspaceId: loopCtx.state.workspaceId,
repoId: loopCtx.state.repoId,
handoffId: loopCtx.state.handoffId,
taskId: loopCtx.state.taskId,
sandboxId: record.activeSandboxId,
sessionId: record.activeSessionId,
error: resolveErrorMessage(error)
@ -120,14 +120,14 @@ export async function handleArchiveActivity(loopCtx: any, msg: any): Promise<voi
}
if (record.activeSandboxId) {
await setHandoffState(loopCtx, "archive_release_sandbox", "releasing sandbox");
await setTaskState(loopCtx, "archive_release_sandbox", "releasing sandbox");
const { providers } = getActorRuntimeContext();
const activeSandbox =
record.sandboxes.find((sb: any) => sb.sandboxId === record.activeSandboxId) ?? null;
const provider = providers.get(activeSandbox?.providerId ?? record.providerId);
const workspaceId = loopCtx.state.workspaceId;
const repoId = loopCtx.state.repoId;
const handoffId = loopCtx.state.handoffId;
const taskId = loopCtx.state.taskId;
const sandboxId = record.activeSandboxId;
// Do not block archive finalization on provider stop. Some provider stop calls can
@ -140,10 +140,10 @@ export async function handleArchiveActivity(loopCtx: any, msg: any): Promise<voi
45_000,
"provider releaseSandbox"
).catch((error) => {
logActorWarning("handoff.commands", "failed to release sandbox during archive", {
logActorWarning("task.commands", "failed to release sandbox during archive", {
workspaceId,
repoId,
handoffId,
taskId,
sandboxId,
error: resolveErrorMessage(error)
});
@ -151,25 +151,25 @@ export async function handleArchiveActivity(loopCtx: any, msg: any): Promise<voi
}
const db = loopCtx.db;
await setHandoffState(loopCtx, "archive_finalize", "finalizing archive");
await setTaskState(loopCtx, "archive_finalize", "finalizing archive");
await db
.update(handoffTable)
.update(taskTable)
.set({ status: "archived", updatedAt: Date.now() })
.where(eq(handoffTable.id, HANDOFF_ROW_ID))
.where(eq(taskTable.id, HANDOFF_ROW_ID))
.run();
await db
.update(handoffRuntime)
.update(taskRuntime)
.set({ activeSessionId: null, statusMessage: "archived", updatedAt: Date.now() })
.where(eq(handoffRuntime.id, HANDOFF_ROW_ID))
.where(eq(taskRuntime.id, HANDOFF_ROW_ID))
.run();
await appendHistory(loopCtx, "handoff.archive", { reason: msg.body?.reason ?? null });
await appendHistory(loopCtx, "task.archive", { reason: msg.body?.reason ?? null });
await msg.complete({ ok: true });
}
export async function killDestroySandboxActivity(loopCtx: any): Promise<void> {
await setHandoffState(loopCtx, "kill_destroy_sandbox", "destroying sandbox");
await setTaskState(loopCtx, "kill_destroy_sandbox", "destroying sandbox");
const record = await getCurrentRecord(loopCtx);
if (!record.activeSandboxId) {
return;
@ -186,21 +186,21 @@ export async function killDestroySandboxActivity(loopCtx: any): Promise<void> {
}
export async function killWriteDbActivity(loopCtx: any, msg: any): Promise<void> {
await setHandoffState(loopCtx, "kill_finalize", "finalizing kill");
await setTaskState(loopCtx, "kill_finalize", "finalizing kill");
const db = loopCtx.db;
await db
.update(handoffTable)
.update(taskTable)
.set({ status: "killed", updatedAt: Date.now() })
.where(eq(handoffTable.id, HANDOFF_ROW_ID))
.where(eq(taskTable.id, HANDOFF_ROW_ID))
.run();
await db
.update(handoffRuntime)
.update(taskRuntime)
.set({ statusMessage: "killed", updatedAt: Date.now() })
.where(eq(handoffRuntime.id, HANDOFF_ROW_ID))
.where(eq(taskRuntime.id, HANDOFF_ROW_ID))
.run();
await appendHistory(loopCtx, "handoff.kill", { reason: msg.body?.reason ?? null });
await appendHistory(loopCtx, "task.kill", { reason: msg.body?.reason ?? null });
await msg.complete({ ok: true });
}

View file

@ -1,8 +1,8 @@
// @ts-nocheck
import { eq } from "drizzle-orm";
import type { HandoffRecord, HandoffStatus } from "@sandbox-agent/factory-shared";
import type { TaskRecord, TaskStatus } from "@sandbox-agent/factory-shared";
import { getOrCreateWorkspace } from "../../handles.js";
import { handoff as handoffTable, handoffRuntime, handoffSandboxes } from "../db/schema.js";
import { task as taskTable, taskRuntime, taskSandboxes } from "../db/schema.js";
import { historyKey } from "../../keys.js";
export const HANDOFF_ROW_ID = 1;
@ -58,22 +58,22 @@ export function buildAgentPrompt(task: string): string {
return task.trim();
}
export async function setHandoffState(
export async function setTaskState(
ctx: any,
status: HandoffStatus,
status: TaskStatus,
statusMessage?: string
): Promise<void> {
const now = Date.now();
const db = ctx.db;
await db
.update(handoffTable)
.update(taskTable)
.set({ status, updatedAt: now })
.where(eq(handoffTable.id, HANDOFF_ROW_ID))
.where(eq(taskTable.id, HANDOFF_ROW_ID))
.run();
if (statusMessage != null) {
await db
.insert(handoffRuntime)
.insert(taskRuntime)
.values({
id: HANDOFF_ROW_ID,
activeSandboxId: null,
@ -84,7 +84,7 @@ export async function setHandoffState(
updatedAt: now
})
.onConflictDoUpdate({
target: handoffRuntime.id,
target: taskRuntime.id,
set: {
statusMessage,
updatedAt: now
@ -97,50 +97,51 @@ export async function setHandoffState(
await workspace.notifyWorkbenchUpdated({});
}
export async function getCurrentRecord(ctx: any): Promise<HandoffRecord> {
export async function getCurrentRecord(ctx: any): Promise<TaskRecord> {
const db = ctx.db;
const row = await db
.select({
branchName: handoffTable.branchName,
title: handoffTable.title,
task: handoffTable.task,
providerId: handoffTable.providerId,
status: handoffTable.status,
statusMessage: handoffRuntime.statusMessage,
activeSandboxId: handoffRuntime.activeSandboxId,
activeSessionId: handoffRuntime.activeSessionId,
agentType: handoffTable.agentType,
prSubmitted: handoffTable.prSubmitted,
createdAt: handoffTable.createdAt,
updatedAt: handoffTable.updatedAt
branchName: taskTable.branchName,
title: taskTable.title,
task: taskTable.task,
providerId: taskTable.providerId,
status: taskTable.status,
statusMessage: taskRuntime.statusMessage,
activeSandboxId: taskRuntime.activeSandboxId,
activeSessionId: taskRuntime.activeSessionId,
agentType: taskTable.agentType,
prSubmitted: taskTable.prSubmitted,
createdAt: taskTable.createdAt,
updatedAt: taskTable.updatedAt
})
.from(handoffTable)
.leftJoin(handoffRuntime, eq(handoffTable.id, handoffRuntime.id))
.where(eq(handoffTable.id, HANDOFF_ROW_ID))
.from(taskTable)
.leftJoin(taskRuntime, eq(taskTable.id, taskRuntime.id))
.where(eq(taskTable.id, HANDOFF_ROW_ID))
.get();
if (!row) {
throw new Error(`Handoff not found: ${ctx.state.handoffId}`);
throw new Error(`Task not found: ${ctx.state.taskId}`);
}
const sandboxes = await db
.select({
sandboxId: handoffSandboxes.sandboxId,
providerId: handoffSandboxes.providerId,
sandboxActorId: handoffSandboxes.sandboxActorId,
switchTarget: handoffSandboxes.switchTarget,
cwd: handoffSandboxes.cwd,
createdAt: handoffSandboxes.createdAt,
updatedAt: handoffSandboxes.updatedAt,
sandboxId: taskSandboxes.sandboxId,
providerId: taskSandboxes.providerId,
sandboxActorId: taskSandboxes.sandboxActorId,
switchTarget: taskSandboxes.switchTarget,
cwd: taskSandboxes.cwd,
createdAt: taskSandboxes.createdAt,
updatedAt: taskSandboxes.updatedAt,
})
.from(handoffSandboxes)
.from(taskSandboxes)
.all();
return {
workspaceId: ctx.state.workspaceId,
repoId: ctx.state.repoId,
repoIds: ctx.state.repoIds?.length ? [...ctx.state.repoIds] : [ctx.state.repoId],
repoRemote: ctx.state.repoRemote,
handoffId: ctx.state.handoffId,
taskId: ctx.state.taskId,
branchName: row.branchName,
title: row.title,
task: row.task,
@ -171,7 +172,7 @@ export async function getCurrentRecord(ctx: any): Promise<HandoffRecord> {
reviewer: null,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
} as HandoffRecord;
} as TaskRecord;
}
export async function appendHistory(ctx: any, kind: string, payload: Record<string, unknown>): Promise<void> {
@ -182,7 +183,7 @@ export async function appendHistory(ctx: any, kind: string, payload: Record<stri
);
await history.append({
kind,
handoffId: ctx.state.handoffId,
taskId: ctx.state.taskId,
branchName: ctx.state.branchName,
payload
});

View file

@ -26,7 +26,7 @@ import {
killWriteDbActivity
} from "./commands.js";
import { idleNotifyActivity, idleSubmitPrActivity, statusUpdateActivity } from "./status-sync.js";
import { HANDOFF_QUEUE_NAMES } from "./queue.js";
import { TASK_QUEUE_NAMES } from "./queue.js";
import {
changeWorkbenchModel,
closeWorkbenchSession,
@ -34,7 +34,7 @@ import {
markWorkbenchUnread,
publishWorkbenchPr,
renameWorkbenchBranch,
renameWorkbenchHandoff,
renameWorkbenchTask,
renameWorkbenchSession,
revertWorkbenchFile,
sendWorkbenchMessage,
@ -44,16 +44,16 @@ import {
updateWorkbenchDraft,
} from "../workbench.js";
export { HANDOFF_QUEUE_NAMES, handoffWorkflowQueueName } from "./queue.js";
export { TASK_QUEUE_NAMES, taskWorkflowQueueName } from "./queue.js";
const INIT_ENSURE_NAME_TIMEOUT_MS = 5 * 60_000;
type HandoffQueueName = (typeof HANDOFF_QUEUE_NAMES)[number];
type TaskQueueName = (typeof TASK_QUEUE_NAMES)[number];
type WorkflowHandler = (loopCtx: any, msg: { name: HandoffQueueName; body: any; complete: (response: unknown) => Promise<void> }) => Promise<void>;
type WorkflowHandler = (loopCtx: any, msg: { name: TaskQueueName; body: any; complete: (response: unknown) => Promise<void> }) => Promise<void>;
const commandHandlers: Record<HandoffQueueName, WorkflowHandler> = {
"handoff.command.initialize": async (loopCtx, msg) => {
const commandHandlers: Record<TaskQueueName, WorkflowHandler> = {
"task.command.initialize": async (loopCtx, msg) => {
const body = msg.body;
await loopCtx.step("init-bootstrap-db", async () => initBootstrapDbActivity(loopCtx, body));
@ -67,13 +67,13 @@ const commandHandlers: Record<HandoffQueueName, WorkflowHandler> = {
try {
await msg.complete(currentRecord);
} catch (error) {
logActorWarning("handoff.workflow", "initialize completion failed", {
logActorWarning("task.workflow", "initialize completion failed", {
error: resolveErrorMessage(error)
});
}
},
"handoff.command.provision": async (loopCtx, msg) => {
"task.command.provision": async (loopCtx, msg) => {
const body = msg.body;
await loopCtx.removed("init-failed", "step");
try {
@ -118,56 +118,56 @@ const commandHandlers: Record<HandoffQueueName, WorkflowHandler> = {
}
},
"handoff.command.attach": async (loopCtx, msg) => {
"task.command.attach": async (loopCtx, msg) => {
await loopCtx.step("handle-attach", async () => handleAttachActivity(loopCtx, msg));
},
"handoff.command.switch": async (loopCtx, msg) => {
"task.command.switch": async (loopCtx, msg) => {
await loopCtx.step("handle-switch", async () => handleSwitchActivity(loopCtx, msg));
},
"handoff.command.push": async (loopCtx, msg) => {
"task.command.push": async (loopCtx, msg) => {
await loopCtx.step("handle-push", async () => handlePushActivity(loopCtx, msg));
},
"handoff.command.sync": async (loopCtx, msg) => {
"task.command.sync": async (loopCtx, msg) => {
await loopCtx.step(
"handle-sync",
async () => handleSimpleCommandActivity(loopCtx, msg, "sync requested", "handoff.sync")
async () => handleSimpleCommandActivity(loopCtx, msg, "sync requested", "task.sync")
);
},
"handoff.command.merge": async (loopCtx, msg) => {
"task.command.merge": async (loopCtx, msg) => {
await loopCtx.step(
"handle-merge",
async () => handleSimpleCommandActivity(loopCtx, msg, "merge requested", "handoff.merge")
async () => handleSimpleCommandActivity(loopCtx, msg, "merge requested", "task.merge")
);
},
"handoff.command.archive": async (loopCtx, msg) => {
"task.command.archive": async (loopCtx, msg) => {
await loopCtx.step("handle-archive", async () => handleArchiveActivity(loopCtx, msg));
},
"handoff.command.kill": async (loopCtx, msg) => {
"task.command.kill": async (loopCtx, msg) => {
await loopCtx.step("kill-destroy-sandbox", async () => killDestroySandboxActivity(loopCtx));
await loopCtx.step("kill-write-db", async () => killWriteDbActivity(loopCtx, msg));
},
"handoff.command.get": async (loopCtx, msg) => {
"task.command.get": async (loopCtx, msg) => {
await loopCtx.step("handle-get", async () => handleGetActivity(loopCtx, msg));
},
"handoff.command.workbench.mark_unread": async (loopCtx, msg) => {
"task.command.workbench.mark_unread": async (loopCtx, msg) => {
await loopCtx.step("workbench-mark-unread", async () => markWorkbenchUnread(loopCtx));
await msg.complete({ ok: true });
},
"handoff.command.workbench.rename_handoff": async (loopCtx, msg) => {
await loopCtx.step("workbench-rename-handoff", async () => renameWorkbenchHandoff(loopCtx, msg.body.value));
"task.command.workbench.rename_task": async (loopCtx, msg) => {
await loopCtx.step("workbench-rename-task", async () => renameWorkbenchTask(loopCtx, msg.body.value));
await msg.complete({ ok: true });
},
"handoff.command.workbench.rename_branch": async (loopCtx, msg) => {
"task.command.workbench.rename_branch": async (loopCtx, msg) => {
await loopCtx.step({
name: "workbench-rename-branch",
timeout: 5 * 60_000,
@ -176,7 +176,7 @@ const commandHandlers: Record<HandoffQueueName, WorkflowHandler> = {
await msg.complete({ ok: true });
},
"handoff.command.workbench.create_session": async (loopCtx, msg) => {
"task.command.workbench.create_session": async (loopCtx, msg) => {
const created = await loopCtx.step({
name: "workbench-create-session",
timeout: 5 * 60_000,
@ -185,35 +185,35 @@ const commandHandlers: Record<HandoffQueueName, WorkflowHandler> = {
await msg.complete(created);
},
"handoff.command.workbench.rename_session": async (loopCtx, msg) => {
"task.command.workbench.rename_session": async (loopCtx, msg) => {
await loopCtx.step("workbench-rename-session", async () =>
renameWorkbenchSession(loopCtx, msg.body.sessionId, msg.body.title),
);
await msg.complete({ ok: true });
},
"handoff.command.workbench.set_session_unread": async (loopCtx, msg) => {
"task.command.workbench.set_session_unread": async (loopCtx, msg) => {
await loopCtx.step("workbench-set-session-unread", async () =>
setWorkbenchSessionUnread(loopCtx, msg.body.sessionId, msg.body.unread),
);
await msg.complete({ ok: true });
},
"handoff.command.workbench.update_draft": async (loopCtx, msg) => {
"task.command.workbench.update_draft": async (loopCtx, msg) => {
await loopCtx.step("workbench-update-draft", async () =>
updateWorkbenchDraft(loopCtx, msg.body.sessionId, msg.body.text, msg.body.attachments),
);
await msg.complete({ ok: true });
},
"handoff.command.workbench.change_model": async (loopCtx, msg) => {
"task.command.workbench.change_model": async (loopCtx, msg) => {
await loopCtx.step("workbench-change-model", async () =>
changeWorkbenchModel(loopCtx, msg.body.sessionId, msg.body.model),
);
await msg.complete({ ok: true });
},
"handoff.command.workbench.send_message": async (loopCtx, msg) => {
"task.command.workbench.send_message": async (loopCtx, msg) => {
await loopCtx.step({
name: "workbench-send-message",
timeout: 10 * 60_000,
@ -222,7 +222,7 @@ const commandHandlers: Record<HandoffQueueName, WorkflowHandler> = {
await msg.complete({ ok: true });
},
"handoff.command.workbench.stop_session": async (loopCtx, msg) => {
"task.command.workbench.stop_session": async (loopCtx, msg) => {
await loopCtx.step({
name: "workbench-stop-session",
timeout: 5 * 60_000,
@ -231,14 +231,14 @@ const commandHandlers: Record<HandoffQueueName, WorkflowHandler> = {
await msg.complete({ ok: true });
},
"handoff.command.workbench.sync_session_status": async (loopCtx, msg) => {
"task.command.workbench.sync_session_status": async (loopCtx, msg) => {
await loopCtx.step("workbench-sync-session-status", async () =>
syncWorkbenchSessionStatus(loopCtx, msg.body.sessionId, msg.body.status, msg.body.at),
);
await msg.complete({ ok: true });
},
"handoff.command.workbench.close_session": async (loopCtx, msg) => {
"task.command.workbench.close_session": async (loopCtx, msg) => {
await loopCtx.step({
name: "workbench-close-session",
timeout: 5 * 60_000,
@ -247,7 +247,7 @@ const commandHandlers: Record<HandoffQueueName, WorkflowHandler> = {
await msg.complete({ ok: true });
},
"handoff.command.workbench.publish_pr": async (loopCtx, msg) => {
"task.command.workbench.publish_pr": async (loopCtx, msg) => {
await loopCtx.step({
name: "workbench-publish-pr",
timeout: 10 * 60_000,
@ -256,7 +256,7 @@ const commandHandlers: Record<HandoffQueueName, WorkflowHandler> = {
await msg.complete({ ok: true });
},
"handoff.command.workbench.revert_file": async (loopCtx, msg) => {
"task.command.workbench.revert_file": async (loopCtx, msg) => {
await loopCtx.step({
name: "workbench-revert-file",
timeout: 5 * 60_000,
@ -265,7 +265,7 @@ const commandHandlers: Record<HandoffQueueName, WorkflowHandler> = {
await msg.complete({ ok: true });
},
"handoff.status_sync.result": async (loopCtx, msg) => {
"task.status_sync.result": async (loopCtx, msg) => {
const transitionedToIdle = await loopCtx.step("status-update", async () => statusUpdateActivity(loopCtx, msg.body));
if (transitionedToIdle) {
@ -278,16 +278,16 @@ const commandHandlers: Record<HandoffQueueName, WorkflowHandler> = {
}
};
export async function runHandoffWorkflow(ctx: any): Promise<void> {
await ctx.loop("handoff-command-loop", async (loopCtx: any) => {
export async function runTaskWorkflow(ctx: any): Promise<void> {
await ctx.loop("task-command-loop", async (loopCtx: any) => {
const msg = await loopCtx.queue.next("next-command", {
names: [...HANDOFF_QUEUE_NAMES],
names: [...TASK_QUEUE_NAMES],
completable: true
});
if (!msg) {
return Loop.continue(undefined);
}
const handler = commandHandlers[msg.name as HandoffQueueName];
const handler = commandHandlers[msg.name as TaskQueueName];
if (handler) {
await handler(loopCtx, msg);
}

View file

@ -3,22 +3,22 @@ import { desc, eq } from "drizzle-orm";
import { resolveCreateFlowDecision } from "../../../services/create-flow.js";
import { getActorRuntimeContext } from "../../context.js";
import {
getOrCreateHandoffStatusSync,
getOrCreateTaskStatusSync,
getOrCreateHistory,
getOrCreateProject,
getOrCreateRepo,
getOrCreateSandboxInstance,
selfHandoff
selfTask
} from "../../handles.js";
import { logActorWarning, resolveErrorMessage } from "../../logging.js";
import { handoff as handoffTable, handoffRuntime, handoffSandboxes } from "../db/schema.js";
import { task as taskTable, taskRuntime, taskSandboxes } from "../db/schema.js";
import {
HANDOFF_ROW_ID,
appendHistory,
collectErrorMessages,
resolveErrorDetail,
setHandoffState
setTaskState
} from "./common.js";
import { handoffWorkflowQueueName } from "./queue.js";
import { taskWorkflowQueueName } from "./queue.js";
const DEFAULT_INIT_CREATE_SANDBOX_ACTIVITY_TIMEOUT_MS = 180_000;
@ -37,10 +37,10 @@ function getInitCreateSandboxActivityTimeoutMs(): number {
function debugInit(loopCtx: any, message: string, context?: Record<string, unknown>): void {
loopCtx.log.debug({
msg: message,
scope: "handoff.init",
scope: "task.init",
workspaceId: loopCtx.state.workspaceId,
repoId: loopCtx.state.repoId,
handoffId: loopCtx.state.handoffId,
taskId: loopCtx.state.taskId,
...(context ?? {})
});
}
@ -76,7 +76,7 @@ export async function initBootstrapDbActivity(loopCtx: any, body: any): Promise<
try {
await db
.insert(handoffTable)
.insert(taskTable)
.values({
id: HANDOFF_ROW_ID,
branchName: loopCtx.state.branchName,
@ -89,7 +89,7 @@ export async function initBootstrapDbActivity(loopCtx: any, body: any): Promise<
updatedAt: now
})
.onConflictDoUpdate({
target: handoffTable.id,
target: taskTable.id,
set: {
branchName: loopCtx.state.branchName,
title: loopCtx.state.title,
@ -103,7 +103,7 @@ export async function initBootstrapDbActivity(loopCtx: any, body: any): Promise<
.run();
await db
.insert(handoffRuntime)
.insert(taskRuntime)
.values({
id: HANDOFF_ROW_ID,
activeSandboxId: null,
@ -114,7 +114,7 @@ export async function initBootstrapDbActivity(loopCtx: any, body: any): Promise<
updatedAt: now
})
.onConflictDoUpdate({
target: handoffRuntime.id,
target: taskRuntime.id,
set: {
activeSandboxId: null,
activeSessionId: null,
@ -127,36 +127,36 @@ export async function initBootstrapDbActivity(loopCtx: any, body: any): Promise<
.run();
} catch (error) {
const detail = resolveErrorMessage(error);
throw new Error(`handoff init bootstrap db failed: ${detail}`);
throw new Error(`task init bootstrap db failed: ${detail}`);
}
}
export async function initEnqueueProvisionActivity(loopCtx: any, body: any): Promise<void> {
await setHandoffState(loopCtx, "init_enqueue_provision", "provision queued");
const self = selfHandoff(loopCtx);
await setTaskState(loopCtx, "init_enqueue_provision", "provision queued");
const self = selfTask(loopCtx);
void self
.send(handoffWorkflowQueueName("handoff.command.provision"), body, {
.send(taskWorkflowQueueName("task.command.provision"), body, {
wait: false,
})
.catch((error: unknown) => {
logActorWarning("handoff.init", "background provision command failed", {
logActorWarning("task.init", "background provision command failed", {
workspaceId: loopCtx.state.workspaceId,
repoId: loopCtx.state.repoId,
handoffId: loopCtx.state.handoffId,
taskId: loopCtx.state.taskId,
error: resolveErrorMessage(error),
});
});
}
export async function initEnsureNameActivity(loopCtx: any): Promise<void> {
await setHandoffState(loopCtx, "init_ensure_name", "determining title and branch");
await setTaskState(loopCtx, "init_ensure_name", "determining title and branch");
const existing = await loopCtx.db
.select({
branchName: handoffTable.branchName,
title: handoffTable.title
branchName: taskTable.branchName,
title: taskTable.title
})
.from(handoffTable)
.where(eq(handoffTable.id, HANDOFF_ROW_ID))
.from(taskTable)
.where(eq(taskTable.id, HANDOFF_ROW_ID))
.get();
if (existing?.branchName && existing?.title) {
@ -169,10 +169,10 @@ export async function initEnsureNameActivity(loopCtx: any): Promise<void> {
try {
await driver.git.fetch(loopCtx.state.repoLocalPath);
} catch (error) {
logActorWarning("handoff.init", "fetch before naming failed", {
logActorWarning("task.init", "fetch before naming failed", {
workspaceId: loopCtx.state.workspaceId,
repoId: loopCtx.state.repoId,
handoffId: loopCtx.state.handoffId,
taskId: loopCtx.state.taskId,
error: resolveErrorMessage(error)
});
}
@ -180,31 +180,31 @@ export async function initEnsureNameActivity(loopCtx: any): Promise<void> {
(branch: any) => branch.branchName
);
const project = await getOrCreateProject(
const repo = await getOrCreateRepo(
loopCtx,
loopCtx.state.workspaceId,
loopCtx.state.repoId,
loopCtx.state.repoRemote
);
const reservedBranches = await project.listReservedBranches({});
const reservedBranches = await repo.listReservedBranches({});
const resolved = resolveCreateFlowDecision({
task: loopCtx.state.task,
explicitTitle: loopCtx.state.explicitTitle ?? undefined,
explicitBranchName: loopCtx.state.explicitBranchName ?? undefined,
localBranches: remoteBranches,
handoffBranches: reservedBranches
taskBranches: reservedBranches
});
const now = Date.now();
await loopCtx.db
.update(handoffTable)
.update(taskTable)
.set({
branchName: resolved.branchName,
title: resolved.title,
updatedAt: now
})
.where(eq(handoffTable.id, HANDOFF_ROW_ID))
.where(eq(taskTable.id, HANDOFF_ROW_ID))
.run();
loopCtx.state.branchName = resolved.branchName;
@ -213,34 +213,34 @@ export async function initEnsureNameActivity(loopCtx: any): Promise<void> {
loopCtx.state.explicitBranchName = null;
await loopCtx.db
.update(handoffRuntime)
.update(taskRuntime)
.set({
statusMessage: "provisioning",
updatedAt: now
})
.where(eq(handoffRuntime.id, HANDOFF_ROW_ID))
.where(eq(taskRuntime.id, HANDOFF_ROW_ID))
.run();
await project.registerHandoffBranch({
handoffId: loopCtx.state.handoffId,
await repo.registerTaskBranch({
taskId: loopCtx.state.taskId,
branchName: resolved.branchName
});
await appendHistory(loopCtx, "handoff.named", {
await appendHistory(loopCtx, "task.named", {
title: resolved.title,
branchName: resolved.branchName
});
}
export async function initAssertNameActivity(loopCtx: any): Promise<void> {
await setHandoffState(loopCtx, "init_assert_name", "validating naming");
await setTaskState(loopCtx, "init_assert_name", "validating naming");
if (!loopCtx.state.branchName) {
throw new Error("handoff branchName is not initialized");
throw new Error("task branchName is not initialized");
}
}
export async function initCreateSandboxActivity(loopCtx: any, body: any): Promise<any> {
await setHandoffState(loopCtx, "init_create_sandbox", "creating sandbox");
await setTaskState(loopCtx, "init_create_sandbox", "creating sandbox");
const { providers } = getActorRuntimeContext();
const providerId = body?.providerId ?? loopCtx.state.providerId;
const provider = providers.get(providerId);
@ -255,16 +255,16 @@ export async function initCreateSandboxActivity(loopCtx: any, body: any): Promis
if (provider.capabilities().supportsSessionReuse) {
const runtime = await loopCtx.db
.select({ activeSandboxId: handoffRuntime.activeSandboxId })
.from(handoffRuntime)
.where(eq(handoffRuntime.id, HANDOFF_ROW_ID))
.select({ activeSandboxId: taskRuntime.activeSandboxId })
.from(taskRuntime)
.where(eq(taskRuntime.id, HANDOFF_ROW_ID))
.get();
const existing = await loopCtx.db
.select({ sandboxId: handoffSandboxes.sandboxId })
.from(handoffSandboxes)
.where(eq(handoffSandboxes.providerId, providerId))
.orderBy(desc(handoffSandboxes.updatedAt))
.select({ sandboxId: taskSandboxes.sandboxId })
.from(taskSandboxes)
.where(eq(taskSandboxes.providerId, providerId))
.orderBy(desc(taskSandboxes.updatedAt))
.limit(1)
.get();
@ -287,10 +287,10 @@ export async function initCreateSandboxActivity(loopCtx: any, body: any): Promis
});
return resumed;
} catch (error) {
logActorWarning("handoff.init", "resume sandbox failed; creating a new sandbox", {
logActorWarning("task.init", "resume sandbox failed; creating a new sandbox", {
workspaceId: loopCtx.state.workspaceId,
repoId: loopCtx.state.repoId,
handoffId: loopCtx.state.handoffId,
taskId: loopCtx.state.taskId,
sandboxId,
error: resolveErrorMessage(error)
});
@ -311,7 +311,7 @@ export async function initCreateSandboxActivity(loopCtx: any, body: any): Promis
repoId: loopCtx.state.repoId,
repoRemote: loopCtx.state.repoRemote,
branchName: loopCtx.state.branchName,
handoffId: loopCtx.state.handoffId,
taskId: loopCtx.state.taskId,
debug: (message, context) => debugInit(loopCtx, message, context)
})
);
@ -331,7 +331,7 @@ export async function initCreateSandboxActivity(loopCtx: any, body: any): Promis
}
export async function initEnsureAgentActivity(loopCtx: any, body: any, sandbox: any): Promise<any> {
await setHandoffState(loopCtx, "init_ensure_agent", "ensuring sandbox agent");
await setTaskState(loopCtx, "init_ensure_agent", "ensuring sandbox agent");
const { providers } = getActorRuntimeContext();
const providerId = body?.providerId ?? loopCtx.state.providerId;
const provider = providers.get(providerId);
@ -347,7 +347,7 @@ export async function initStartSandboxInstanceActivity(
sandbox: any,
agent: any
): Promise<any> {
await setHandoffState(loopCtx, "init_start_sandbox_instance", "starting sandbox runtime");
await setTaskState(loopCtx, "init_start_sandbox_instance", "starting sandbox runtime");
try {
const providerId = body?.providerId ?? loopCtx.state.providerId;
const sandboxInstance = await getOrCreateSandboxInstance(
@ -392,7 +392,7 @@ export async function initCreateSessionActivity(
sandbox: any,
sandboxInstanceReady: any
): Promise<any> {
await setHandoffState(loopCtx, "init_create_session", "deferring agent session creation");
await setTaskState(loopCtx, "init_create_session", "deferring agent session creation");
if (!sandboxInstanceReady.ok) {
return {
id: null,
@ -415,7 +415,7 @@ export async function initWriteDbActivity(
session: any,
sandboxInstanceReady?: { actorId?: string | null }
): Promise<void> {
await setHandoffState(loopCtx, "init_write_db", "persisting handoff runtime");
await setTaskState(loopCtx, "init_write_db", "persisting task runtime");
const providerId = body?.providerId ?? loopCtx.state.providerId;
const { config } = getActorRuntimeContext();
const now = Date.now();
@ -443,18 +443,18 @@ export async function initWriteDbActivity(
: null;
await db
.update(handoffTable)
.update(taskTable)
.set({
providerId,
status: sessionHealthy ? "idle" : "error",
agentType: loopCtx.state.agentType ?? config.default_agent,
updatedAt: now
})
.where(eq(handoffTable.id, HANDOFF_ROW_ID))
.where(eq(taskTable.id, HANDOFF_ROW_ID))
.run();
await db
.insert(handoffSandboxes)
.insert(taskSandboxes)
.values({
sandboxId: sandbox.sandboxId,
providerId,
@ -466,7 +466,7 @@ export async function initWriteDbActivity(
updatedAt: now
})
.onConflictDoUpdate({
target: handoffSandboxes.sandboxId,
target: taskSandboxes.sandboxId,
set: {
providerId,
sandboxActorId,
@ -479,7 +479,7 @@ export async function initWriteDbActivity(
.run();
await db
.insert(handoffRuntime)
.insert(taskRuntime)
.values({
id: HANDOFF_ROW_ID,
activeSandboxId: sandbox.sandboxId,
@ -490,7 +490,7 @@ export async function initWriteDbActivity(
updatedAt: now
})
.onConflictDoUpdate({
target: handoffRuntime.id,
target: taskRuntime.id,
set: {
activeSandboxId: sandbox.sandboxId,
activeSessionId,
@ -514,19 +514,19 @@ export async function initStartStatusSyncActivity(
return;
}
await setHandoffState(loopCtx, "init_start_status_sync", "starting session status sync");
await setTaskState(loopCtx, "init_start_status_sync", "starting session status sync");
const providerId = body?.providerId ?? loopCtx.state.providerId;
const sync = await getOrCreateHandoffStatusSync(
const sync = await getOrCreateTaskStatusSync(
loopCtx,
loopCtx.state.workspaceId,
loopCtx.state.repoId,
loopCtx.state.handoffId,
loopCtx.state.taskId,
sandbox.sandboxId,
sessionId,
{
workspaceId: loopCtx.state.workspaceId,
repoId: loopCtx.state.repoId,
handoffId: loopCtx.state.handoffId,
taskId: loopCtx.state.taskId,
providerId,
sandboxId: sandbox.sandboxId,
sessionId,
@ -543,12 +543,12 @@ export async function initCompleteActivity(loopCtx: any, body: any, sandbox: any
const sessionId = session?.id ?? null;
const sessionHealthy = !sessionId || session?.status !== "error";
if (sessionHealthy) {
await setHandoffState(loopCtx, "init_complete", "handoff initialized");
await setTaskState(loopCtx, "init_complete", "task initialized");
const history = await getOrCreateHistory(loopCtx, loopCtx.state.workspaceId, loopCtx.state.repoId);
await history.append({
kind: "handoff.initialized",
handoffId: loopCtx.state.handoffId,
kind: "task.initialized",
taskId: loopCtx.state.taskId,
branchName: loopCtx.state.branchName,
payload: { providerId, sandboxId: sandbox.sandboxId, sessionId: sessionId ?? null }
});
@ -561,8 +561,8 @@ export async function initCompleteActivity(loopCtx: any, body: any, sandbox: any
session?.status === "error"
? (session.error ?? "session create failed")
: "session unavailable";
await setHandoffState(loopCtx, "error", detail);
await appendHistory(loopCtx, "handoff.error", {
await setTaskState(loopCtx, "error", detail);
await appendHistory(loopCtx, "task.error", {
detail,
messages: [detail]
});
@ -578,7 +578,7 @@ export async function initFailedActivity(loopCtx: any, error: unknown): Promise<
const providerId = loopCtx.state.providerId ?? providers.defaultProviderId();
await db
.insert(handoffTable)
.insert(taskTable)
.values({
id: HANDOFF_ROW_ID,
branchName: loopCtx.state.branchName ?? null,
@ -591,7 +591,7 @@ export async function initFailedActivity(loopCtx: any, error: unknown): Promise<
updatedAt: now
})
.onConflictDoUpdate({
target: handoffTable.id,
target: taskTable.id,
set: {
branchName: loopCtx.state.branchName ?? null,
title: loopCtx.state.title ?? null,
@ -605,7 +605,7 @@ export async function initFailedActivity(loopCtx: any, error: unknown): Promise<
.run();
await db
.insert(handoffRuntime)
.insert(taskRuntime)
.values({
id: HANDOFF_ROW_ID,
activeSandboxId: null,
@ -616,7 +616,7 @@ export async function initFailedActivity(loopCtx: any, error: unknown): Promise<
updatedAt: now
})
.onConflictDoUpdate({
target: handoffRuntime.id,
target: taskRuntime.id,
set: {
activeSandboxId: null,
activeSessionId: null,
@ -628,7 +628,7 @@ export async function initFailedActivity(loopCtx: any, error: unknown): Promise<
})
.run();
await appendHistory(loopCtx, "handoff.error", {
await appendHistory(loopCtx, "task.error", {
detail,
messages
});

View file

@ -1,7 +1,7 @@
// @ts-nocheck
import { eq } from "drizzle-orm";
import { getActorRuntimeContext } from "../../context.js";
import { handoffRuntime, handoffSandboxes } from "../db/schema.js";
import { taskRuntime, taskSandboxes } from "../db/schema.js";
import { HANDOFF_ROW_ID, appendHistory, getCurrentRecord } from "./common.js";
export interface PushActiveBranchOptions {
@ -21,7 +21,7 @@ export async function pushActiveBranchActivity(
throw new Error("cannot push: no active sandbox");
}
if (!branchName) {
throw new Error("cannot push: handoff branch is not set");
throw new Error("cannot push: task branch is not set");
}
const activeSandbox =
@ -37,15 +37,15 @@ export async function pushActiveBranchActivity(
const now = Date.now();
await loopCtx.db
.update(handoffRuntime)
.update(taskRuntime)
.set({ statusMessage: `pushing branch ${branchName}`, updatedAt: now })
.where(eq(handoffRuntime.id, HANDOFF_ROW_ID))
.where(eq(taskRuntime.id, HANDOFF_ROW_ID))
.run();
await loopCtx.db
.update(handoffSandboxes)
.update(taskSandboxes)
.set({ statusMessage: `pushing branch ${branchName}`, updatedAt: now })
.where(eq(handoffSandboxes.sandboxId, activeSandboxId))
.where(eq(taskSandboxes.sandboxId, activeSandboxId))
.run();
const script = [
@ -69,18 +69,18 @@ export async function pushActiveBranchActivity(
const updatedAt = Date.now();
await loopCtx.db
.update(handoffRuntime)
.update(taskRuntime)
.set({ statusMessage: `push complete for ${branchName}`, updatedAt })
.where(eq(handoffRuntime.id, HANDOFF_ROW_ID))
.where(eq(taskRuntime.id, HANDOFF_ROW_ID))
.run();
await loopCtx.db
.update(handoffSandboxes)
.update(taskSandboxes)
.set({ statusMessage: `push complete for ${branchName}`, updatedAt })
.where(eq(handoffSandboxes.sandboxId, activeSandboxId))
.where(eq(taskSandboxes.sandboxId, activeSandboxId))
.run();
await appendHistory(loopCtx, options.historyKind ?? "handoff.push", {
await appendHistory(loopCtx, options.historyKind ?? "task.push", {
reason: options.reason ?? null,
branchName,
sandboxId: activeSandboxId

View file

@ -0,0 +1,31 @@
export const TASK_QUEUE_NAMES = [
"task.command.initialize",
"task.command.provision",
"task.command.attach",
"task.command.switch",
"task.command.push",
"task.command.sync",
"task.command.merge",
"task.command.archive",
"task.command.kill",
"task.command.get",
"task.command.workbench.mark_unread",
"task.command.workbench.rename_task",
"task.command.workbench.rename_branch",
"task.command.workbench.create_session",
"task.command.workbench.rename_session",
"task.command.workbench.set_session_unread",
"task.command.workbench.update_draft",
"task.command.workbench.change_model",
"task.command.workbench.send_message",
"task.command.workbench.stop_session",
"task.command.workbench.sync_session_status",
"task.command.workbench.close_session",
"task.command.workbench.publish_pr",
"task.command.workbench.revert_file",
"task.status_sync.result"
] as const;
export function taskWorkflowQueueName(name: string): string {
return name;
}

View file

@ -2,7 +2,7 @@
import { eq } from "drizzle-orm";
import { getActorRuntimeContext } from "../../context.js";
import { logActorWarning, resolveErrorMessage } from "../../logging.js";
import { handoff as handoffTable, handoffRuntime, handoffSandboxes } from "../db/schema.js";
import { task as taskTable, taskRuntime, taskSandboxes } from "../db/schema.js";
import { HANDOFF_ROW_ID, appendHistory, resolveErrorDetail } from "./common.js";
import { pushActiveBranchActivity } from "./push.js";
@ -25,11 +25,11 @@ export async function statusUpdateActivity(loopCtx: any, body: any): Promise<boo
const db = loopCtx.db;
const runtime = await db
.select({
activeSandboxId: handoffRuntime.activeSandboxId,
activeSessionId: handoffRuntime.activeSessionId
activeSandboxId: taskRuntime.activeSandboxId,
activeSessionId: taskRuntime.activeSessionId
})
.from(handoffRuntime)
.where(eq(handoffRuntime.id, HANDOFF_ROW_ID))
.from(taskRuntime)
.where(eq(taskRuntime.id, HANDOFF_ROW_ID))
.get();
const isActive =
@ -37,25 +37,25 @@ export async function statusUpdateActivity(loopCtx: any, body: any): Promise<boo
if (isActive) {
await db
.update(handoffTable)
.update(taskTable)
.set({ status: newStatus, updatedAt: body.at })
.where(eq(handoffTable.id, HANDOFF_ROW_ID))
.where(eq(taskTable.id, HANDOFF_ROW_ID))
.run();
await db
.update(handoffRuntime)
.update(taskRuntime)
.set({ statusMessage: `session:${body.status}`, updatedAt: body.at })
.where(eq(handoffRuntime.id, HANDOFF_ROW_ID))
.where(eq(taskRuntime.id, HANDOFF_ROW_ID))
.run();
}
await db
.update(handoffSandboxes)
.update(taskSandboxes)
.set({ statusMessage: `session:${body.status}`, updatedAt: body.at })
.where(eq(handoffSandboxes.sandboxId, body.sandboxId))
.where(eq(taskSandboxes.sandboxId, body.sandboxId))
.run();
await appendHistory(loopCtx, "handoff.status", {
await appendHistory(loopCtx, "task.status", {
status: body.status,
sessionId: body.sessionId,
sandboxId: body.sandboxId
@ -79,9 +79,9 @@ export async function idleSubmitPrActivity(loopCtx: any): Promise<void> {
const db = loopCtx.db;
const self = await db
.select({ prSubmitted: handoffTable.prSubmitted })
.from(handoffTable)
.where(eq(handoffTable.id, HANDOFF_ROW_ID))
.select({ prSubmitted: taskTable.prSubmitted })
.from(taskTable)
.where(eq(taskTable.id, HANDOFF_ROW_ID))
.get();
if (self && self.prSubmitted) return;
@ -89,22 +89,22 @@ export async function idleSubmitPrActivity(loopCtx: any): Promise<void> {
try {
await driver.git.fetch(loopCtx.state.repoLocalPath);
} catch (error) {
logActorWarning("handoff.status-sync", "fetch before PR submit failed", {
logActorWarning("task.status-sync", "fetch before PR submit failed", {
workspaceId: loopCtx.state.workspaceId,
repoId: loopCtx.state.repoId,
handoffId: loopCtx.state.handoffId,
taskId: loopCtx.state.taskId,
error: resolveErrorMessage(error)
});
}
if (!loopCtx.state.branchName || !loopCtx.state.title) {
throw new Error("cannot submit PR before handoff has a branch and title");
throw new Error("cannot submit PR before task has a branch and title");
}
try {
await pushActiveBranchActivity(loopCtx, {
reason: "auto_submit_idle",
historyKind: "handoff.push.auto"
historyKind: "task.push.auto"
});
const pr = await driver.github.createPr(
@ -114,21 +114,21 @@ export async function idleSubmitPrActivity(loopCtx: any): Promise<void> {
);
await db
.update(handoffTable)
.update(taskTable)
.set({ prSubmitted: 1, updatedAt: Date.now() })
.where(eq(handoffTable.id, HANDOFF_ROW_ID))
.where(eq(taskTable.id, HANDOFF_ROW_ID))
.run();
await appendHistory(loopCtx, "handoff.step", {
await appendHistory(loopCtx, "task.step", {
step: "pr_submit",
handoffId: loopCtx.state.handoffId,
taskId: loopCtx.state.taskId,
branchName: loopCtx.state.branchName,
prUrl: pr.url,
prNumber: pr.number
});
await appendHistory(loopCtx, "handoff.pr_created", {
handoffId: loopCtx.state.handoffId,
await appendHistory(loopCtx, "task.pr_created", {
taskId: loopCtx.state.taskId,
branchName: loopCtx.state.branchName,
prUrl: pr.url,
prNumber: pr.number
@ -136,16 +136,16 @@ export async function idleSubmitPrActivity(loopCtx: any): Promise<void> {
} catch (error) {
const detail = resolveErrorDetail(error);
await db
.update(handoffRuntime)
.update(taskRuntime)
.set({
statusMessage: `pr submit failed: ${detail}`,
updatedAt: Date.now()
})
.where(eq(handoffRuntime.id, HANDOFF_ROW_ID))
.where(eq(taskRuntime.id, HANDOFF_ROW_ID))
.run();
await appendHistory(loopCtx, "handoff.pr_create_failed", {
handoffId: loopCtx.state.handoffId,
await appendHistory(loopCtx, "task.pr_create_failed", {
taskId: loopCtx.state.taskId,
branchName: loopCtx.state.branchName,
error: detail
});

View file

@ -3,38 +3,43 @@ import { desc, eq } from "drizzle-orm";
import { Loop } from "rivetkit/workflow";
import type {
AddRepoInput,
CreateHandoffInput,
HandoffRecord,
HandoffSummary,
HandoffWorkbenchChangeModelInput,
HandoffWorkbenchCreateHandoffInput,
HandoffWorkbenchDiffInput,
HandoffWorkbenchRenameInput,
HandoffWorkbenchRenameSessionInput,
HandoffWorkbenchSelectInput,
HandoffWorkbenchSetSessionUnreadInput,
HandoffWorkbenchSendMessageInput,
HandoffWorkbenchSnapshot,
HandoffWorkbenchTabInput,
HandoffWorkbenchUpdateDraftInput,
CreateTaskInput,
TaskRecord,
TaskSummary,
TaskWorkbenchChangeModelInput,
TaskWorkbenchCreateTaskInput,
TaskWorkbenchDiffInput,
TaskWorkbenchRenameInput,
TaskWorkbenchRenameSessionInput,
TaskWorkbenchSelectInput,
TaskWorkbenchSetSessionUnreadInput,
TaskWorkbenchSendMessageInput,
TaskWorkbenchSnapshot,
TaskWorkbenchTabInput,
TaskWorkbenchUpdateDraftInput,
HistoryEvent,
HistoryQueryInput,
ListHandoffsInput,
ListTasksInput,
ProviderId,
RepoOverview,
RepoStackActionInput,
RepoStackActionResult,
RepoRecord,
SwitchResult,
TaskRecord,
TaskSummary,
TaskWorkbenchCreateTaskInput,
TaskWorkbenchSnapshot,
WorkspaceUseInput
} from "@sandbox-agent/factory-shared";
import { getActorRuntimeContext } from "../context.js";
import { getHandoff, getOrCreateHistory, getOrCreateProject, selfWorkspace } from "../handles.js";
import { getTask, getOrCreateHistory, getOrCreateRepo, selfWorkspace } from "../handles.js";
import { logActorWarning, resolveErrorMessage } from "../logging.js";
import { normalizeRemoteUrl, repoIdFromRemote, repoLabelFromRemote } from "../../services/repo.js";
import { handoffLookup, repos, providerProfiles } from "./db/schema.js";
import { agentTypeForModel } from "../handoff/workbench.js";
import { taskLookup, repos, providerProfiles } from "./db/schema.js";
import { agentTypeForModel } from "../task/workbench.js";
import { expectQueueResponse } from "../../services/queue.js";
import { workspaceAppActions } from "./app-shell.js";
interface WorkspaceState {
workspaceId: string;
@ -44,12 +49,12 @@ interface RefreshProviderProfilesCommand {
providerId?: ProviderId;
}
interface GetHandoffInput {
interface GetTaskInput {
workspaceId: string;
handoffId: string;
taskId: string;
}
interface HandoffProxyActionInput extends GetHandoffInput {
interface TaskProxyActionInput extends GetTaskInput {
reason?: string;
}
@ -60,7 +65,7 @@ interface RepoOverviewInput {
const WORKSPACE_QUEUE_NAMES = [
"workspace.command.addRepo",
"workspace.command.createHandoff",
"workspace.command.createTask",
"workspace.command.refreshProviderProfiles",
] as const;
@ -78,49 +83,54 @@ function assertWorkspace(c: { state: WorkspaceState }, workspaceId: string): voi
}
}
async function resolveRepoId(c: any, handoffId: string): Promise<string> {
async function resolveRepoId(c: any, taskId: string): Promise<string> {
const row = await c.db
.select({ repoId: handoffLookup.repoId })
.from(handoffLookup)
.where(eq(handoffLookup.handoffId, handoffId))
.select({ repoId: taskLookup.repoId })
.from(taskLookup)
.where(eq(taskLookup.taskId, taskId))
.get();
if (!row) {
throw new Error(`Unknown handoff: ${handoffId} (not in lookup)`);
throw new Error(`Unknown task: ${taskId} (not in lookup)`);
}
return row.repoId;
}
async function upsertHandoffLookupRow(c: any, handoffId: string, repoId: string): Promise<void> {
async function upsertTaskLookupRow(c: any, taskId: string, repoId: string): Promise<void> {
await c.db
.insert(handoffLookup)
.insert(taskLookup)
.values({
handoffId,
taskId,
repoId,
})
.onConflictDoUpdate({
target: handoffLookup.handoffId,
target: taskLookup.taskId,
set: { repoId },
})
.run();
}
async function collectAllHandoffSummaries(c: any): Promise<HandoffSummary[]> {
async function collectAllTaskSummaries(c: any): Promise<TaskSummary[]> {
const repoRows = await c.db
.select({ repoId: repos.repoId, remoteUrl: repos.remoteUrl })
.from(repos)
.orderBy(desc(repos.updatedAt))
.all();
const all: HandoffSummary[] = [];
const all = new Map<string, TaskSummary>();
for (const row of repoRows) {
try {
const project = await getOrCreateProject(c, c.state.workspaceId, row.repoId, row.remoteUrl);
const snapshot = await project.listHandoffSummaries({ includeArchived: true });
all.push(...snapshot);
const repo = await getOrCreateRepo(c, c.state.workspaceId, row.repoId, row.remoteUrl);
const snapshot = await repo.listTaskSummaries({ includeArchived: true });
for (const summary of snapshot) {
const existing = all.get(summary.taskId);
if (!existing || summary.updatedAt > existing.updatedAt) {
all.set(summary.taskId, summary);
}
}
} catch (error) {
logActorWarning("workspace", "failed collecting handoffs for repo", {
logActorWarning("workspace", "failed collecting tasks for repo", {
workspaceId: c.state.workspaceId,
repoId: row.repoId,
error: resolveErrorMessage(error)
@ -128,49 +138,41 @@ async function collectAllHandoffSummaries(c: any): Promise<HandoffSummary[]> {
}
}
all.sort((a, b) => b.updatedAt - a.updatedAt);
return all;
return [...all.values()].sort((a, b) => b.updatedAt - a.updatedAt);
}
async function buildWorkbenchSnapshot(c: any): Promise<HandoffWorkbenchSnapshot> {
async function buildWorkbenchSnapshot(c: any): Promise<TaskWorkbenchSnapshot> {
const repoRows = await c.db
.select({ repoId: repos.repoId, remoteUrl: repos.remoteUrl, updatedAt: repos.updatedAt })
.from(repos)
.orderBy(desc(repos.updatedAt))
.all();
const handoffs: Array<any> = [];
const projects: Array<any> = [];
const tasksById = new Map<string, any>();
const repoSections: Array<any> = [];
for (const row of repoRows) {
const projectHandoffs: Array<any> = [];
const repoTasks: Array<any> = [];
try {
const project = await getOrCreateProject(c, c.state.workspaceId, row.repoId, row.remoteUrl);
const summaries = await project.listHandoffSummaries({ includeArchived: true });
const repo = await getOrCreateRepo(c, c.state.workspaceId, row.repoId, row.remoteUrl);
const summaries = await repo.listTaskSummaries({ includeArchived: true });
for (const summary of summaries) {
try {
await upsertHandoffLookupRow(c, summary.handoffId, row.repoId);
const handoff = getHandoff(c, c.state.workspaceId, row.repoId, summary.handoffId);
const snapshot = await handoff.getWorkbench({});
handoffs.push(snapshot);
projectHandoffs.push(snapshot);
const task = getTask(c, c.state.workspaceId, row.repoId, summary.taskId);
const snapshot = await task.getWorkbench({});
if (!tasksById.has(snapshot.id)) {
tasksById.set(snapshot.id, snapshot);
}
repoTasks.push(snapshot);
} catch (error) {
logActorWarning("workspace", "failed collecting workbench handoff", {
logActorWarning("workspace", "failed collecting workbench task", {
workspaceId: c.state.workspaceId,
repoId: row.repoId,
handoffId: summary.handoffId,
taskId: summary.taskId,
error: resolveErrorMessage(error)
});
}
}
if (projectHandoffs.length > 0) {
projects.push({
id: row.repoId,
label: repoLabelFromRemote(row.remoteUrl),
updatedAtMs: projectHandoffs[0]?.updatedAtMs ?? row.updatedAt,
handoffs: projectHandoffs.sort((left, right) => right.updatedAtMs - left.updatedAtMs),
});
}
} catch (error) {
logActorWarning("workspace", "failed collecting workbench repo snapshot", {
workspaceId: c.state.workspaceId,
@ -178,24 +180,33 @@ async function buildWorkbenchSnapshot(c: any): Promise<HandoffWorkbenchSnapshot>
error: resolveErrorMessage(error)
});
}
const sortedRepoTasks = repoTasks.sort((left, right) => right.updatedAtMs - left.updatedAtMs);
repoSections.push({
id: row.repoId,
label: repoLabelFromRemote(row.remoteUrl),
updatedAtMs: sortedRepoTasks[0]?.updatedAtMs ?? row.updatedAt,
tasks: sortedRepoTasks,
});
}
handoffs.sort((left, right) => right.updatedAtMs - left.updatedAtMs);
projects.sort((left, right) => right.updatedAtMs - left.updatedAtMs);
const tasks = [...tasksById.values()].sort((left, right) => right.updatedAtMs - left.updatedAtMs);
repoSections.sort((left, right) => right.updatedAtMs - left.updatedAtMs);
return {
workspaceId: c.state.workspaceId,
repos: repoRows.map((row) => ({
id: row.repoId,
label: repoLabelFromRemote(row.remoteUrl)
})),
projects,
handoffs,
repoSections,
tasks,
};
}
async function requireWorkbenchHandoff(c: any, handoffId: string) {
const repoId = await resolveRepoId(c, handoffId);
return getHandoff(c, c.state.workspaceId, repoId, handoffId);
async function requireWorkbenchTask(c: any, taskId: string) {
const repoId = await resolveRepoId(c, taskId);
void repoId;
return getTask(c, c.state.workspaceId, taskId);
}
async function addRepoMutation(c: any, input: AddRepoInput): Promise<RepoRecord> {
@ -239,7 +250,7 @@ async function addRepoMutation(c: any, input: AddRepoInput): Promise<RepoRecord>
};
}
async function createHandoffMutation(c: any, input: CreateHandoffInput): Promise<HandoffRecord> {
async function createTaskMutation(c: any, input: CreateTaskInput): Promise<TaskRecord> {
assertWorkspace(c, input.workspaceId);
const { providers } = getActorRuntimeContext();
@ -272,10 +283,11 @@ async function createHandoffMutation(c: any, input: CreateHandoffInput): Promise
})
.run();
const project = await getOrCreateProject(c, c.state.workspaceId, repoId, remoteUrl);
await project.ensure({ remoteUrl });
const repo = await getOrCreateRepo(c, c.state.workspaceId, repoId, remoteUrl);
await repo.ensure({ remoteUrl });
const created = await project.createHandoff({
const created = await repo.createTask({
repoIds: input.repoIds,
task: input.task,
providerId,
agentType: input.agentType ?? null,
@ -285,19 +297,41 @@ async function createHandoffMutation(c: any, input: CreateHandoffInput): Promise
});
await c.db
.insert(handoffLookup)
.insert(taskLookup)
.values({
handoffId: created.handoffId,
taskId: created.taskId,
repoId
})
.onConflictDoUpdate({
target: handoffLookup.handoffId,
target: taskLookup.taskId,
set: { repoId }
})
.run();
const handoff = getHandoff(c, c.state.workspaceId, repoId, created.handoffId);
await handoff.provision({ providerId });
const task = getTask(c, c.state.workspaceId, repoId, created.taskId);
await task.provision({ providerId });
for (const linkedRepoId of input.repoIds ?? []) {
if (linkedRepoId === repoId) {
continue;
}
const linkedRepoRow = await c.db
.select({ remoteUrl: repos.remoteUrl })
.from(repos)
.where(eq(repos.repoId, linkedRepoId))
.get();
if (!linkedRepoRow) {
throw new Error(`Unknown linked repo: ${linkedRepoId}`);
}
const linkedRepo = await getOrCreateRepo(c, c.state.workspaceId, linkedRepoId, linkedRepoRow.remoteUrl);
await linkedRepo.ensure({ remoteUrl: linkedRepoRow.remoteUrl });
await linkedRepo.linkTask({
taskId: created.taskId,
branchName: null,
});
}
await workspaceActions.notifyWorkbenchUpdated(c);
return created;
@ -347,11 +381,11 @@ export async function runWorkspaceWorkflow(ctx: any): Promise<void> {
return Loop.continue(undefined);
}
if (msg.name === "workspace.command.createHandoff") {
if (msg.name === "workspace.command.createTask") {
const result = await loopCtx.step({
name: "workspace-create-handoff",
name: "workspace-create-task",
timeout: 12 * 60_000,
run: async () => createHandoffMutation(loopCtx, msg.body as CreateHandoffInput),
run: async () => createTaskMutation(loopCtx, msg.body as CreateTaskInput),
});
await msg.complete(result);
return Loop.continue(undefined);
@ -369,6 +403,8 @@ export async function runWorkspaceWorkflow(ctx: any): Promise<void> {
}
export const workspaceActions = {
...workspaceAppActions,
async useWorkspace(c: any, input: WorkspaceUseInput): Promise<{ workspaceId: string }> {
assertWorkspace(c, input.workspaceId);
return { workspaceId: c.state.workspaceId };
@ -407,17 +443,17 @@ export const workspaceActions = {
}));
},
async createHandoff(c: any, input: CreateHandoffInput): Promise<HandoffRecord> {
async createTask(c: any, input: CreateTaskInput): Promise<TaskRecord> {
const self = selfWorkspace(c);
return expectQueueResponse<HandoffRecord>(
await self.send(workspaceWorkflowQueueName("workspace.command.createHandoff"), input, {
return expectQueueResponse<TaskRecord>(
await self.send(workspaceWorkflowQueueName("workspace.command.createTask"), input, {
wait: true,
timeout: 12 * 60_000,
}),
);
},
async getWorkbench(c: any, input: WorkspaceUseInput): Promise<HandoffWorkbenchSnapshot> {
async getWorkbench(c: any, input: WorkspaceUseInput): Promise<TaskWorkbenchSnapshot> {
assertWorkspace(c, input.workspaceId);
return await buildWorkbenchSnapshot(c);
},
@ -426,84 +462,85 @@ export const workspaceActions = {
c.broadcast("workbenchUpdated", { at: Date.now() });
},
async createWorkbenchHandoff(c: any, input: HandoffWorkbenchCreateHandoffInput): Promise<{ handoffId: string }> {
const created = await workspaceActions.createHandoff(c, {
async createWorkbenchTask(c: any, input: TaskWorkbenchCreateTaskInput): Promise<{ taskId: string }> {
const created = await workspaceActions.createTask(c, {
workspaceId: c.state.workspaceId,
repoId: input.repoId,
...(input.repoIds?.length ? { repoIds: input.repoIds } : {}),
task: input.task,
...(input.title ? { explicitTitle: input.title } : {}),
...(input.branch ? { explicitBranchName: input.branch } : {}),
...(input.model ? { agentType: agentTypeForModel(input.model) } : {})
});
return { handoffId: created.handoffId };
return { taskId: created.taskId };
},
async markWorkbenchUnread(c: any, input: HandoffWorkbenchSelectInput): Promise<void> {
const handoff = await requireWorkbenchHandoff(c, input.handoffId);
await handoff.markWorkbenchUnread({});
async markWorkbenchUnread(c: any, input: TaskWorkbenchSelectInput): Promise<void> {
const task = await requireWorkbenchTask(c, input.taskId);
await task.markWorkbenchUnread({});
},
async renameWorkbenchHandoff(c: any, input: HandoffWorkbenchRenameInput): Promise<void> {
const handoff = await requireWorkbenchHandoff(c, input.handoffId);
await handoff.renameWorkbenchHandoff(input);
async renameWorkbenchTask(c: any, input: TaskWorkbenchRenameInput): Promise<void> {
const task = await requireWorkbenchTask(c, input.taskId);
await task.renameWorkbenchTask(input);
},
async renameWorkbenchBranch(c: any, input: HandoffWorkbenchRenameInput): Promise<void> {
const handoff = await requireWorkbenchHandoff(c, input.handoffId);
await handoff.renameWorkbenchBranch(input);
async renameWorkbenchBranch(c: any, input: TaskWorkbenchRenameInput): Promise<void> {
const task = await requireWorkbenchTask(c, input.taskId);
await task.renameWorkbenchBranch(input);
},
async createWorkbenchSession(c: any, input: HandoffWorkbenchSelectInput & { model?: string }): Promise<{ tabId: string }> {
const handoff = await requireWorkbenchHandoff(c, input.handoffId);
return await handoff.createWorkbenchSession({ ...(input.model ? { model: input.model } : {}) });
async createWorkbenchSession(c: any, input: TaskWorkbenchSelectInput & { model?: string }): Promise<{ tabId: string }> {
const task = await requireWorkbenchTask(c, input.taskId);
return await task.createWorkbenchSession({ ...(input.model ? { model: input.model } : {}) });
},
async renameWorkbenchSession(c: any, input: HandoffWorkbenchRenameSessionInput): Promise<void> {
const handoff = await requireWorkbenchHandoff(c, input.handoffId);
await handoff.renameWorkbenchSession(input);
async renameWorkbenchSession(c: any, input: TaskWorkbenchRenameSessionInput): Promise<void> {
const task = await requireWorkbenchTask(c, input.taskId);
await task.renameWorkbenchSession(input);
},
async setWorkbenchSessionUnread(c: any, input: HandoffWorkbenchSetSessionUnreadInput): Promise<void> {
const handoff = await requireWorkbenchHandoff(c, input.handoffId);
await handoff.setWorkbenchSessionUnread(input);
async setWorkbenchSessionUnread(c: any, input: TaskWorkbenchSetSessionUnreadInput): Promise<void> {
const task = await requireWorkbenchTask(c, input.taskId);
await task.setWorkbenchSessionUnread(input);
},
async updateWorkbenchDraft(c: any, input: HandoffWorkbenchUpdateDraftInput): Promise<void> {
const handoff = await requireWorkbenchHandoff(c, input.handoffId);
await handoff.updateWorkbenchDraft(input);
async updateWorkbenchDraft(c: any, input: TaskWorkbenchUpdateDraftInput): Promise<void> {
const task = await requireWorkbenchTask(c, input.taskId);
await task.updateWorkbenchDraft(input);
},
async changeWorkbenchModel(c: any, input: HandoffWorkbenchChangeModelInput): Promise<void> {
const handoff = await requireWorkbenchHandoff(c, input.handoffId);
await handoff.changeWorkbenchModel(input);
async changeWorkbenchModel(c: any, input: TaskWorkbenchChangeModelInput): Promise<void> {
const task = await requireWorkbenchTask(c, input.taskId);
await task.changeWorkbenchModel(input);
},
async sendWorkbenchMessage(c: any, input: HandoffWorkbenchSendMessageInput): Promise<void> {
const handoff = await requireWorkbenchHandoff(c, input.handoffId);
await handoff.sendWorkbenchMessage(input);
async sendWorkbenchMessage(c: any, input: TaskWorkbenchSendMessageInput): Promise<void> {
const task = await requireWorkbenchTask(c, input.taskId);
await task.sendWorkbenchMessage(input);
},
async stopWorkbenchSession(c: any, input: HandoffWorkbenchTabInput): Promise<void> {
const handoff = await requireWorkbenchHandoff(c, input.handoffId);
await handoff.stopWorkbenchSession(input);
async stopWorkbenchSession(c: any, input: TaskWorkbenchTabInput): Promise<void> {
const task = await requireWorkbenchTask(c, input.taskId);
await task.stopWorkbenchSession(input);
},
async closeWorkbenchSession(c: any, input: HandoffWorkbenchTabInput): Promise<void> {
const handoff = await requireWorkbenchHandoff(c, input.handoffId);
await handoff.closeWorkbenchSession(input);
async closeWorkbenchSession(c: any, input: TaskWorkbenchTabInput): Promise<void> {
const task = await requireWorkbenchTask(c, input.taskId);
await task.closeWorkbenchSession(input);
},
async publishWorkbenchPr(c: any, input: HandoffWorkbenchSelectInput): Promise<void> {
const handoff = await requireWorkbenchHandoff(c, input.handoffId);
await handoff.publishWorkbenchPr({});
async publishWorkbenchPr(c: any, input: TaskWorkbenchSelectInput): Promise<void> {
const task = await requireWorkbenchTask(c, input.taskId);
await task.publishWorkbenchPr({});
},
async revertWorkbenchFile(c: any, input: HandoffWorkbenchDiffInput): Promise<void> {
const handoff = await requireWorkbenchHandoff(c, input.handoffId);
await handoff.revertWorkbenchFile(input);
async revertWorkbenchFile(c: any, input: TaskWorkbenchDiffInput): Promise<void> {
const task = await requireWorkbenchTask(c, input.taskId);
await task.revertWorkbenchFile(input);
},
async listHandoffs(c: any, input: ListHandoffsInput): Promise<HandoffSummary[]> {
async listTasks(c: any, input: ListTasksInput): Promise<TaskSummary[]> {
assertWorkspace(c, input.workspaceId);
if (input.repoId) {
@ -516,11 +553,11 @@ export const workspaceActions = {
throw new Error(`Unknown repo: ${input.repoId}`);
}
const project = await getOrCreateProject(c, c.state.workspaceId, input.repoId, repoRow.remoteUrl);
return await project.listHandoffSummaries({ includeArchived: true });
const repo = await getOrCreateRepo(c, c.state.workspaceId, input.repoId, repoRow.remoteUrl);
return await repo.listTaskSummaries({ includeArchived: true });
}
return await collectAllHandoffSummaries(c);
return await collectAllTaskSummaries(c);
},
async getRepoOverview(c: any, input: RepoOverviewInput): Promise<RepoOverview> {
@ -535,9 +572,9 @@ export const workspaceActions = {
throw new Error(`Unknown repo: ${input.repoId}`);
}
const project = await getOrCreateProject(c, c.state.workspaceId, input.repoId, repoRow.remoteUrl);
await project.ensure({ remoteUrl: repoRow.remoteUrl });
return await project.getRepoOverview({});
const repo = await getOrCreateRepo(c, c.state.workspaceId, input.repoId, repoRow.remoteUrl);
await repo.ensure({ remoteUrl: repoRow.remoteUrl });
return await repo.getRepoOverview({});
},
async runRepoStackAction(c: any, input: RepoStackActionInput): Promise<RepoStackActionResult> {
@ -552,24 +589,25 @@ export const workspaceActions = {
throw new Error(`Unknown repo: ${input.repoId}`);
}
const project = await getOrCreateProject(c, c.state.workspaceId, input.repoId, repoRow.remoteUrl);
await project.ensure({ remoteUrl: repoRow.remoteUrl });
return await project.runRepoStackAction({
const repo = await getOrCreateRepo(c, c.state.workspaceId, input.repoId, repoRow.remoteUrl);
await repo.ensure({ remoteUrl: repoRow.remoteUrl });
return await repo.runRepoStackAction({
action: input.action,
branchName: input.branchName,
parentBranch: input.parentBranch
});
},
async switchHandoff(c: any, handoffId: string): Promise<SwitchResult> {
const repoId = await resolveRepoId(c, handoffId);
const h = getHandoff(c, c.state.workspaceId, repoId, handoffId);
async switchTask(c: any, taskId: string): Promise<SwitchResult> {
const repoId = await resolveRepoId(c, taskId);
void repoId;
const h = getTask(c, c.state.workspaceId, taskId);
const record = await h.get();
const switched = await h.switch();
return {
workspaceId: c.state.workspaceId,
handoffId,
taskId,
providerId: record.providerId,
switchTarget: switched.switchTarget
};
@ -596,7 +634,7 @@ export const workspaceActions = {
const hist = await getOrCreateHistory(c, c.state.workspaceId, row.repoId);
const items = await hist.list({
branch: input.branch,
handoffId: input.handoffId,
taskId: input.taskId,
limit
});
allEvents.push(...items);
@ -613,10 +651,10 @@ export const workspaceActions = {
return allEvents.slice(0, limit);
},
async getHandoff(c: any, input: GetHandoffInput): Promise<HandoffRecord> {
async getTask(c: any, input: GetTaskInput): Promise<TaskRecord> {
assertWorkspace(c, input.workspaceId);
const repoId = await resolveRepoId(c, input.handoffId);
const repoId = await resolveRepoId(c, input.taskId);
const repoRow = await c.db
.select({ remoteUrl: repos.remoteUrl })
@ -627,49 +665,55 @@ export const workspaceActions = {
throw new Error(`Unknown repo: ${repoId}`);
}
const project = await getOrCreateProject(c, c.state.workspaceId, repoId, repoRow.remoteUrl);
return await project.getHandoffEnriched({ handoffId: input.handoffId });
const repo = await getOrCreateRepo(c, c.state.workspaceId, repoId, repoRow.remoteUrl);
return await repo.getTaskEnriched({ taskId: input.taskId });
},
async attachHandoff(c: any, input: HandoffProxyActionInput): Promise<{ target: string; sessionId: string | null }> {
async attachTask(c: any, input: TaskProxyActionInput): Promise<{ target: string; sessionId: string | null }> {
assertWorkspace(c, input.workspaceId);
const repoId = await resolveRepoId(c, input.handoffId);
const h = getHandoff(c, c.state.workspaceId, repoId, input.handoffId);
const repoId = await resolveRepoId(c, input.taskId);
void repoId;
const h = getTask(c, c.state.workspaceId, input.taskId);
return await h.attach({ reason: input.reason });
},
async pushHandoff(c: any, input: HandoffProxyActionInput): Promise<void> {
async pushTask(c: any, input: TaskProxyActionInput): Promise<void> {
assertWorkspace(c, input.workspaceId);
const repoId = await resolveRepoId(c, input.handoffId);
const h = getHandoff(c, c.state.workspaceId, repoId, input.handoffId);
const repoId = await resolveRepoId(c, input.taskId);
void repoId;
const h = getTask(c, c.state.workspaceId, input.taskId);
await h.push({ reason: input.reason });
},
async syncHandoff(c: any, input: HandoffProxyActionInput): Promise<void> {
async syncTask(c: any, input: TaskProxyActionInput): Promise<void> {
assertWorkspace(c, input.workspaceId);
const repoId = await resolveRepoId(c, input.handoffId);
const h = getHandoff(c, c.state.workspaceId, repoId, input.handoffId);
const repoId = await resolveRepoId(c, input.taskId);
void repoId;
const h = getTask(c, c.state.workspaceId, input.taskId);
await h.sync({ reason: input.reason });
},
async mergeHandoff(c: any, input: HandoffProxyActionInput): Promise<void> {
async mergeTask(c: any, input: TaskProxyActionInput): Promise<void> {
assertWorkspace(c, input.workspaceId);
const repoId = await resolveRepoId(c, input.handoffId);
const h = getHandoff(c, c.state.workspaceId, repoId, input.handoffId);
const repoId = await resolveRepoId(c, input.taskId);
void repoId;
const h = getTask(c, c.state.workspaceId, input.taskId);
await h.merge({ reason: input.reason });
},
async archiveHandoff(c: any, input: HandoffProxyActionInput): Promise<void> {
async archiveTask(c: any, input: TaskProxyActionInput): Promise<void> {
assertWorkspace(c, input.workspaceId);
const repoId = await resolveRepoId(c, input.handoffId);
const h = getHandoff(c, c.state.workspaceId, repoId, input.handoffId);
const repoId = await resolveRepoId(c, input.taskId);
void repoId;
const h = getTask(c, c.state.workspaceId, input.taskId);
await h.archive({ reason: input.reason });
},
async killHandoff(c: any, input: HandoffProxyActionInput): Promise<void> {
async killTask(c: any, input: TaskProxyActionInput): Promise<void> {
assertWorkspace(c, input.workspaceId);
const repoId = await resolveRepoId(c, input.handoffId);
const h = getHandoff(c, c.state.workspaceId, repoId, input.handoffId);
const repoId = await resolveRepoId(c, input.taskId);
void repoId;
const h = getTask(c, c.state.workspaceId, input.taskId);
await h.kill({ reason: input.reason });
}
};

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,4 @@
CREATE TABLE `handoff_lookup` (
CREATE TABLE `task_lookup` (
`handoff_id` text PRIMARY KEY NOT NULL,
`repo_id` text NOT NULL
);

View file

@ -0,0 +1,8 @@
ALTER TABLE `organization_profile` ADD COLUMN `github_sync_status` text NOT NULL DEFAULT 'pending';
ALTER TABLE `organization_profile` ADD COLUMN `github_last_sync_at` integer;
UPDATE `organization_profile`
SET `github_sync_status` = CASE
WHEN `repo_import_status` = 'ready' THEN 'synced'
WHEN `repo_import_status` = 'importing' THEN 'syncing'
ELSE 'pending'
END;

View file

@ -21,6 +21,48 @@ const journal = {
"when": 1772668800000,
"tag": "0002_tiny_silver_surfer",
"breakpoints": true
},
{
"idx": 3,
"when": 1773100800000,
"tag": "0003_app_shell_organization_profile",
"breakpoints": true
},
{
"idx": 4,
"when": 1773100800001,
"tag": "0004_app_shell_organization_members",
"breakpoints": true
},
{
"idx": 5,
"when": 1773100800002,
"tag": "0005_app_shell_seat_assignments",
"breakpoints": true
},
{
"idx": 6,
"when": 1773100800003,
"tag": "0006_app_shell_invoices",
"breakpoints": true
},
{
"idx": 7,
"when": 1773100800004,
"tag": "0007_app_shell_sessions",
"breakpoints": true
},
{
"idx": 8,
"when": 1773100800005,
"tag": "0008_app_shell_stripe_lookup",
"breakpoints": true
},
{
"idx": 9,
"when": 1773100800006,
"tag": "0009_github_sync_status",
"breakpoints": true
}
]
} as const;
@ -41,10 +83,94 @@ export default {
\`updated_at\` integer NOT NULL
);
`,
m0002: `CREATE TABLE \`handoff_lookup\` (
\`handoff_id\` text PRIMARY KEY NOT NULL,
m0002: `CREATE TABLE \`task_lookup\` (
\`task_id\` text PRIMARY KEY NOT NULL,
\`repo_id\` text NOT NULL
);
`,
m0003: `CREATE TABLE \`organization_profile\` (
\`id\` text PRIMARY KEY NOT NULL,
\`kind\` text NOT NULL,
\`github_account_id\` text NOT NULL,
\`github_login\` text NOT NULL,
\`github_account_type\` text NOT NULL,
\`display_name\` text NOT NULL,
\`slug\` text NOT NULL,
\`primary_domain\` text NOT NULL,
\`default_model\` text NOT NULL,
\`auto_import_repos\` integer NOT NULL,
\`repo_import_status\` text NOT NULL,
\`github_connected_account\` text NOT NULL,
\`github_installation_status\` text NOT NULL,
\`github_installation_id\` integer,
\`github_last_sync_label\` text NOT NULL,
\`stripe_customer_id\` text,
\`stripe_subscription_id\` text,
\`stripe_price_id\` text,
\`billing_plan_id\` text NOT NULL,
\`billing_status\` text NOT NULL,
\`billing_seats_included\` integer NOT NULL,
\`billing_trial_ends_at\` text,
\`billing_renewal_at\` text,
\`billing_payment_method_label\` text NOT NULL,
\`created_at\` integer NOT NULL,
\`updated_at\` integer NOT NULL
);
`,
m0004: `CREATE TABLE \`organization_members\` (
\`id\` text PRIMARY KEY NOT NULL,
\`name\` text NOT NULL,
\`email\` text NOT NULL,
\`role\` text NOT NULL,
\`state\` text NOT NULL,
\`updated_at\` integer NOT NULL
);
`,
m0005: `CREATE TABLE \`seat_assignments\` (
\`email\` text PRIMARY KEY NOT NULL,
\`created_at\` integer NOT NULL
);
`,
m0006: `CREATE TABLE \`invoices\` (
\`id\` text PRIMARY KEY NOT NULL,
\`label\` text NOT NULL,
\`issued_at\` text NOT NULL,
\`amount_usd\` integer NOT NULL,
\`status\` text NOT NULL,
\`created_at\` integer NOT NULL
);
`,
m0007: `CREATE TABLE \`app_sessions\` (
\`id\` text PRIMARY KEY NOT NULL,
\`current_user_id\` text,
\`current_user_name\` text,
\`current_user_email\` text,
\`current_user_github_login\` text,
\`current_user_role_label\` text,
\`eligible_organization_ids_json\` text NOT NULL,
\`active_organization_id\` text,
\`github_access_token\` text,
\`github_scope\` text NOT NULL,
\`oauth_state\` text,
\`oauth_state_expires_at\` integer,
\`created_at\` integer NOT NULL,
\`updated_at\` integer NOT NULL
);
`,
m0008: `CREATE TABLE \`stripe_lookup\` (
\`lookup_key\` text PRIMARY KEY NOT NULL,
\`organization_id\` text NOT NULL,
\`updated_at\` integer NOT NULL
);
`,
m0009: `ALTER TABLE \`organization_profile\` ADD COLUMN \`github_sync_status\` text NOT NULL DEFAULT 'pending';
ALTER TABLE \`organization_profile\` ADD COLUMN \`github_last_sync_at\` integer;
UPDATE \`organization_profile\`
SET \`github_sync_status\` = CASE
WHEN \`repo_import_status\` = 'ready' THEN 'synced'
WHEN \`repo_import_status\` = 'importing' THEN 'syncing'
ELSE 'pending'
END;
`,
} as const
};

View file

@ -14,7 +14,84 @@ export const repos = sqliteTable("repos", {
updatedAt: integer("updated_at").notNull(),
});
export const handoffLookup = sqliteTable("handoff_lookup", {
handoffId: text("handoff_id").notNull().primaryKey(),
export const taskLookup = sqliteTable("task_lookup", {
taskId: text("task_id").notNull().primaryKey(),
repoId: text("repo_id").notNull(),
});
export const organizationProfile = sqliteTable("organization_profile", {
id: text("id").notNull().primaryKey(),
kind: text("kind").notNull(),
githubAccountId: text("github_account_id").notNull(),
githubLogin: text("github_login").notNull(),
githubAccountType: text("github_account_type").notNull(),
displayName: text("display_name").notNull(),
slug: text("slug").notNull(),
primaryDomain: text("primary_domain").notNull(),
defaultModel: text("default_model").notNull(),
autoImportRepos: integer("auto_import_repos").notNull(),
repoImportStatus: text("repo_import_status").notNull(),
githubConnectedAccount: text("github_connected_account").notNull(),
githubInstallationStatus: text("github_installation_status").notNull(),
githubSyncStatus: text("github_sync_status").notNull(),
githubInstallationId: integer("github_installation_id"),
githubLastSyncLabel: text("github_last_sync_label").notNull(),
githubLastSyncAt: integer("github_last_sync_at"),
stripeCustomerId: text("stripe_customer_id"),
stripeSubscriptionId: text("stripe_subscription_id"),
stripePriceId: text("stripe_price_id"),
billingPlanId: text("billing_plan_id").notNull(),
billingStatus: text("billing_status").notNull(),
billingSeatsIncluded: integer("billing_seats_included").notNull(),
billingTrialEndsAt: text("billing_trial_ends_at"),
billingRenewalAt: text("billing_renewal_at"),
billingPaymentMethodLabel: text("billing_payment_method_label").notNull(),
createdAt: integer("created_at").notNull(),
updatedAt: integer("updated_at").notNull(),
});
export const organizationMembers = sqliteTable("organization_members", {
id: text("id").notNull().primaryKey(),
name: text("name").notNull(),
email: text("email").notNull(),
role: text("role").notNull(),
state: text("state").notNull(),
updatedAt: integer("updated_at").notNull(),
});
export const seatAssignments = sqliteTable("seat_assignments", {
email: text("email").notNull().primaryKey(),
createdAt: integer("created_at").notNull(),
});
export const invoices = sqliteTable("invoices", {
id: text("id").notNull().primaryKey(),
label: text("label").notNull(),
issuedAt: text("issued_at").notNull(),
amountUsd: integer("amount_usd").notNull(),
status: text("status").notNull(),
createdAt: integer("created_at").notNull(),
});
export const appSessions = sqliteTable("app_sessions", {
id: text("id").notNull().primaryKey(),
currentUserId: text("current_user_id"),
currentUserName: text("current_user_name"),
currentUserEmail: text("current_user_email"),
currentUserGithubLogin: text("current_user_github_login"),
currentUserRoleLabel: text("current_user_role_label"),
eligibleOrganizationIdsJson: text("eligible_organization_ids_json").notNull(),
activeOrganizationId: text("active_organization_id"),
githubAccessToken: text("github_access_token"),
githubScope: text("github_scope").notNull(),
oauthState: text("oauth_state"),
oauthStateExpiresAt: integer("oauth_state_expires_at"),
createdAt: integer("created_at").notNull(),
updatedAt: integer("updated_at").notNull(),
});
export const stripeLookup = sqliteTable("stripe_lookup", {
lookupKey: text("lookup_key").notNull().primaryKey(),
organizationId: text("organization_id").notNull(),
updatedAt: integer("updated_at").notNull(),
});

View file

@ -9,8 +9,9 @@ import { createBackends, createNotificationService } from "./notifications/index
import { createDefaultDriver } from "./driver.js";
import { createProviderRegistry } from "./providers/index.js";
import { createClient } from "rivetkit/client";
import { FactoryAppStore } from "./services/app-state.js";
import type { FactoryBillingPlanId, FactoryOrganization } from "@sandbox-agent/factory-shared";
import type { FactoryBillingPlanId } from "@sandbox-agent/factory-shared";
import { createDefaultAppShellServices } from "./services/app-shell-runtime.js";
import { APP_SHELL_WORKSPACE_ID } from "./actors/workspace/app-shell.js";
export interface BackendStartOptions {
host?: string;
@ -18,6 +19,7 @@ export interface BackendStartOptions {
}
export async function startBackend(options: BackendStartOptions = {}): Promise<void> {
process.env.NODE_ENV ||= "development";
loadDevelopmentEnvFiles();
applyDevelopmentEnvDefaults();
@ -50,36 +52,15 @@ export async function startBackend(options: BackendStartOptions = {}): Promise<v
const providers = createProviderRegistry(config, driver);
const backends = await createBackends(config.notify);
const notifications = createNotificationService(backends);
initActorRuntimeContext(config, providers, notifications, driver);
initActorRuntimeContext(config, providers, notifications, driver, createDefaultAppShellServices());
registry.startRunner();
const inner = registry.serve();
const actorClient = createClient({
endpoint: `http://127.0.0.1:${resolveManagerPort()}`,
disableMetadataLookup: true,
}) as any;
const syncOrganizationRepos = async (organization: FactoryOrganization): Promise<void> => {
const workspace = await actorClient.workspace.getOrCreate(workspaceKey(organization.workspaceId), {
createWithInput: organization.workspaceId,
});
const existing = await workspace.listRepos({ workspaceId: organization.workspaceId });
const existingRemotes = new Set(existing.map((repo: { remoteUrl: string }) => repo.remoteUrl));
for (const repo of organization.repoCatalog) {
const remoteUrl = `mockgithub://${repo}`;
if (existingRemotes.has(remoteUrl)) {
continue;
}
await workspace.addRepo({
workspaceId: organization.workspaceId,
remoteUrl,
});
}
};
const appStore = new FactoryAppStore({
onOrganizationReposReady: syncOrganizationRepos,
});
const managerOrigin = `http://127.0.0.1:${resolveManagerPort()}`;
// Wrap in a Hono app mounted at /api/rivet to serve on the backend port.
@ -87,14 +68,31 @@ export async function startBackend(options: BackendStartOptions = {}): Promise<v
// RivetKit's internal Bun.serve manager server (Bun bug: mixing Node HTTP
// server and Bun.serve in the same process breaks Bun.serve's fetch handler).
const app = new Hono();
const allowHeaders = [
"Content-Type",
"Authorization",
"x-rivet-token",
"x-rivet-encoding",
"x-rivet-query",
"x-rivet-conn-params",
"x-rivet-actor",
"x-rivet-target",
"x-rivet-namespace",
"x-rivet-endpoint",
"x-rivet-total-slots",
"x-rivet-runner-name",
"x-rivet-namespace-name",
"x-factory-session",
];
const exposeHeaders = ["Content-Type", "x-factory-session", "x-rivet-ray-id"];
app.use(
"/api/rivet/*",
cors({
origin: (origin) => origin ?? "*",
credentials: true,
allowHeaders: ["Content-Type", "Authorization", "x-rivet-token", "x-factory-session"],
allowHeaders,
allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
exposeHeaders: ["Content-Type", "x-factory-session"],
exposeHeaders,
})
);
app.use(
@ -102,44 +100,68 @@ export async function startBackend(options: BackendStartOptions = {}): Promise<v
cors({
origin: (origin) => origin ?? "*",
credentials: true,
allowHeaders: ["Content-Type", "Authorization", "x-rivet-token", "x-factory-session"],
allowHeaders,
allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
exposeHeaders: ["Content-Type", "x-factory-session"],
exposeHeaders,
})
);
const resolveSessionId = (c: any): string => {
const appWorkspace = async () =>
await actorClient.workspace.getOrCreate(workspaceKey(APP_SHELL_WORKSPACE_ID), {
createWithInput: APP_SHELL_WORKSPACE_ID,
});
const resolveSessionId = async (c: any): Promise<string> => {
const requested = c.req.header("x-factory-session");
const sessionId = appStore.ensureSession(requested);
const { sessionId } = await (await appWorkspace()).ensureAppSession({
requestedSessionId: requested ?? null,
});
c.header("x-factory-session", sessionId);
return sessionId;
};
app.get("/api/rivet/app/snapshot", (c) => {
const sessionId = resolveSessionId(c);
return c.json(appStore.getSnapshot(sessionId));
app.get("/api/rivet/app/snapshot", async (c) => {
const sessionId = await resolveSessionId(c);
return c.json(await (await appWorkspace()).getAppSnapshot({ sessionId }));
});
app.post("/api/rivet/app/sign-in", async (c) => {
const sessionId = resolveSessionId(c);
const body = await c.req.json().catch(() => ({}));
const userId = typeof body?.userId === "string" ? body.userId : undefined;
return c.json(appStore.signInWithGithub(sessionId, userId));
app.get("/api/rivet/app/auth/github/start", async (c) => {
const sessionId = await resolveSessionId(c);
const result = await (await appWorkspace()).startAppGithubAuth({ sessionId });
return Response.redirect(result.url, 302);
});
app.post("/api/rivet/app/sign-out", (c) => {
const sessionId = resolveSessionId(c);
return c.json(appStore.signOut(sessionId));
app.get("/api/rivet/app/auth/github/callback", async (c) => {
const code = c.req.query("code");
const state = c.req.query("state");
if (!code || !state) {
return c.text("Missing GitHub OAuth callback parameters", 400);
}
const result = await (await appWorkspace()).completeAppGithubAuth({ code, state });
c.header("x-factory-session", result.sessionId);
return Response.redirect(result.redirectTo, 302);
});
app.post("/api/rivet/app/sign-out", async (c) => {
const sessionId = await resolveSessionId(c);
return c.json(await (await appWorkspace()).signOutApp({ sessionId }));
});
app.post("/api/rivet/app/organizations/:organizationId/select", async (c) => {
const sessionId = resolveSessionId(c);
return c.json(await appStore.selectOrganization(sessionId, c.req.param("organizationId")));
const sessionId = await resolveSessionId(c);
return c.json(
await (await appWorkspace()).selectAppOrganization({
sessionId,
organizationId: c.req.param("organizationId"),
}),
);
});
app.patch("/api/rivet/app/organizations/:organizationId/profile", async (c) => {
const sessionId = await resolveSessionId(c);
const body = await c.req.json();
return c.json(
appStore.updateOrganizationProfile({
await (await appWorkspace()).updateAppOrganizationProfile({
sessionId,
organizationId: c.req.param("organizationId"),
displayName: typeof body?.displayName === "string" ? body.displayName : "",
slug: typeof body?.slug === "string" ? body.slug : "",
@ -149,38 +171,116 @@ export async function startBackend(options: BackendStartOptions = {}): Promise<v
});
app.post("/api/rivet/app/organizations/:organizationId/import", async (c) => {
return c.json(await appStore.triggerRepoImport(c.req.param("organizationId")));
const sessionId = await resolveSessionId(c);
return c.json(
await (await appWorkspace()).triggerAppRepoImport({
sessionId,
organizationId: c.req.param("organizationId"),
}),
);
});
app.post("/api/rivet/app/organizations/:organizationId/reconnect", (c) => {
return c.json(appStore.reconnectGithub(c.req.param("organizationId")));
app.post("/api/rivet/app/organizations/:organizationId/reconnect", async (c) => {
const sessionId = await resolveSessionId(c);
return c.json(
await (await appWorkspace()).beginAppGithubInstall({
sessionId,
organizationId: c.req.param("organizationId"),
}),
);
});
app.post("/api/rivet/app/organizations/:organizationId/billing/checkout", async (c) => {
const sessionId = await resolveSessionId(c);
const body = await c.req.json().catch(() => ({}));
const planId =
body?.planId === "free" || body?.planId === "team" || body?.planId === "enterprise"
? (body.planId as FactoryBillingPlanId)
: "team";
return c.json(appStore.completeHostedCheckout(c.req.param("organizationId"), planId));
const planId = body?.planId === "free" || body?.planId === "team" ? (body.planId as FactoryBillingPlanId) : "team";
return c.json(
await (await appWorkspace()).createAppCheckoutSession({
sessionId,
organizationId: c.req.param("organizationId"),
planId,
}),
);
});
app.post("/api/rivet/app/organizations/:organizationId/billing/cancel", (c) => {
return c.json(appStore.cancelScheduledRenewal(c.req.param("organizationId")));
});
app.post("/api/rivet/app/organizations/:organizationId/billing/resume", (c) => {
return c.json(appStore.resumeSubscription(c.req.param("organizationId")));
});
app.post("/api/rivet/app/workspaces/:workspaceId/seat-usage", (c) => {
const sessionId = resolveSessionId(c);
const workspaceId = c.req.param("workspaceId");
const userEmail = appStore.findUserEmailForWorkspace(workspaceId, sessionId);
if (userEmail) {
appStore.recordSeatUsage(workspaceId, userEmail);
app.get("/api/rivet/app/billing/checkout/complete", async (c) => {
const organizationId = c.req.query("organizationId");
const sessionId = c.req.query("factorySession");
const checkoutSessionId = c.req.query("session_id");
if (!organizationId || !sessionId || !checkoutSessionId) {
return c.text("Missing Stripe checkout completion parameters", 400);
}
return c.json(appStore.getSnapshot(sessionId));
const result = await (await appWorkspace()).finalizeAppCheckoutSession({
organizationId,
sessionId,
checkoutSessionId,
});
return Response.redirect(result.redirectTo, 302);
});
app.post("/api/rivet/app/organizations/:organizationId/billing/portal", async (c) => {
const sessionId = await resolveSessionId(c);
return c.json(
await (await appWorkspace()).createAppBillingPortalSession({
sessionId,
organizationId: c.req.param("organizationId"),
}),
);
});
app.post("/api/rivet/app/organizations/:organizationId/billing/cancel", async (c) => {
const sessionId = await resolveSessionId(c);
return c.json(
await (await appWorkspace()).cancelAppScheduledRenewal({
sessionId,
organizationId: c.req.param("organizationId"),
}),
);
});
app.post("/api/rivet/app/organizations/:organizationId/billing/resume", async (c) => {
const sessionId = await resolveSessionId(c);
return c.json(
await (await appWorkspace()).resumeAppSubscription({
sessionId,
organizationId: c.req.param("organizationId"),
}),
);
});
const handleStripeWebhook = async (c: any) => {
const payload = await c.req.text();
await (await appWorkspace()).handleAppStripeWebhook({
payload,
signatureHeader: c.req.header("stripe-signature") ?? null,
});
return c.json({ ok: true });
};
app.post("/api/rivet/app/webhooks/stripe", handleStripeWebhook);
app.post("/api/rivet/app/stripe/webhook", handleStripeWebhook);
const handleGithubWebhook = async (c: any) => {
const payload = await c.req.text();
await (await appWorkspace()).handleAppGithubWebhook({
payload,
signatureHeader: c.req.header("x-hub-signature-256") ?? null,
eventHeader: c.req.header("x-github-event") ?? null,
});
return c.json({ ok: true });
};
app.post("/api/rivet/app/webhooks/github", handleGithubWebhook);
app.post("/api/rivet/app/workspaces/:workspaceId/seat-usage", async (c) => {
const sessionId = await resolveSessionId(c);
const workspaceId = c.req.param("workspaceId");
return c.json(
await (await appWorkspace()).recordAppSeatUsage({
sessionId,
workspaceId,
}),
);
});
const proxyManagerRequest = async (c: any) => {
@ -192,7 +292,17 @@ export async function startBackend(options: BackendStartOptions = {}): Promise<v
const forward = async (c: any) => {
try {
const pathname = new URL(c.req.url).pathname;
if (pathname === "/api/rivet/app/webhooks/stripe" || pathname === "/api/rivet/app/stripe/webhook") {
return await handleStripeWebhook(c);
}
if (pathname === "/api/rivet/app/webhooks/github") {
return await handleGithubWebhook(c);
}
if (pathname.startsWith("/api/rivet/app/")) {
return c.text("Not Found", 404);
}
if (
pathname === "/api/rivet/metadata" ||
pathname === "/api/rivet/actors" ||
pathname.startsWith("/api/rivet/actors/") ||
pathname.startsWith("/api/rivet/gateway/")

View file

@ -169,7 +169,7 @@ export class SandboxAgentClient {
// If the agent doesn't support session modes, ignore.
//
// Do this in the background: ACP mode updates can occasionally time out (504),
// and waiting here can stall session creation long enough to trip handoff init
// and waiting here can stall session creation long enough to trip task init
// step timeouts even though the session itself was created.
if (modeId) {
void session.send("session/set_mode", { modeId }).catch(() => {

View file

@ -12,7 +12,7 @@ export interface NotificationService {
prApproved(branchName: string, prNumber: number, reviewer: string): Promise<void>;
changesRequested(branchName: string, prNumber: number, reviewer: string): Promise<void>;
prMerged(branchName: string, prNumber: number): Promise<void>;
handoffCreated(branchName: string): Promise<void>;
taskCreated(branchName: string): Promise<void>;
}
export function createNotificationService(backends: NotifyBackend[]): NotificationService {
@ -60,8 +60,8 @@ export function createNotificationService(backends: NotifyBackend[]): Notificati
await notify("PR Merged", `PR #${prNumber} on ${branchName} merged`, "normal");
},
async handoffCreated(branchName: string): Promise<void> {
await notify("Handoff Created", `New handoff on ${branchName}`, "low");
async taskCreated(branchName: string): Promise<void> {
await notify("Task Created", `New task on ${branchName}`, "low");
},
};
}

View file

@ -195,7 +195,7 @@ export class DaytonaProvider implements SandboxProvider {
emitDebug("daytona.createSandbox.start", {
workspaceId: req.workspaceId,
repoId: req.repoId,
handoffId: req.handoffId,
taskId: req.taskId,
branchName: req.branchName
});
@ -206,7 +206,7 @@ export class DaytonaProvider implements SandboxProvider {
envVars: this.buildEnvVars(),
labels: {
"factory.workspace": req.workspaceId,
"factory.handoff": req.handoffId,
"factory.task": req.taskId,
"factory.repo_id": req.repoId,
"factory.repo_remote": req.repoRemote,
"factory.branch": req.branchName,
@ -220,9 +220,9 @@ export class DaytonaProvider implements SandboxProvider {
state: sandbox.state ?? null
});
const repoDir = `/home/daytona/sandbox-agent-factory/${req.workspaceId}/${req.repoId}/${req.handoffId}/repo`;
const repoDir = `/home/daytona/sandbox-agent-factory/${req.workspaceId}/${req.repoId}/${req.taskId}/repo`;
// Prepare a working directory for the agent. This must succeed for the handoff to work.
// Prepare a working directory for the agent. This must succeed for the task to work.
const installStartedAt = Date.now();
await this.runCheckedCommand(
sandbox.id,
@ -256,7 +256,7 @@ export class DaytonaProvider implements SandboxProvider {
`git clone "${req.repoRemote}" "${repoDir}"`,
`cd "${repoDir}"`,
`git fetch origin --prune`,
// The handoff branch may not exist remotely yet (agent push creates it). Base off current branch (default branch).
// The task branch may not exist remotely yet (agent push creates it). Base off current branch (default branch).
`if git show-ref --verify --quiet "refs/remotes/origin/${req.branchName}"; then git checkout -B "${req.branchName}" "origin/${req.branchName}"; else git checkout -B "${req.branchName}" "$(git branch --show-current 2>/dev/null || echo main)"; fi`,
`git config user.email "factory@local" >/dev/null 2>&1 || true`,
`git config user.name "Sandbox Agent Factory" >/dev/null 2>&1 || true`,
@ -296,10 +296,10 @@ export class DaytonaProvider implements SandboxProvider {
const labels = info.labels ?? {};
const workspaceId = labels["factory.workspace"] ?? req.workspaceId;
const repoId = labels["factory.repo_id"] ?? "";
const handoffId = labels["factory.handoff"] ?? "";
const taskId = labels["factory.task"] ?? "";
const cwd =
repoId && handoffId
? `/home/daytona/sandbox-agent-factory/${workspaceId}/${repoId}/${handoffId}/repo`
repoId && taskId
? `/home/daytona/sandbox-agent-factory/${workspaceId}/${repoId}/${taskId}/repo`
: null;
return {

View file

@ -160,7 +160,7 @@ export class LocalProvider implements SandboxProvider {
}
async createSandbox(req: CreateSandboxRequest): Promise<SandboxHandle> {
const sandboxId = req.handoffId || `local-${randomUUID()}`;
const sandboxId = req.taskId || `local-${randomUUID()}`;
const repoDir = this.repoDir(req.workspaceId, sandboxId);
mkdirSync(dirname(repoDir), { recursive: true });
await this.git.ensureCloned(req.repoRemote, repoDir);

View file

@ -10,7 +10,7 @@ export interface CreateSandboxRequest {
repoId: string;
repoRemote: string;
branchName: string;
handoffId: string;
taskId: string;
debug?: (message: string, context?: Record<string, unknown>) => void;
options?: Record<string, unknown>;
}

View file

@ -0,0 +1,503 @@
import { createHmac, createPrivateKey, createSign, timingSafeEqual } from "node:crypto";
export class GitHubAppError extends Error {
readonly status: number;
constructor(message: string, status = 500) {
super(message);
this.name = "GitHubAppError";
this.status = status;
}
}
export interface GitHubOAuthSession {
accessToken: string;
scopes: string[];
}
export interface GitHubViewerIdentity {
id: string;
login: string;
name: string;
email: string | null;
}
export interface GitHubOrgIdentity {
id: string;
login: string;
name: string | null;
}
export interface GitHubInstallationRecord {
id: number;
accountLogin: string;
}
export interface GitHubRepositoryRecord {
fullName: string;
cloneUrl: string;
private: boolean;
}
interface GitHubTokenResponse {
access_token?: string;
scope?: string;
error?: string;
error_description?: string;
}
interface GitHubPageResponse<T> {
items: T[];
nextUrl: string | null;
}
export interface GitHubWebhookEvent {
action?: string;
installation?: { id: number; account?: { login?: string; type?: string; id?: number } | null };
repositories_added?: Array<{ id: number; full_name: string; private: boolean }>;
repositories_removed?: Array<{ id: number; full_name: string }>;
repository?: { id: number; full_name: string; clone_url?: string; private?: boolean; owner?: { login?: string } };
pull_request?: { number: number; title?: string; state?: string; head?: { ref?: string }; base?: { ref?: string } };
sender?: { login?: string; id?: number };
[key: string]: unknown;
}
export interface GitHubAppClientOptions {
apiBaseUrl?: string;
authBaseUrl?: string;
clientId?: string;
clientSecret?: string;
redirectUri?: string;
appId?: string;
appPrivateKey?: string;
webhookSecret?: string;
}
export class GitHubAppClient {
private readonly apiBaseUrl: string;
private readonly authBaseUrl: string;
private readonly clientId?: string;
private readonly clientSecret?: string;
private readonly redirectUri?: string;
private readonly appId?: string;
private readonly appPrivateKey?: string;
private readonly webhookSecret?: string;
constructor(options: GitHubAppClientOptions = {}) {
this.apiBaseUrl = (options.apiBaseUrl ?? "https://api.github.com").replace(/\/$/, "");
this.authBaseUrl = (options.authBaseUrl ?? "https://github.com").replace(/\/$/, "");
this.clientId = options.clientId ?? process.env.GITHUB_CLIENT_ID;
this.clientSecret = options.clientSecret ?? process.env.GITHUB_CLIENT_SECRET;
this.redirectUri = options.redirectUri ?? process.env.GITHUB_REDIRECT_URI;
this.appId = options.appId ?? process.env.GITHUB_APP_ID;
this.appPrivateKey = options.appPrivateKey ?? process.env.GITHUB_APP_PRIVATE_KEY;
this.webhookSecret = options.webhookSecret ?? process.env.GITHUB_WEBHOOK_SECRET;
}
isOauthConfigured(): boolean {
return Boolean(this.clientId && this.clientSecret && this.redirectUri);
}
isAppConfigured(): boolean {
return Boolean(this.appId && this.appPrivateKey);
}
isWebhookConfigured(): boolean {
return Boolean(this.webhookSecret);
}
verifyWebhookEvent(payload: string, signatureHeader: string | null, eventHeader: string | null): { event: string; body: GitHubWebhookEvent } {
if (!this.webhookSecret) {
throw new GitHubAppError("GitHub webhook secret is not configured", 500);
}
if (!signatureHeader) {
throw new GitHubAppError("Missing GitHub signature header", 400);
}
if (!eventHeader) {
throw new GitHubAppError("Missing GitHub event header", 400);
}
const expectedSignature = signatureHeader.startsWith("sha256=") ? signatureHeader.slice(7) : null;
if (!expectedSignature) {
throw new GitHubAppError("Malformed GitHub signature header", 400);
}
const computed = createHmac("sha256", this.webhookSecret).update(payload).digest("hex");
const computedBuffer = Buffer.from(computed, "utf8");
const expectedBuffer = Buffer.from(expectedSignature, "utf8");
if (computedBuffer.length !== expectedBuffer.length || !timingSafeEqual(computedBuffer, expectedBuffer)) {
throw new GitHubAppError("GitHub webhook signature verification failed", 400);
}
return {
event: eventHeader,
body: JSON.parse(payload) as GitHubWebhookEvent,
};
}
buildAuthorizeUrl(state: string): string {
if (!this.clientId || !this.redirectUri) {
throw new GitHubAppError("GitHub OAuth is not configured", 500);
}
const url = new URL(`${this.authBaseUrl}/login/oauth/authorize`);
url.searchParams.set("client_id", this.clientId);
url.searchParams.set("redirect_uri", this.redirectUri);
url.searchParams.set("scope", "read:user user:email read:org");
url.searchParams.set("state", state);
return url.toString();
}
async exchangeCode(code: string): Promise<GitHubOAuthSession> {
if (!this.clientId || !this.clientSecret || !this.redirectUri) {
throw new GitHubAppError("GitHub OAuth is not configured", 500);
}
const response = await fetch(`${this.authBaseUrl}/login/oauth/access_token`, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
client_id: this.clientId,
client_secret: this.clientSecret,
code,
redirect_uri: this.redirectUri,
}),
});
const responseText = await response.text();
let payload: GitHubTokenResponse;
try {
payload = JSON.parse(responseText) as GitHubTokenResponse;
} catch {
// GitHub may return URL-encoded responses despite Accept: application/json
const params = new URLSearchParams(responseText);
if (params.has("access_token")) {
payload = {
access_token: params.get("access_token")!,
scope: params.get("scope") ?? "",
};
} else {
throw new GitHubAppError(
params.get("error_description") ?? params.get("error") ?? `GitHub token exchange failed: ${responseText.slice(0, 200)}`,
response.status || 502,
);
}
}
if (!response.ok || !payload.access_token) {
throw new GitHubAppError(
payload.error_description ?? payload.error ?? `GitHub token exchange failed with ${response.status}`,
response.status,
);
}
return {
accessToken: payload.access_token,
scopes: payload.scope
?.split(",")
.map((value) => value.trim())
.filter((value) => value.length > 0) ?? [],
};
}
async getViewer(accessToken: string): Promise<GitHubViewerIdentity> {
const user = await this.requestJson<{
id: number;
login: string;
name?: string | null;
email?: string | null;
}>("/user", accessToken);
let email = user.email ?? null;
if (!email) {
try {
const emails = await this.requestJson<Array<{ email: string; primary?: boolean; verified?: boolean }>>(
"/user/emails",
accessToken,
);
const primary = emails.find((candidate) => candidate.primary && candidate.verified) ?? emails[0] ?? null;
email = primary?.email ?? null;
} catch (error) {
if (!(error instanceof GitHubAppError) || error.status !== 404) {
throw error;
}
}
}
return {
id: String(user.id),
login: user.login,
name: user.name?.trim() || user.login,
email,
};
}
async listOrganizations(accessToken: string): Promise<GitHubOrgIdentity[]> {
const organizations = await this.paginate<{ id: number; login: string; description?: string | null }>(
"/user/orgs?per_page=100",
accessToken,
);
return organizations.map((organization) => ({
id: String(organization.id),
login: organization.login,
name: organization.description?.trim() || organization.login,
}));
}
async listInstallations(accessToken: string): Promise<GitHubInstallationRecord[]> {
if (!this.isAppConfigured()) {
return [];
}
try {
const payload = await this.requestJson<{
installations?: Array<{ id: number; account?: { login?: string } | null }>;
}>("/user/installations", accessToken);
return (payload.installations ?? [])
.map((installation) => ({
id: installation.id,
accountLogin: installation.account?.login?.trim() ?? "",
}))
.filter((installation) => installation.accountLogin.length > 0);
} catch (error) {
if (!(error instanceof GitHubAppError) || (error.status !== 401 && error.status !== 403)) {
throw error;
}
}
const installations = await this.paginateApp<{ id: number; account?: { login?: string } | null }>(
"/app/installations?per_page=100",
);
return installations
.map((installation) => ({
id: installation.id,
accountLogin: installation.account?.login?.trim() ?? "",
}))
.filter((installation) => installation.accountLogin.length > 0);
}
async listUserRepositories(accessToken: string): Promise<GitHubRepositoryRecord[]> {
const repositories = await this.paginate<{
full_name: string;
clone_url: string;
private: boolean;
}>("/user/repos?per_page=100&affiliation=owner&sort=updated", accessToken);
return repositories.map((repository) => ({
fullName: repository.full_name,
cloneUrl: repository.clone_url,
private: repository.private,
}));
}
async listInstallationRepositories(installationId: number): Promise<GitHubRepositoryRecord[]> {
const accessToken = await this.createInstallationAccessToken(installationId);
const repositories = await this.paginate<{
full_name: string;
clone_url: string;
private: boolean;
}>("/installation/repositories?per_page=100", accessToken);
return repositories.map((repository) => ({
fullName: repository.full_name,
cloneUrl: repository.clone_url,
private: repository.private,
}));
}
async buildInstallationUrl(organizationLogin: string, state: string): Promise<string> {
if (!this.isAppConfigured()) {
throw new GitHubAppError("GitHub App is not configured", 500);
}
const app = await this.requestAppJson<{ slug?: string }>("/app");
if (!app.slug) {
throw new GitHubAppError("GitHub App slug is unavailable", 500);
}
const url = new URL(`${this.authBaseUrl}/apps/${app.slug}/installations/new`);
url.searchParams.set("state", state);
void organizationLogin;
return url.toString();
}
private async createInstallationAccessToken(installationId: number): Promise<string> {
if (!this.appId || !this.appPrivateKey) {
throw new GitHubAppError("GitHub App is not configured", 500);
}
const response = await fetch(`${this.apiBaseUrl}/app/installations/${installationId}/access_tokens`, {
method: "POST",
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${this.createAppJwt()}`,
"X-GitHub-Api-Version": "2022-11-28",
},
});
const payload = (await response.json()) as { token?: string; message?: string };
if (!response.ok || !payload.token) {
throw new GitHubAppError(payload.message ?? "Unable to mint GitHub installation token", response.status);
}
return payload.token;
}
private createAppJwt(): string {
if (!this.appId || !this.appPrivateKey) {
throw new GitHubAppError("GitHub App is not configured", 500);
}
const header = base64UrlEncode(JSON.stringify({ alg: "RS256", typ: "JWT" }));
const now = Math.floor(Date.now() / 1000);
const payload = base64UrlEncode(
JSON.stringify({
iat: now - 60,
exp: now + 540,
iss: this.appId,
}),
);
const signer = createSign("RSA-SHA256");
signer.update(`${header}.${payload}`);
signer.end();
const key = createPrivateKey(this.appPrivateKey);
const signature = signer.sign(key);
return `${header}.${payload}.${base64UrlEncode(signature)}`;
}
private async requestAppJson<T>(path: string): Promise<T> {
const response = await fetch(`${this.apiBaseUrl}${path}`, {
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${this.createAppJwt()}`,
"X-GitHub-Api-Version": "2022-11-28",
},
});
const payload = (await response.json()) as T | { message?: string };
if (!response.ok) {
throw new GitHubAppError(
typeof payload === "object" && payload && "message" in payload ? payload.message ?? "GitHub request failed" : "GitHub request failed",
response.status,
);
}
return payload as T;
}
private async paginateApp<T>(path: string): Promise<T[]> {
let nextUrl = `${this.apiBaseUrl}${path.startsWith("/") ? path : `/${path}`}`;
const items: T[] = [];
while (nextUrl) {
const page = await this.requestAppPage<T>(nextUrl);
items.push(...page.items);
nextUrl = page.nextUrl ?? "";
}
return items;
}
private async requestJson<T>(path: string, accessToken: string): Promise<T> {
const response = await fetch(`${this.apiBaseUrl}${path}`, {
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${accessToken}`,
"X-GitHub-Api-Version": "2022-11-28",
},
});
const payload = (await response.json()) as T | { message?: string };
if (!response.ok) {
throw new GitHubAppError(
typeof payload === "object" && payload && "message" in payload ? payload.message ?? "GitHub request failed" : "GitHub request failed",
response.status,
);
}
return payload as T;
}
private async paginate<T>(path: string, accessToken: string): Promise<T[]> {
let nextUrl = `${this.apiBaseUrl}${path.startsWith("/") ? path : `/${path}`}`;
const items: T[] = [];
while (nextUrl) {
const page = await this.requestPage<T>(nextUrl, accessToken);
items.push(...page.items);
nextUrl = page.nextUrl ?? "";
}
return items;
}
private async requestPage<T>(url: string, accessToken: string): Promise<GitHubPageResponse<T>> {
const response = await fetch(url, {
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${accessToken}`,
"X-GitHub-Api-Version": "2022-11-28",
},
});
const payload = (await response.json()) as T[] | { repositories?: T[]; message?: string };
if (!response.ok) {
throw new GitHubAppError(
typeof payload === "object" && payload && "message" in payload ? payload.message ?? "GitHub request failed" : "GitHub request failed",
response.status,
);
}
const items = Array.isArray(payload) ? payload : payload.repositories ?? [];
return {
items,
nextUrl: parseNextLink(response.headers.get("link")),
};
}
private async requestAppPage<T>(url: string): Promise<GitHubPageResponse<T>> {
const response = await fetch(url, {
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${this.createAppJwt()}`,
"X-GitHub-Api-Version": "2022-11-28",
},
});
const payload = (await response.json()) as T[] | { installations?: T[]; message?: string };
if (!response.ok) {
throw new GitHubAppError(
typeof payload === "object" && payload && "message" in payload ? payload.message ?? "GitHub request failed" : "GitHub request failed",
response.status,
);
}
const items = Array.isArray(payload) ? payload : payload.installations ?? [];
return {
items,
nextUrl: parseNextLink(response.headers.get("link")),
};
}
}
function parseNextLink(linkHeader: string | null): string | null {
if (!linkHeader) {
return null;
}
for (const part of linkHeader.split(",")) {
const [urlPart, relPart] = part.split(";").map((value) => value.trim());
if (!urlPart || !relPart || !relPart.includes('rel="next"')) {
continue;
}
return urlPart.replace(/^<|>$/g, "");
}
return null;
}
function base64UrlEncode(value: string | Buffer): string {
const source = typeof value === "string" ? Buffer.from(value, "utf8") : value;
return source
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "");
}

View file

@ -0,0 +1,68 @@
import { GitHubAppClient, type GitHubInstallationRecord, type GitHubOAuthSession, type GitHubOrgIdentity, type GitHubRepositoryRecord, type GitHubViewerIdentity, type GitHubWebhookEvent } from "./app-github.js";
import { StripeAppClient, type StripeCheckoutCompletion, type StripeCheckoutSession, type StripePortalSession, type StripeSubscriptionSnapshot, type StripeWebhookEvent } from "./app-stripe.js";
import type { FactoryBillingPlanId } from "@sandbox-agent/factory-shared";
export type AppShellGithubClient = Pick<
GitHubAppClient,
| "isAppConfigured"
| "isWebhookConfigured"
| "buildAuthorizeUrl"
| "exchangeCode"
| "getViewer"
| "listOrganizations"
| "listInstallations"
| "listUserRepositories"
| "listInstallationRepositories"
| "buildInstallationUrl"
| "verifyWebhookEvent"
>;
export type AppShellStripeClient = Pick<
StripeAppClient,
| "isConfigured"
| "createCustomer"
| "createCheckoutSession"
| "retrieveCheckoutCompletion"
| "retrieveSubscription"
| "createPortalSession"
| "updateSubscriptionCancellation"
| "verifyWebhookEvent"
| "planIdForPriceId"
>;
export interface AppShellServices {
appUrl: string;
github: AppShellGithubClient;
stripe: AppShellStripeClient;
}
export interface CreateAppShellServicesOptions {
appUrl?: string;
github?: AppShellGithubClient;
stripe?: AppShellStripeClient;
}
export function createDefaultAppShellServices(
options: CreateAppShellServicesOptions = {},
): AppShellServices {
return {
appUrl: (options.appUrl ?? process.env.APP_URL ?? "http://localhost:4173").replace(/\/$/, ""),
github: options.github ?? new GitHubAppClient(),
stripe: options.stripe ?? new StripeAppClient(),
};
}
export type {
GitHubInstallationRecord,
GitHubOAuthSession,
GitHubOrgIdentity,
GitHubRepositoryRecord,
GitHubViewerIdentity,
GitHubWebhookEvent,
StripeCheckoutCompletion,
StripeCheckoutSession,
StripePortalSession,
StripeSubscriptionSnapshot,
StripeWebhookEvent,
FactoryBillingPlanId,
};

View file

@ -1,498 +0,0 @@
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { randomUUID } from "node:crypto";
import type {
FactoryAppSnapshot,
FactoryBillingPlanId,
FactoryOrganization,
FactoryUser,
UpdateFactoryOrganizationProfileInput,
} from "@sandbox-agent/factory-shared";
interface PersistedFactorySession {
sessionId: string;
currentUserId: string | null;
activeOrganizationId: string | null;
}
interface PersistedFactoryAppState {
users: FactoryUser[];
organizations: FactoryOrganization[];
sessions: PersistedFactorySession[];
}
function nowIso(daysFromNow = 0): string {
const value = new Date();
value.setDate(value.getDate() + daysFromNow);
return value.toISOString();
}
function planSeatsIncluded(planId: FactoryBillingPlanId): number {
switch (planId) {
case "free":
return 1;
case "team":
return 5;
case "enterprise":
return 25;
}
}
function buildDefaultState(): PersistedFactoryAppState {
return {
users: [
{
id: "user-nathan",
name: "Nathan",
email: "nathan@acme.dev",
githubLogin: "nathan",
roleLabel: "Founder",
eligibleOrganizationIds: ["personal-nathan", "acme", "rivet"],
},
],
organizations: [
{
id: "personal-nathan",
workspaceId: "personal-nathan",
kind: "personal",
settings: {
displayName: "Nathan",
slug: "nathan",
primaryDomain: "personal",
seatAccrualMode: "first_prompt",
defaultModel: "claude-sonnet-4",
autoImportRepos: true,
},
github: {
connectedAccount: "nathan",
installationStatus: "connected",
importedRepoCount: 1,
lastSyncLabel: "Synced just now",
},
billing: {
planId: "free",
status: "active",
seatsIncluded: 1,
trialEndsAt: null,
renewalAt: null,
stripeCustomerId: "cus_remote_personal_nathan",
paymentMethodLabel: "No card required",
invoices: [],
},
members: [
{ id: "member-nathan", name: "Nathan", email: "nathan@acme.dev", role: "owner", state: "active" },
],
seatAssignments: ["nathan@acme.dev"],
repoImportStatus: "ready",
repoCatalog: ["nathan/personal-site"],
},
{
id: "acme",
workspaceId: "acme",
kind: "organization",
settings: {
displayName: "Acme",
slug: "acme",
primaryDomain: "acme.dev",
seatAccrualMode: "first_prompt",
defaultModel: "claude-sonnet-4",
autoImportRepos: true,
},
github: {
connectedAccount: "acme",
installationStatus: "connected",
importedRepoCount: 3,
lastSyncLabel: "Waiting for first import",
},
billing: {
planId: "team",
status: "active",
seatsIncluded: 5,
trialEndsAt: null,
renewalAt: nowIso(18),
stripeCustomerId: "cus_remote_acme_team",
paymentMethodLabel: "Visa ending in 4242",
invoices: [
{ id: "inv-acme-001", label: "March 2026", issuedAt: "2026-03-01", amountUsd: 240, status: "paid" },
],
},
members: [
{ id: "member-acme-nathan", name: "Nathan", email: "nathan@acme.dev", role: "owner", state: "active" },
{ id: "member-acme-maya", name: "Maya", email: "maya@acme.dev", role: "admin", state: "active" },
{ id: "member-acme-priya", name: "Priya", email: "priya@acme.dev", role: "member", state: "active" },
],
seatAssignments: ["nathan@acme.dev", "maya@acme.dev"],
repoImportStatus: "not_started",
repoCatalog: ["acme/backend", "acme/frontend", "acme/infra"],
},
{
id: "rivet",
workspaceId: "rivet",
kind: "organization",
settings: {
displayName: "Rivet",
slug: "rivet",
primaryDomain: "rivet.dev",
seatAccrualMode: "first_prompt",
defaultModel: "o3",
autoImportRepos: true,
},
github: {
connectedAccount: "rivet-dev",
installationStatus: "reconnect_required",
importedRepoCount: 4,
lastSyncLabel: "Sync stalled 2 hours ago",
},
billing: {
planId: "enterprise",
status: "trialing",
seatsIncluded: 25,
trialEndsAt: nowIso(12),
renewalAt: nowIso(12),
stripeCustomerId: "cus_remote_rivet_enterprise",
paymentMethodLabel: "ACH verified",
invoices: [
{ id: "inv-rivet-001", label: "Enterprise pilot", issuedAt: "2026-03-04", amountUsd: 0, status: "paid" },
],
},
members: [
{ id: "member-rivet-jamie", name: "Jamie", email: "jamie@rivet.dev", role: "owner", state: "active" },
{ id: "member-rivet-nathan", name: "Nathan", email: "nathan@acme.dev", role: "member", state: "active" },
],
seatAssignments: ["jamie@rivet.dev"],
repoImportStatus: "not_started",
repoCatalog: ["rivet/dashboard", "rivet/agents", "rivet/billing", "rivet/infrastructure"],
},
],
sessions: [],
};
}
function githubRemote(repo: string): string {
return `https://github.com/${repo}.git`;
}
export interface FactoryAppStoreOptions {
filePath?: string;
onOrganizationReposReady?: (organization: FactoryOrganization) => Promise<void>;
}
export class FactoryAppStore {
private readonly filePath: string;
private readonly onOrganizationReposReady?: (organization: FactoryOrganization) => Promise<void>;
private state: PersistedFactoryAppState;
private readonly importTimers = new Map<string, ReturnType<typeof setTimeout>>();
constructor(options: FactoryAppStoreOptions = {}) {
this.filePath =
options.filePath ??
join(process.cwd(), ".sandbox-agent-factory", "backend", "app-state.json");
this.onOrganizationReposReady = options.onOrganizationReposReady;
this.state = this.loadState();
}
ensureSession(sessionId?: string | null): string {
if (sessionId) {
const existing = this.state.sessions.find((candidate) => candidate.sessionId === sessionId);
if (existing) {
return existing.sessionId;
}
}
const nextSessionId = randomUUID();
this.state.sessions.push({
sessionId: nextSessionId,
currentUserId: null,
activeOrganizationId: null,
});
this.persist();
return nextSessionId;
}
getSnapshot(sessionId: string): FactoryAppSnapshot {
const session = this.requireSession(sessionId);
return {
auth: {
status: session.currentUserId ? "signed_in" : "signed_out",
currentUserId: session.currentUserId,
},
activeOrganizationId: session.activeOrganizationId,
users: this.state.users,
organizations: this.state.organizations,
};
}
signInWithGithub(sessionId: string, userId = "user-nathan"): FactoryAppSnapshot {
const user = this.state.users.find((candidate) => candidate.id === userId);
if (!user) {
throw new Error(`Unknown user: ${userId}`);
}
this.updateSession(sessionId, (session) => ({
...session,
currentUserId: userId,
activeOrganizationId: user.eligibleOrganizationIds.length === 1 ? user.eligibleOrganizationIds[0] ?? null : null,
}));
return this.getSnapshot(sessionId);
}
signOut(sessionId: string): FactoryAppSnapshot {
this.updateSession(sessionId, (session) => ({
...session,
currentUserId: null,
activeOrganizationId: null,
}));
return this.getSnapshot(sessionId);
}
async selectOrganization(sessionId: string, organizationId: string): Promise<FactoryAppSnapshot> {
const session = this.requireSession(sessionId);
const user = this.requireSignedInUser(session);
if (!user.eligibleOrganizationIds.includes(organizationId)) {
throw new Error(`Organization ${organizationId} is not available to ${user.id}`);
}
const organization = this.requireOrganization(organizationId);
this.updateSession(sessionId, (current) => ({
...current,
activeOrganizationId: organizationId,
}));
if (organization.repoImportStatus !== "ready") {
await this.triggerRepoImport(organizationId);
} else if (this.onOrganizationReposReady) {
await this.onOrganizationReposReady(this.requireOrganization(organizationId));
}
return this.getSnapshot(sessionId);
}
updateOrganizationProfile(input: UpdateFactoryOrganizationProfileInput): FactoryAppSnapshot {
this.updateOrganization(input.organizationId, (organization) => ({
...organization,
settings: {
...organization.settings,
displayName: input.displayName.trim() || organization.settings.displayName,
slug: input.slug.trim() || organization.settings.slug,
primaryDomain: input.primaryDomain.trim() || organization.settings.primaryDomain,
},
}));
return this.snapshotForOrganization(input.organizationId);
}
async triggerRepoImport(organizationId: string): Promise<FactoryAppSnapshot> {
const organization = this.requireOrganization(organizationId);
const existingTimer = this.importTimers.get(organizationId);
if (existingTimer) {
clearTimeout(existingTimer);
}
this.updateOrganization(organizationId, (current) => ({
...current,
repoImportStatus: "importing",
github: {
...current.github,
lastSyncLabel: "Importing repository catalog...",
},
}));
const timer = setTimeout(async () => {
this.updateOrganization(organizationId, (current) => ({
...current,
repoImportStatus: "ready",
github: {
...current.github,
importedRepoCount: current.repoCatalog.length,
installationStatus: "connected",
lastSyncLabel: "Synced just now",
},
}));
if (this.onOrganizationReposReady) {
await this.onOrganizationReposReady(this.requireOrganization(organizationId));
}
this.importTimers.delete(organizationId);
}, organization.kind === "personal" ? 100 : 1_250);
this.importTimers.set(organizationId, timer);
return this.snapshotForOrganization(organizationId);
}
completeHostedCheckout(organizationId: string, planId: FactoryBillingPlanId): FactoryAppSnapshot {
this.updateOrganization(organizationId, (organization) => ({
...organization,
billing: {
...organization.billing,
planId,
status: "active",
seatsIncluded: planSeatsIncluded(planId),
trialEndsAt: null,
renewalAt: nowIso(30),
paymentMethodLabel: planId === "enterprise" ? "ACH verified" : "Visa ending in 4242",
invoices: [
{
id: `inv-${organizationId}-${Date.now()}`,
label: `${organization.settings.displayName} ${planId} upgrade`,
issuedAt: new Date().toISOString().slice(0, 10),
amountUsd: planId === "team" ? 240 : planId === "enterprise" ? 1200 : 0,
status: "paid",
},
...organization.billing.invoices,
],
},
}));
return this.snapshotForOrganization(organizationId);
}
cancelScheduledRenewal(organizationId: string): FactoryAppSnapshot {
this.updateOrganization(organizationId, (organization) => ({
...organization,
billing: {
...organization.billing,
status: "scheduled_cancel",
},
}));
return this.snapshotForOrganization(organizationId);
}
resumeSubscription(organizationId: string): FactoryAppSnapshot {
this.updateOrganization(organizationId, (organization) => ({
...organization,
billing: {
...organization.billing,
status: "active",
},
}));
return this.snapshotForOrganization(organizationId);
}
reconnectGithub(organizationId: string): FactoryAppSnapshot {
this.updateOrganization(organizationId, (organization) => ({
...organization,
github: {
...organization.github,
installationStatus: "connected",
lastSyncLabel: "Reconnected just now",
},
}));
return this.snapshotForOrganization(organizationId);
}
recordSeatUsage(workspaceId: string, userEmail: string): void {
const organization = this.state.organizations.find((candidate) => candidate.workspaceId === workspaceId);
if (!organization || organization.seatAssignments.includes(userEmail)) {
return;
}
this.updateOrganization(organization.id, (current) => ({
...current,
seatAssignments: [...current.seatAssignments, userEmail],
}));
}
organizationRepos(organizationId: string): string[] {
return this.requireOrganization(organizationId).repoCatalog.map(githubRemote);
}
findUserEmailForWorkspace(workspaceId: string, sessionId: string): string | null {
const session = this.requireSession(sessionId);
const user = session.currentUserId ? this.state.users.find((candidate) => candidate.id === session.currentUserId) : null;
const organization = this.state.organizations.find((candidate) => candidate.workspaceId === workspaceId);
if (!user || !organization) {
return null;
}
return organization.members.some((member) => member.email === user.email) ? user.email : null;
}
private loadState(): PersistedFactoryAppState {
try {
const raw = readFileSync(this.filePath, "utf8");
const parsed = JSON.parse(raw) as PersistedFactoryAppState;
if (!parsed || typeof parsed !== "object") {
throw new Error("Invalid app state");
}
return parsed;
} catch {
const initial = buildDefaultState();
this.persistState(initial);
return initial;
}
}
private snapshotForOrganization(organizationId: string): FactoryAppSnapshot {
const session = this.state.sessions.find((candidate) => candidate.activeOrganizationId === organizationId);
if (!session) {
return {
auth: { status: "signed_out", currentUserId: null },
activeOrganizationId: null,
users: this.state.users,
organizations: this.state.organizations,
};
}
return this.getSnapshot(session.sessionId);
}
private updateSession(
sessionId: string,
updater: (session: PersistedFactorySession) => PersistedFactorySession,
): void {
const session = this.requireSession(sessionId);
this.state = {
...this.state,
sessions: this.state.sessions.map((candidate) => (candidate.sessionId === sessionId ? updater(session) : candidate)),
};
this.persist();
}
private updateOrganization(
organizationId: string,
updater: (organization: FactoryOrganization) => FactoryOrganization,
): void {
this.requireOrganization(organizationId);
this.state = {
...this.state,
organizations: this.state.organizations.map((candidate) =>
candidate.id === organizationId ? updater(candidate) : candidate,
),
};
this.persist();
}
private requireSession(sessionId: string): PersistedFactorySession {
const session = this.state.sessions.find((candidate) => candidate.sessionId === sessionId);
if (!session) {
throw new Error(`Unknown app session: ${sessionId}`);
}
return session;
}
private requireOrganization(organizationId: string): FactoryOrganization {
const organization = this.state.organizations.find((candidate) => candidate.id === organizationId);
if (!organization) {
throw new Error(`Unknown organization: ${organizationId}`);
}
return organization;
}
private requireSignedInUser(session: PersistedFactorySession): FactoryUser {
if (!session.currentUserId) {
throw new Error("User must be signed in");
}
const user = this.state.users.find((candidate) => candidate.id === session.currentUserId);
if (!user) {
throw new Error(`Unknown user: ${session.currentUserId}`);
}
return user;
}
private persist(): void {
this.persistState(this.state);
}
private persistState(state: PersistedFactoryAppState): void {
mkdirSync(dirname(this.filePath), { recursive: true });
writeFileSync(this.filePath, JSON.stringify(state, null, 2));
}
}

View file

@ -0,0 +1,308 @@
import { createHmac, timingSafeEqual } from "node:crypto";
import type { FactoryBillingPlanId } from "@sandbox-agent/factory-shared";
export class StripeAppError extends Error {
readonly status: number;
constructor(message: string, status = 500) {
super(message);
this.name = "StripeAppError";
this.status = status;
}
}
export interface StripeCheckoutSession {
id: string;
url: string;
}
export interface StripePortalSession {
url: string;
}
export interface StripeSubscriptionSnapshot {
id: string;
customerId: string;
priceId: string | null;
status: string;
cancelAtPeriodEnd: boolean;
currentPeriodEnd: number | null;
trialEnd: number | null;
defaultPaymentMethodLabel: string;
}
export interface StripeCheckoutCompletion {
customerId: string | null;
subscriptionId: string | null;
planId: FactoryBillingPlanId | null;
paymentMethodLabel: string;
}
export interface StripeWebhookEvent<T = unknown> {
id: string;
type: string;
data: {
object: T;
};
}
export interface StripeAppClientOptions {
apiBaseUrl?: string;
secretKey?: string;
webhookSecret?: string;
teamPriceId?: string;
}
export class StripeAppClient {
private readonly apiBaseUrl: string;
private readonly secretKey?: string;
private readonly webhookSecret?: string;
private readonly teamPriceId?: string;
constructor(options: StripeAppClientOptions = {}) {
this.apiBaseUrl = (options.apiBaseUrl ?? "https://api.stripe.com").replace(/\/$/, "");
this.secretKey = options.secretKey ?? process.env.STRIPE_SECRET_KEY;
this.webhookSecret = options.webhookSecret ?? process.env.STRIPE_WEBHOOK_SECRET;
this.teamPriceId = options.teamPriceId ?? process.env.STRIPE_PRICE_TEAM;
}
isConfigured(): boolean {
return Boolean(this.secretKey);
}
createCheckoutSession(input: {
organizationId: string;
customerId: string;
customerEmail: string | null;
planId: Exclude<FactoryBillingPlanId, "free">;
successUrl: string;
cancelUrl: string;
}): Promise<StripeCheckoutSession> {
const priceId = this.priceIdForPlan(input.planId);
return this.formRequest<StripeCheckoutSession>("/v1/checkout/sessions", {
mode: "subscription",
success_url: input.successUrl,
cancel_url: input.cancelUrl,
customer: input.customerId,
"line_items[0][price]": priceId,
"line_items[0][quantity]": "1",
"metadata[organizationId]": input.organizationId,
"metadata[planId]": input.planId,
"subscription_data[metadata][organizationId]": input.organizationId,
"subscription_data[metadata][planId]": input.planId,
});
}
createPortalSession(input: { customerId: string; returnUrl: string }): Promise<StripePortalSession> {
return this.formRequest<StripePortalSession>("/v1/billing_portal/sessions", {
customer: input.customerId,
return_url: input.returnUrl,
});
}
createCustomer(input: {
organizationId: string;
displayName: string;
email: string | null;
}): Promise<{ id: string }> {
return this.formRequest<{ id: string }>("/v1/customers", {
name: input.displayName,
...(input.email ? { email: input.email } : {}),
"metadata[organizationId]": input.organizationId,
});
}
async updateSubscriptionCancellation(
subscriptionId: string,
cancelAtPeriodEnd: boolean,
): Promise<StripeSubscriptionSnapshot> {
const payload = await this.formRequest<Record<string, unknown>>(`/v1/subscriptions/${subscriptionId}`, {
cancel_at_period_end: cancelAtPeriodEnd ? "true" : "false",
});
return stripeSubscriptionSnapshot(payload);
}
async retrieveCheckoutCompletion(sessionId: string): Promise<StripeCheckoutCompletion> {
const payload = await this.requestJson<Record<string, unknown>>(
`/v1/checkout/sessions/${sessionId}?expand[]=subscription.default_payment_method`,
);
const subscription =
typeof payload.subscription === "object" && payload.subscription ? (payload.subscription as Record<string, unknown>) : null;
const subscriptionId =
typeof payload.subscription === "string"
? payload.subscription
: subscription && typeof subscription.id === "string"
? subscription.id
: null;
const priceId = firstStripePriceId(subscription);
return {
customerId: typeof payload.customer === "string" ? payload.customer : null,
subscriptionId,
planId: priceId ? this.planIdForPriceId(priceId) : planIdFromMetadata(payload.metadata),
paymentMethodLabel: subscription ? paymentMethodLabelFromObject(subscription.default_payment_method) : "Card on file",
};
}
async retrieveSubscription(subscriptionId: string): Promise<StripeSubscriptionSnapshot> {
const payload = await this.requestJson<Record<string, unknown>>(
`/v1/subscriptions/${subscriptionId}?expand[]=default_payment_method`,
);
return stripeSubscriptionSnapshot(payload);
}
verifyWebhookEvent(payload: string, signatureHeader: string | null): StripeWebhookEvent {
if (!this.webhookSecret) {
throw new StripeAppError("Stripe webhook secret is not configured", 500);
}
if (!signatureHeader) {
throw new StripeAppError("Missing Stripe signature header", 400);
}
const parts = Object.fromEntries(
signatureHeader
.split(",")
.map((entry) => entry.split("="))
.filter((entry): entry is [string, string] => entry.length === 2),
);
const timestamp = parts.t;
const signature = parts.v1;
if (!timestamp || !signature) {
throw new StripeAppError("Malformed Stripe signature header", 400);
}
const expected = createHmac("sha256", this.webhookSecret)
.update(`${timestamp}.${payload}`)
.digest("hex");
const expectedBuffer = Buffer.from(expected, "utf8");
const actualBuffer = Buffer.from(signature, "utf8");
if (expectedBuffer.length !== actualBuffer.length || !timingSafeEqual(expectedBuffer, actualBuffer)) {
throw new StripeAppError("Stripe signature verification failed", 400);
}
return JSON.parse(payload) as StripeWebhookEvent;
}
planIdForPriceId(priceId: string): FactoryBillingPlanId | null {
if (priceId === this.teamPriceId) {
return "team";
}
return null;
}
priceIdForPlan(planId: Exclude<FactoryBillingPlanId, "free">): string {
const priceId = this.teamPriceId;
if (!priceId) {
throw new StripeAppError(`Stripe price ID is not configured for ${planId}`, 500);
}
return priceId;
}
private async requestJson<T>(path: string): Promise<T> {
if (!this.secretKey) {
throw new StripeAppError("Stripe is not configured", 500);
}
const response = await fetch(`${this.apiBaseUrl}${path}`, {
headers: {
Authorization: `Bearer ${this.secretKey}`,
},
});
const payload = (await response.json()) as T | { error?: { message?: string } };
if (!response.ok) {
throw new StripeAppError(
typeof payload === "object" && payload && "error" in payload
? payload.error?.message ?? "Stripe request failed"
: "Stripe request failed",
response.status,
);
}
return payload as T;
}
private async formRequest<T>(path: string, body: Record<string, string>): Promise<T> {
if (!this.secretKey) {
throw new StripeAppError("Stripe is not configured", 500);
}
const form = new URLSearchParams();
for (const [key, value] of Object.entries(body)) {
form.set(key, value);
}
const response = await fetch(`${this.apiBaseUrl}${path}`, {
method: "POST",
headers: {
Authorization: `Bearer ${this.secretKey}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: form,
});
const payload = (await response.json()) as T | { error?: { message?: string } };
if (!response.ok) {
throw new StripeAppError(
typeof payload === "object" && payload && "error" in payload
? payload.error?.message ?? "Stripe request failed"
: "Stripe request failed",
response.status,
);
}
return payload as T;
}
}
function planIdFromMetadata(metadata: unknown): FactoryBillingPlanId | null {
if (!metadata || typeof metadata !== "object") {
return null;
}
const planId = (metadata as Record<string, unknown>).planId;
return planId === "team" || planId === "free" ? planId : null;
}
function firstStripePriceId(subscription: Record<string, unknown> | null): string | null {
if (!subscription || typeof subscription.items !== "object" || !subscription.items) {
return null;
}
const data = (subscription.items as { data?: Array<Record<string, unknown>> }).data;
const first = data?.[0];
if (!first || typeof first.price !== "object" || !first.price) {
return null;
}
return typeof (first.price as Record<string, unknown>).id === "string"
? ((first.price as Record<string, unknown>).id as string)
: null;
}
function paymentMethodLabelFromObject(paymentMethod: unknown): string {
if (!paymentMethod || typeof paymentMethod !== "object") {
return "Card on file";
}
const card = (paymentMethod as Record<string, unknown>).card;
if (card && typeof card === "object") {
const brand = typeof (card as Record<string, unknown>).brand === "string" ? ((card as Record<string, unknown>).brand as string) : "Card";
const last4 = typeof (card as Record<string, unknown>).last4 === "string" ? ((card as Record<string, unknown>).last4 as string) : "file";
return `${capitalize(brand)} ending in ${last4}`;
}
return "Payment method on file";
}
function stripeSubscriptionSnapshot(payload: Record<string, unknown>): StripeSubscriptionSnapshot {
return {
id: typeof payload.id === "string" ? payload.id : "",
customerId: typeof payload.customer === "string" ? payload.customer : "",
priceId: firstStripePriceId(payload),
status: typeof payload.status === "string" ? payload.status : "active",
cancelAtPeriodEnd: payload.cancel_at_period_end === true,
currentPeriodEnd: typeof payload.current_period_end === "number" ? payload.current_period_end : null,
trialEnd: typeof payload.trial_end === "number" ? payload.trial_end : null,
defaultPaymentMethodLabel: paymentMethodLabelFromObject(payload.default_payment_method),
};
}
function capitalize(value: string): string {
return value.length > 0 ? `${value[0]!.toUpperCase()}${value.slice(1)}` : value;
}

View file

@ -3,7 +3,7 @@ export interface ResolveCreateFlowDecisionInput {
explicitTitle?: string;
explicitBranchName?: string;
localBranches: string[];
handoffBranches: string[];
taskBranches: string[];
}
export interface ResolveCreateFlowDecisionResult {
@ -20,7 +20,7 @@ function firstNonEmptyLine(input: string): string {
}
export function deriveFallbackTitle(task: string, explicitTitle?: string): string {
const source = (explicitTitle && explicitTitle.trim()) || firstNonEmptyLine(task) || "update handoff";
const source = (explicitTitle && explicitTitle.trim()) || firstNonEmptyLine(task) || "update task";
const explicitPrefixMatch = source.match(/^\s*(feat|fix|docs|refactor):\s+(.+)$/i);
if (explicitPrefixMatch) {
const explicitTypePrefix = explicitPrefixMatch[1]!.toLowerCase();
@ -34,7 +34,7 @@ export function deriveFallbackTitle(task: string, explicitTitle?: string): strin
.slice(0, 62)
.trim();
return `${explicitTypePrefix}: ${explicitSummary || "update handoff"}`;
return `${explicitTypePrefix}: ${explicitSummary || "update task"}`;
}
const lowered = source.toLowerCase();
@ -55,7 +55,7 @@ export function deriveFallbackTitle(task: string, explicitTitle?: string): strin
.filter((token) => token.length > 0)
.join(" ");
const summary = (cleaned || "update handoff").slice(0, 62).trim();
const summary = (cleaned || "update task").slice(0, 62).trim();
return `${typePrefix}: ${summary}`.trim();
}
@ -93,16 +93,16 @@ export function resolveCreateFlowDecision(
): ResolveCreateFlowDecisionResult {
const explicitBranch = input.explicitBranchName?.trim();
const title = deriveFallbackTitle(input.task, input.explicitTitle);
const generatedBase = sanitizeBranchName(title) || "handoff";
const generatedBase = sanitizeBranchName(title) || "task";
const branchBase = explicitBranch && explicitBranch.length > 0 ? explicitBranch : generatedBase;
const existingBranches = new Set(input.localBranches.map((value) => value.trim()).filter((value) => value.length > 0));
const existingHandoffBranches = new Set(
input.handoffBranches.map((value) => value.trim()).filter((value) => value.length > 0)
const existingTaskBranches = new Set(
input.taskBranches.map((value) => value.trim()).filter((value) => value.length > 0)
);
const conflicts = (name: string): boolean =>
existingBranches.has(name) || existingHandoffBranches.has(name);
existingBranches.has(name) || existingTaskBranches.has(name);
if (explicitBranch && conflicts(branchBase)) {
throw new Error(

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

View file

@ -1,26 +0,0 @@
{
"name": "@sandbox-agent/factory-cli",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"bin": {
"hf": "dist/index.js"
},
"scripts": {
"build": "tsup --config tsup.config.ts",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@iarna/toml": "^2.2.5",
"@opentui/core": "^0.1.77",
"@sandbox-agent/factory-client": "workspace:*",
"@sandbox-agent/factory-shared": "workspace:*",
"zod": "^4.1.5"
},
"devDependencies": {
"tsup": "^8.5.0"
}
}

View file

@ -1,446 +0,0 @@
import * as childProcess from "node:child_process";
import {
closeSync,
existsSync,
mkdirSync,
openSync,
readFileSync,
rmSync,
writeFileSync
} from "node:fs";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { checkBackendHealth } from "@sandbox-agent/factory-client";
import type { AppConfig } from "@sandbox-agent/factory-shared";
import { CLI_BUILD_ID } from "../build-id.js";
const HEALTH_TIMEOUT_MS = 1_500;
const START_TIMEOUT_MS = 30_000;
const STOP_TIMEOUT_MS = 5_000;
const POLL_INTERVAL_MS = 150;
function sleep(ms: number): Promise<void> {
return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
}
function sanitizeHost(host: string): string {
return host
.split("")
.map((ch) => (/[a-zA-Z0-9]/.test(ch) ? ch : "-"))
.join("");
}
function backendStateDir(): string {
const override = process.env.HF_BACKEND_STATE_DIR?.trim();
if (override) {
return override;
}
const xdgDataHome = process.env.XDG_DATA_HOME?.trim();
if (xdgDataHome) {
return join(xdgDataHome, "sandbox-agent-factory", "backend");
}
return join(homedir(), ".local", "share", "sandbox-agent-factory", "backend");
}
function backendPidPath(host: string, port: number): string {
return join(backendStateDir(), `backend-${sanitizeHost(host)}-${port}.pid`);
}
function backendVersionPath(host: string, port: number): string {
return join(backendStateDir(), `backend-${sanitizeHost(host)}-${port}.version`);
}
function backendLogPath(host: string, port: number): string {
return join(backendStateDir(), `backend-${sanitizeHost(host)}-${port}.log`);
}
function readText(path: string): string | null {
try {
return readFileSync(path, "utf8").trim();
} catch {
return null;
}
}
function readPid(host: string, port: number): number | null {
const raw = readText(backendPidPath(host, port));
if (!raw) {
return null;
}
const pid = Number.parseInt(raw, 10);
if (!Number.isInteger(pid) || pid <= 0) {
return null;
}
return pid;
}
function writePid(host: string, port: number, pid: number): void {
const path = backendPidPath(host, port);
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, String(pid), "utf8");
}
function removePid(host: string, port: number): void {
const path = backendPidPath(host, port);
if (existsSync(path)) {
rmSync(path);
}
}
function readBackendVersion(host: string, port: number): string | null {
return readText(backendVersionPath(host, port));
}
function writeBackendVersion(host: string, port: number, buildId: string): void {
const path = backendVersionPath(host, port);
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, buildId, "utf8");
}
function removeBackendVersion(host: string, port: number): void {
const path = backendVersionPath(host, port);
if (existsSync(path)) {
rmSync(path);
}
}
function readCliBuildId(): string {
const override = process.env.HF_BUILD_ID?.trim();
if (override) {
return override;
}
return CLI_BUILD_ID;
}
function isVersionCurrent(host: string, port: number): boolean {
return readBackendVersion(host, port) === readCliBuildId();
}
function isProcessRunning(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException | undefined)?.code === "EPERM") {
return true;
}
return false;
}
}
function removeStateFiles(host: string, port: number): void {
removePid(host, port);
removeBackendVersion(host, port);
}
async function checkHealth(host: string, port: number): Promise<boolean> {
return await checkBackendHealth({
endpoint: `http://${host}:${port}/api/rivet`,
timeoutMs: HEALTH_TIMEOUT_MS
});
}
async function waitForHealth(host: string, port: number, timeoutMs: number, pid?: number): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (pid && !isProcessRunning(pid)) {
throw new Error(`backend process ${pid} exited before becoming healthy`);
}
if (await checkHealth(host, port)) {
return;
}
await sleep(POLL_INTERVAL_MS);
}
throw new Error(`backend did not become healthy within ${timeoutMs}ms`);
}
async function waitForChildPid(child: childProcess.ChildProcess): Promise<number | null> {
if (child.pid && child.pid > 0) {
return child.pid;
}
for (let i = 0; i < 20; i += 1) {
await sleep(50);
if (child.pid && child.pid > 0) {
return child.pid;
}
}
return null;
}
interface LaunchSpec {
command: string;
args: string[];
cwd: string;
}
function resolveBunCommand(): string {
const override = process.env.HF_BUN?.trim();
if (override && (override === "bun" || existsSync(override))) {
return override;
}
const homeBun = join(homedir(), ".bun", "bin", "bun");
if (existsSync(homeBun)) {
return homeBun;
}
return "bun";
}
function resolveLaunchSpec(host: string, port: number): LaunchSpec {
const repoRoot = resolve(fileURLToPath(new URL("../../..", import.meta.url)));
const backendEntry = resolve(fileURLToPath(new URL("../../backend/dist/index.js", import.meta.url)));
if (existsSync(backendEntry)) {
return {
command: resolveBunCommand(),
args: [backendEntry, "start", "--host", host, "--port", String(port)],
cwd: repoRoot
};
}
return {
command: "pnpm",
args: [
"--filter",
"@sandbox-agent/factory-backend",
"exec",
"bun",
"src/index.ts",
"start",
"--host",
host,
"--port",
String(port)
],
cwd: repoRoot
};
}
async function startBackend(host: string, port: number): Promise<void> {
if (await checkHealth(host, port)) {
return;
}
const existingPid = readPid(host, port);
if (existingPid && isProcessRunning(existingPid)) {
await waitForHealth(host, port, START_TIMEOUT_MS, existingPid);
return;
}
if (existingPid) {
removeStateFiles(host, port);
}
const logPath = backendLogPath(host, port);
mkdirSync(dirname(logPath), { recursive: true });
const fd = openSync(logPath, "a");
const launch = resolveLaunchSpec(host, port);
const child = childProcess.spawn(launch.command, launch.args, {
cwd: launch.cwd,
detached: true,
stdio: ["ignore", fd, fd],
env: process.env
});
child.on("error", (error) => {
console.error(`failed to launch backend: ${String(error)}`);
});
child.unref();
closeSync(fd);
const pid = await waitForChildPid(child);
writeBackendVersion(host, port, readCliBuildId());
if (pid) {
writePid(host, port, pid);
}
try {
await waitForHealth(host, port, START_TIMEOUT_MS, pid ?? undefined);
} catch (error) {
if (pid) {
removeStateFiles(host, port);
} else {
removeBackendVersion(host, port);
}
throw error;
}
}
function trySignal(pid: number, signal: NodeJS.Signals): boolean {
try {
process.kill(pid, signal);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException | undefined)?.code === "ESRCH") {
return false;
}
throw error;
}
}
function findProcessOnPort(port: number): number | null {
try {
const out = childProcess
.execFileSync("lsof", ["-i", `:${port}`, "-t", "-sTCP:LISTEN"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"]
})
.trim();
const pidRaw = out.split("\n")[0]?.trim();
if (!pidRaw) {
return null;
}
const pid = Number.parseInt(pidRaw, 10);
if (!Number.isInteger(pid) || pid <= 0) {
return null;
}
return pid;
} catch {
return null;
}
}
export async function stopBackend(host: string, port: number): Promise<void> {
let pid = readPid(host, port);
if (!pid) {
if (!(await checkHealth(host, port))) {
removeStateFiles(host, port);
return;
}
pid = findProcessOnPort(port);
if (!pid) {
throw new Error(`backend is healthy at ${host}:${port} but no PID could be resolved`);
}
}
if (!isProcessRunning(pid)) {
removeStateFiles(host, port);
return;
}
trySignal(pid, "SIGTERM");
const deadline = Date.now() + STOP_TIMEOUT_MS;
while (Date.now() < deadline) {
if (!isProcessRunning(pid)) {
removeStateFiles(host, port);
return;
}
await sleep(100);
}
trySignal(pid, "SIGKILL");
removeStateFiles(host, port);
}
export interface BackendStatus {
running: boolean;
pid: number | null;
version: string | null;
versionCurrent: boolean;
logPath: string;
}
export async function getBackendStatus(host: string, port: number): Promise<BackendStatus> {
const logPath = backendLogPath(host, port);
const pid = readPid(host, port);
if (pid) {
if (isProcessRunning(pid)) {
return {
running: true,
pid,
version: readBackendVersion(host, port),
versionCurrent: isVersionCurrent(host, port),
logPath
};
}
removeStateFiles(host, port);
}
if (await checkHealth(host, port)) {
return {
running: true,
pid: null,
version: readBackendVersion(host, port),
versionCurrent: isVersionCurrent(host, port),
logPath
};
}
return {
running: false,
pid: null,
version: readBackendVersion(host, port),
versionCurrent: false,
logPath
};
}
export async function ensureBackendRunning(config: AppConfig): Promise<void> {
const host = config.backend.host;
const port = config.backend.port;
if (await checkHealth(host, port)) {
if (!isVersionCurrent(host, port)) {
await stopBackend(host, port);
await startBackend(host, port);
}
return;
}
const pid = readPid(host, port);
if (pid && isProcessRunning(pid)) {
try {
await waitForHealth(host, port, START_TIMEOUT_MS, pid);
if (!isVersionCurrent(host, port)) {
await stopBackend(host, port);
await startBackend(host, port);
}
return;
} catch {
await stopBackend(host, port);
await startBackend(host, port);
return;
}
}
if (pid) {
removeStateFiles(host, port);
}
await startBackend(host, port);
}
export function parseBackendPort(value: string | undefined, fallback: number): number {
if (!value) {
return fallback;
}
const port = Number(value);
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
throw new Error(`Invalid backend port: ${value}`);
}
return port;
}

View file

@ -1,7 +0,0 @@
declare const __HF_BUILD_ID__: string | undefined;
export const CLI_BUILD_ID =
typeof __HF_BUILD_ID__ === "string" && __HF_BUILD_ID__.trim().length > 0
? __HF_BUILD_ID__.trim()
: "dev";

View file

@ -1,754 +0,0 @@
#!/usr/bin/env bun
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { homedir } from "node:os";
import { AgentTypeSchema, CreateHandoffInputSchema, type HandoffRecord } from "@sandbox-agent/factory-shared";
import {
readBackendMetadata,
createBackendClientFromConfig,
formatRelativeAge,
groupHandoffStatus,
summarizeHandoffs
} from "@sandbox-agent/factory-client";
import {
ensureBackendRunning,
getBackendStatus,
parseBackendPort,
stopBackend
} from "./backend/manager.js";
import { openEditorForTask } from "./task-editor.js";
import { spawnCreateTmuxWindow } from "./tmux.js";
import { loadConfig, resolveWorkspace, saveConfig } from "./workspace/config.js";
async function ensureBunRuntime(): Promise<void> {
if (typeof (globalThis as { Bun?: unknown }).Bun !== "undefined") {
return;
}
const preferred = process.env.HF_BUN?.trim();
const candidates = [
preferred,
`${homedir()}/.bun/bin/bun`,
"bun"
].filter((item): item is string => Boolean(item && item.length > 0));
for (const candidate of candidates) {
const command = candidate;
const canExec = command === "bun" || existsSync(command);
if (!canExec) {
continue;
}
const child = spawnSync(command, [process.argv[1] ?? "", ...process.argv.slice(2)], {
stdio: "inherit",
env: process.env
});
if (child.error) {
continue;
}
const code = child.status ?? 1;
process.exit(code);
}
throw new Error("hf requires Bun runtime. Set HF_BUN or install Bun at ~/.bun/bin/bun.");
}
async function runTuiCommand(config: ReturnType<typeof loadConfig>, workspaceId: string): Promise<void> {
const mod = await import("./tui.js");
await mod.runTui(config, workspaceId);
}
function readOption(args: string[], flag: string): string | undefined {
const idx = args.indexOf(flag);
if (idx < 0) return undefined;
return args[idx + 1];
}
function hasFlag(args: string[], flag: string): boolean {
return args.includes(flag);
}
function parseIntOption(
value: string | undefined,
fallback: number,
label: string
): number {
if (!value) {
return fallback;
}
const parsed = Number.parseInt(value, 10);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new Error(`Invalid ${label}: ${value}`);
}
return parsed;
}
function positionals(args: string[]): string[] {
const out: string[] = [];
for (let i = 0; i < args.length; i += 1) {
const item = args[i];
if (!item) {
continue;
}
if (item.startsWith("--")) {
const next = args[i + 1];
if (next && !next.startsWith("--")) {
i += 1;
}
continue;
}
out.push(item);
}
return out;
}
function printUsage(): void {
console.log(`
Usage:
hf backend start [--host HOST] [--port PORT]
hf backend stop [--host HOST] [--port PORT]
hf backend status
hf backend inspect
hf status [--workspace WS] [--json]
hf history [--workspace WS] [--limit N] [--branch NAME] [--handoff ID] [--json]
hf workspace use <name>
hf tui [--workspace WS]
hf create [task] [--workspace WS] --repo <git-remote> [--name NAME|--branch NAME] [--title TITLE] [--agent claude|codex] [--on BRANCH]
hf list [--workspace WS] [--format table|json] [--full]
hf switch [handoff-id | -] [--workspace WS]
hf attach <handoff-id> [--workspace WS]
hf merge <handoff-id> [--workspace WS]
hf archive <handoff-id> [--workspace WS]
hf push <handoff-id> [--workspace WS]
hf sync <handoff-id> [--workspace WS]
hf kill <handoff-id> [--workspace WS] [--delete-branch] [--abandon]
hf prune [--workspace WS] [--dry-run] [--yes]
hf statusline [--workspace WS] [--format table|claude-code]
hf db path
hf db nuke
Tips:
hf status --help Show status output format and examples
hf history --help Show history output format and examples
hf switch - Switch to most recently updated handoff
`);
}
function printStatusUsage(): void {
console.log(`
Usage:
hf status [--workspace WS] [--json]
Text Output:
workspace=<workspace-id>
backend running=<true|false> pid=<pid|unknown> version=<version|unknown>
handoffs total=<number>
status queued=<n> running=<n> idle=<n> archived=<n> killed=<n> error=<n>
providers <provider-id>=<count> ...
providers -
JSON Output:
{
"workspaceId": "default",
"backend": { ...backend status object... },
"handoffs": {
"total": 4,
"byStatus": { "queued": 0, "running": 1, "idle": 2, "archived": 1, "killed": 0, "error": 0 },
"byProvider": { "daytona": 4 }
}
}
`);
}
function printHistoryUsage(): void {
console.log(`
Usage:
hf history [--workspace WS] [--limit N] [--branch NAME] [--handoff ID] [--json]
Text Output:
<iso8601>\t<event-kind>\t<branch|handoff|repo|->\t<payload-json>
<iso8601>\t<event-kind>\t<branch|handoff|repo|->\t<payload-json...>
no events
Notes:
- payload is truncated to 120 characters in text mode.
- --limit defaults to 20.
JSON Output:
[
{
"id": "...",
"workspaceId": "default",
"kind": "handoff.created",
"handoffId": "...",
"repoId": "...",
"branchName": "feature/foo",
"payloadJson": "{\\"providerId\\":\\"daytona\\"}",
"createdAt": 1770607522229
}
]
`);
}
async function handleBackend(args: string[]): Promise<void> {
const sub = args[0] ?? "start";
const config = loadConfig();
const host = readOption(args, "--host") ?? config.backend.host;
const port = parseBackendPort(readOption(args, "--port"), config.backend.port);
const backendConfig = {
...config,
backend: {
...config.backend,
host,
port
}
};
if (sub === "start") {
await ensureBackendRunning(backendConfig);
const status = await getBackendStatus(host, port);
const pid = status.pid ?? "unknown";
const version = status.version ?? "unknown";
const stale = status.running && !status.versionCurrent ? " [outdated]" : "";
console.log(`running=true pid=${pid} version=${version}${stale} log=${status.logPath}`);
return;
}
if (sub === "stop") {
await stopBackend(host, port);
console.log(`running=false host=${host} port=${port}`);
return;
}
if (sub === "status") {
const status = await getBackendStatus(host, port);
const pid = status.pid ?? "unknown";
const version = status.version ?? "unknown";
const stale = status.running && !status.versionCurrent ? " [outdated]" : "";
console.log(
`running=${status.running} pid=${pid} version=${version}${stale} host=${host} port=${port} log=${status.logPath}`
);
return;
}
if (sub === "inspect") {
await ensureBackendRunning(backendConfig);
const metadata = await readBackendMetadata({
endpoint: `http://${host}:${port}/api/rivet`,
timeoutMs: 4_000
});
const managerEndpoint = metadata.clientEndpoint ?? `http://${host}:${port}`;
const inspectorUrl = `https://inspect.rivet.dev?u=${encodeURIComponent(managerEndpoint)}`;
const openCmd = process.platform === "darwin" ? "open" : "xdg-open";
spawnSync(openCmd, [inspectorUrl], { stdio: "ignore" });
console.log(inspectorUrl);
return;
}
throw new Error(`Unknown backend subcommand: ${sub}`);
}
async function handleWorkspace(args: string[]): Promise<void> {
const sub = args[0];
if (sub !== "use") {
throw new Error("Usage: hf workspace use <name>");
}
const name = args[1];
if (!name) {
throw new Error("Missing workspace name");
}
const config = loadConfig();
config.workspace.default = name;
saveConfig(config);
const client = createBackendClientFromConfig(config);
try {
await client.useWorkspace(name);
} catch {
// Backend may not be running yet. Config is already updated.
}
console.log(`workspace=${name}`);
}
async function handleList(args: string[]): Promise<void> {
const config = loadConfig();
const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config);
const format = readOption(args, "--format") ?? "table";
const full = hasFlag(args, "--full");
const client = createBackendClientFromConfig(config);
const rows = await client.listHandoffs(workspaceId);
if (format === "json") {
console.log(JSON.stringify(rows, null, 2));
return;
}
if (rows.length === 0) {
console.log("no handoffs");
return;
}
for (const row of rows) {
const age = formatRelativeAge(row.updatedAt);
let line = `${row.handoffId}\t${row.branchName}\t${row.status}\t${row.providerId}\t${age}`;
if (full) {
const task = row.task.length > 60 ? `${row.task.slice(0, 57)}...` : row.task;
line += `\t${row.title}\t${task}\t${row.activeSessionId ?? "-"}\t${row.activeSandboxId ?? "-"}`;
}
console.log(line);
}
}
async function handlePush(args: string[]): Promise<void> {
const handoffId = positionals(args)[0];
if (!handoffId) {
throw new Error("Missing handoff id for push");
}
const config = loadConfig();
const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config);
const client = createBackendClientFromConfig(config);
await client.runAction(workspaceId, handoffId, "push");
console.log("ok");
}
async function handleSync(args: string[]): Promise<void> {
const handoffId = positionals(args)[0];
if (!handoffId) {
throw new Error("Missing handoff id for sync");
}
const config = loadConfig();
const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config);
const client = createBackendClientFromConfig(config);
await client.runAction(workspaceId, handoffId, "sync");
console.log("ok");
}
async function handleKill(args: string[]): Promise<void> {
const handoffId = positionals(args)[0];
if (!handoffId) {
throw new Error("Missing handoff id for kill");
}
const config = loadConfig();
const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config);
const deleteBranch = hasFlag(args, "--delete-branch");
const abandon = hasFlag(args, "--abandon");
if (deleteBranch) {
console.log("info: --delete-branch flag set, branch will be deleted after kill");
}
if (abandon) {
console.log("info: --abandon flag set, Graphite abandon will be attempted");
}
const client = createBackendClientFromConfig(config);
await client.runAction(workspaceId, handoffId, "kill");
console.log("ok");
}
async function handlePrune(args: string[]): Promise<void> {
const config = loadConfig();
const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config);
const dryRun = hasFlag(args, "--dry-run");
const yes = hasFlag(args, "--yes");
const client = createBackendClientFromConfig(config);
const rows = await client.listHandoffs(workspaceId);
const prunable = rows.filter((r) => r.status === "archived" || r.status === "killed");
if (prunable.length === 0) {
console.log("nothing to prune");
return;
}
for (const row of prunable) {
const age = formatRelativeAge(row.updatedAt);
console.log(`${dryRun ? "[dry-run] " : ""}${row.handoffId}\t${row.branchName}\t${row.status}\t${age}`);
}
if (dryRun) {
console.log(`\n${prunable.length} handoff(s) would be pruned`);
return;
}
if (!yes) {
console.log("\nnot yet implemented: auto-pruning requires confirmation");
return;
}
console.log(`\n${prunable.length} handoff(s) would be pruned (pruning not yet implemented)`);
}
async function handleStatusline(args: string[]): Promise<void> {
const config = loadConfig();
const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config);
const format = readOption(args, "--format") ?? "table";
const client = createBackendClientFromConfig(config);
const rows = await client.listHandoffs(workspaceId);
const summary = summarizeHandoffs(rows);
const running = summary.byStatus.running;
const idle = summary.byStatus.idle;
const errorCount = summary.byStatus.error;
if (format === "claude-code") {
console.log(`hf:${running}R/${idle}I/${errorCount}E`);
return;
}
console.log(`running=${running} idle=${idle} error=${errorCount}`);
}
async function handleDb(args: string[]): Promise<void> {
const sub = args[0];
if (sub === "path") {
const config = loadConfig();
const dbPath = config.backend.dbPath.replace(/^~/, homedir());
console.log(dbPath);
return;
}
if (sub === "nuke") {
console.log("WARNING: hf db nuke would delete the entire database. This is a placeholder and does not delete anything.");
return;
}
throw new Error("Usage: hf db path | hf db nuke");
}
async function waitForHandoffReady(
client: ReturnType<typeof createBackendClientFromConfig>,
workspaceId: string,
handoffId: string,
timeoutMs: number
): Promise<HandoffRecord> {
const start = Date.now();
let delayMs = 250;
for (;;) {
const record = await client.getHandoff(workspaceId, handoffId);
const hasName = Boolean(record.branchName && record.title);
const hasSandbox = Boolean(record.activeSandboxId);
if (record.status === "error") {
throw new Error(`handoff entered error state while provisioning: ${handoffId}`);
}
if (hasName && hasSandbox) {
return record;
}
if (Date.now() - start > timeoutMs) {
throw new Error(`timed out waiting for handoff provisioning: ${handoffId}`);
}
await new Promise((r) => setTimeout(r, delayMs));
delayMs = Math.min(Math.round(delayMs * 1.5), 2_000);
}
}
async function handleCreate(args: string[]): Promise<void> {
const config = loadConfig();
const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config);
const repoRemote = readOption(args, "--repo");
if (!repoRemote) {
throw new Error("Missing required --repo <git-remote>");
}
const explicitBranchName = readOption(args, "--name") ?? readOption(args, "--branch");
const explicitTitle = readOption(args, "--title");
const agentRaw = readOption(args, "--agent");
const agentType = agentRaw ? AgentTypeSchema.parse(agentRaw) : undefined;
const onBranch = readOption(args, "--on");
const taskFromArgs = positionals(args).join(" ").trim();
const task = taskFromArgs || openEditorForTask();
const client = createBackendClientFromConfig(config);
const repo = await client.addRepo(workspaceId, repoRemote);
const payload = CreateHandoffInputSchema.parse({
workspaceId,
repoId: repo.repoId,
task,
explicitTitle: explicitTitle || undefined,
explicitBranchName: explicitBranchName || undefined,
agentType,
onBranch
});
const created = await client.createHandoff(payload);
const handoff = await waitForHandoffReady(client, workspaceId, created.handoffId, 180_000);
const switched = await client.switchHandoff(workspaceId, handoff.handoffId);
const attached = await client.attachHandoff(workspaceId, handoff.handoffId);
console.log(`Branch: ${handoff.branchName ?? "-"}`);
console.log(`Handoff: ${handoff.handoffId}`);
console.log(`Provider: ${handoff.providerId}`);
console.log(`Session: ${attached.sessionId ?? "none"}`);
console.log(`Target: ${switched.switchTarget || attached.target}`);
console.log(`Title: ${handoff.title ?? "-"}`);
const tmuxResult = spawnCreateTmuxWindow({
branchName: handoff.branchName ?? handoff.handoffId,
targetPath: switched.switchTarget || attached.target,
sessionId: attached.sessionId
});
if (tmuxResult.created) {
console.log(`Window: created (${handoff.branchName})`);
return;
}
console.log("");
console.log(`Run: hf switch ${handoff.handoffId}`);
if ((switched.switchTarget || attached.target).startsWith("/")) {
console.log(`cd ${(switched.switchTarget || attached.target)}`);
}
}
async function handleTui(args: string[]): Promise<void> {
const config = loadConfig();
const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config);
await runTuiCommand(config, workspaceId);
}
async function handleStatus(args: string[]): Promise<void> {
if (hasFlag(args, "--help") || hasFlag(args, "-h")) {
printStatusUsage();
return;
}
const config = loadConfig();
const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config);
const client = createBackendClientFromConfig(config);
const backendStatus = await getBackendStatus(config.backend.host, config.backend.port);
const rows = await client.listHandoffs(workspaceId);
const summary = summarizeHandoffs(rows);
if (hasFlag(args, "--json")) {
console.log(
JSON.stringify(
{
workspaceId,
backend: backendStatus,
handoffs: {
total: summary.total,
byStatus: summary.byStatus,
byProvider: summary.byProvider
}
},
null,
2
)
);
return;
}
console.log(`workspace=${workspaceId}`);
console.log(
`backend running=${backendStatus.running} pid=${backendStatus.pid ?? "unknown"} version=${backendStatus.version ?? "unknown"}`
);
console.log(`handoffs total=${summary.total}`);
console.log(
`status queued=${summary.byStatus.queued} running=${summary.byStatus.running} idle=${summary.byStatus.idle} archived=${summary.byStatus.archived} killed=${summary.byStatus.killed} error=${summary.byStatus.error}`
);
const providerSummary = Object.entries(summary.byProvider)
.map(([provider, count]) => `${provider}=${count}`)
.join(" ");
console.log(`providers ${providerSummary || "-"}`);
}
async function handleHistory(args: string[]): Promise<void> {
if (hasFlag(args, "--help") || hasFlag(args, "-h")) {
printHistoryUsage();
return;
}
const config = loadConfig();
const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config);
const limit = parseIntOption(readOption(args, "--limit"), 20, "limit");
const branch = readOption(args, "--branch");
const handoffId = readOption(args, "--handoff");
const client = createBackendClientFromConfig(config);
const rows = await client.listHistory({
workspaceId,
limit,
branch: branch || undefined,
handoffId: handoffId || undefined
});
if (hasFlag(args, "--json")) {
console.log(JSON.stringify(rows, null, 2));
return;
}
if (rows.length === 0) {
console.log("no events");
return;
}
for (const row of rows) {
const ts = new Date(row.createdAt).toISOString();
const target = row.branchName || row.handoffId || row.repoId || "-";
let payload = row.payloadJson;
if (payload.length > 120) {
payload = `${payload.slice(0, 117)}...`;
}
console.log(`${ts}\t${row.kind}\t${target}\t${payload}`);
}
}
async function handleSwitchLike(cmd: string, args: string[]): Promise<void> {
let handoffId = positionals(args)[0];
if (!handoffId && cmd === "switch") {
await handleTui(args);
return;
}
if (!handoffId) {
throw new Error(`Missing handoff id for ${cmd}`);
}
const config = loadConfig();
const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config);
const client = createBackendClientFromConfig(config);
if (cmd === "switch" && handoffId === "-") {
const rows = await client.listHandoffs(workspaceId);
const active = rows.filter((r) => {
const group = groupHandoffStatus(r.status);
return group === "running" || group === "idle" || group === "queued";
});
const sorted = active.sort((a, b) => b.updatedAt - a.updatedAt);
const target = sorted[0];
if (!target) {
throw new Error("No active handoffs to switch to");
}
handoffId = target.handoffId;
}
if (cmd === "switch") {
const result = await client.switchHandoff(workspaceId, handoffId);
console.log(`cd ${result.switchTarget}`);
return;
}
if (cmd === "attach") {
const result = await client.attachHandoff(workspaceId, handoffId);
console.log(`target=${result.target} session=${result.sessionId ?? "none"}`);
return;
}
if (cmd === "merge" || cmd === "archive") {
await client.runAction(workspaceId, handoffId, cmd);
console.log("ok");
return;
}
throw new Error(`Unsupported action: ${cmd}`);
}
async function main(): Promise<void> {
await ensureBunRuntime();
const args = process.argv.slice(2);
const cmd = args[0];
const rest = args.slice(1);
if (cmd === "help" || cmd === "--help" || cmd === "-h") {
printUsage();
return;
}
if (cmd === "backend") {
await handleBackend(rest);
return;
}
const config = loadConfig();
await ensureBackendRunning(config);
if (!cmd || cmd.startsWith("--")) {
await handleTui(args);
return;
}
if (cmd === "workspace") {
await handleWorkspace(rest);
return;
}
if (cmd === "create") {
await handleCreate(rest);
return;
}
if (cmd === "list") {
await handleList(rest);
return;
}
if (cmd === "tui") {
await handleTui(rest);
return;
}
if (cmd === "status") {
await handleStatus(rest);
return;
}
if (cmd === "history") {
await handleHistory(rest);
return;
}
if (cmd === "push") {
await handlePush(rest);
return;
}
if (cmd === "sync") {
await handleSync(rest);
return;
}
if (cmd === "kill") {
await handleKill(rest);
return;
}
if (cmd === "prune") {
await handlePrune(rest);
return;
}
if (cmd === "statusline") {
await handleStatusline(rest);
return;
}
if (cmd === "db") {
await handleDb(rest);
return;
}
if (["switch", "attach", "merge", "archive"].includes(cmd)) {
await handleSwitchLike(cmd, rest);
return;
}
printUsage();
throw new Error(`Unknown command: ${cmd}`);
}
main().catch((err: unknown) => {
const msg = err instanceof Error ? err.stack ?? err.message : String(err);
console.error(msg);
process.exit(1);
});

View file

@ -1,45 +0,0 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
const DEFAULT_EDITOR_TEMPLATE = [
"# Enter handoff task details below.",
"# Lines starting with # are ignored.",
""
].join("\n");
export function sanitizeEditorTask(input: string): string {
return input
.split(/\r?\n/)
.filter((line) => !line.trim().startsWith("#"))
.join("\n")
.trim();
}
export function openEditorForTask(): string {
const editor = process.env.VISUAL?.trim() || process.env.EDITOR?.trim() || "vi";
const tempDir = mkdtempSync(join(tmpdir(), "hf-task-"));
const taskPath = join(tempDir, "task.md");
try {
writeFileSync(taskPath, DEFAULT_EDITOR_TEMPLATE, "utf8");
const result = spawnSync(editor, [taskPath], { stdio: "inherit" });
if (result.error) {
throw result.error;
}
if ((result.status ?? 1) !== 0) {
throw new Error(`Editor exited with status ${result.status ?? "unknown"}`);
}
const raw = readFileSync(taskPath, "utf8");
const task = sanitizeEditorTask(raw);
if (!task) {
throw new Error("Missing handoff task text");
}
return task;
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
}

View file

@ -1,811 +0,0 @@
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, isAbsolute, join, resolve } from "node:path";
import { cwd } from "node:process";
import * as toml from "@iarna/toml";
import type { AppConfig } from "@sandbox-agent/factory-shared";
import opencodeThemePackJson from "./themes/opencode-pack.json" with { type: "json" };
export type ThemeMode = "dark" | "light";
export interface TuiTheme {
background: string;
text: string;
muted: string;
header: string;
status: string;
highlightBg: string;
highlightFg: string;
selectionBorder: string;
success: string;
warning: string;
error: string;
info: string;
diffAdd: string;
diffDel: string;
diffSep: string;
agentRunning: string;
agentIdle: string;
agentNone: string;
agentError: string;
prUnpushed: string;
author: string;
ciRunning: string;
ciPass: string;
ciFail: string;
ciNone: string;
reviewApproved: string;
reviewChanges: string;
reviewPending: string;
reviewNone: string;
}
export interface TuiThemeResolution {
theme: TuiTheme;
name: string;
source: string;
mode: ThemeMode;
}
interface ThemeCandidate {
theme: TuiTheme;
name: string;
}
type JsonObject = Record<string, unknown>;
type ConfigLike = AppConfig & { theme?: string };
const DEFAULT_THEME: TuiTheme = {
background: "#282828",
text: "#ffffff",
muted: "#6b7280",
header: "#6b7280",
status: "#6b7280",
highlightBg: "#282828",
highlightFg: "#ffffff",
selectionBorder: "#d946ef",
success: "#22c55e",
warning: "#eab308",
error: "#ef4444",
info: "#22d3ee",
diffAdd: "#22c55e",
diffDel: "#ef4444",
diffSep: "#6b7280",
agentRunning: "#22c55e",
agentIdle: "#eab308",
agentNone: "#6b7280",
agentError: "#ef4444",
prUnpushed: "#eab308",
author: "#22d3ee",
ciRunning: "#eab308",
ciPass: "#22c55e",
ciFail: "#ef4444",
ciNone: "#6b7280",
reviewApproved: "#22c55e",
reviewChanges: "#ef4444",
reviewPending: "#eab308",
reviewNone: "#6b7280"
};
const OPENCODE_THEME_PACK = opencodeThemePackJson as Record<string, unknown>;
export function resolveTuiTheme(config: AppConfig, baseDir = cwd()): TuiThemeResolution {
const mode = opencodeStateThemeMode() ?? "dark";
const configWithTheme = config as ConfigLike;
const override = typeof configWithTheme.theme === "string" ? configWithTheme.theme.trim() : "";
if (override) {
const candidate = loadFromSpec(override, [], mode, baseDir);
if (candidate) {
return {
theme: candidate.theme,
name: candidate.name,
source: "factory config",
mode
};
}
}
const fromConfig = loadOpencodeThemeFromConfig(mode, baseDir);
if (fromConfig) {
return fromConfig;
}
const fromState = loadOpencodeThemeFromState(mode, baseDir);
if (fromState) {
return fromState;
}
return {
theme: DEFAULT_THEME,
name: "opencode-default",
source: "default",
mode
};
}
function loadOpencodeThemeFromConfig(mode: ThemeMode, baseDir: string): TuiThemeResolution | null {
for (const path of opencodeConfigPaths(baseDir)) {
if (!existsSync(path)) {
continue;
}
const value = readJsonWithComments(path);
if (!value) {
continue;
}
const themeValue = findOpencodeThemeValue(value);
if (themeValue === undefined) {
continue;
}
const candidate = themeFromOpencodeValue(themeValue, opencodeThemeDirs(dirname(path), baseDir), mode, baseDir);
if (!candidate) {
continue;
}
return {
theme: candidate.theme,
name: candidate.name,
source: `opencode config (${path})`,
mode
};
}
return null;
}
function loadOpencodeThemeFromState(mode: ThemeMode, baseDir: string): TuiThemeResolution | null {
const path = opencodeStatePath();
if (!path || !existsSync(path)) {
return null;
}
const value = readJsonWithComments(path);
if (!isObject(value)) {
return null;
}
const spec = value.theme;
if (typeof spec !== "string" || !spec.trim()) {
return null;
}
const candidate = loadFromSpec(spec.trim(), opencodeThemeDirs(undefined, baseDir), mode, baseDir);
if (!candidate) {
return null;
}
return {
theme: candidate.theme,
name: candidate.name,
source: `opencode state (${path})`,
mode
};
}
function loadFromSpec(
spec: string,
searchDirs: string[],
mode: ThemeMode,
baseDir: string
): ThemeCandidate | null {
if (isDefaultThemeName(spec)) {
return {
theme: DEFAULT_THEME,
name: "opencode-default"
};
}
if (isPathLike(spec)) {
const resolved = resolvePath(spec, baseDir);
if (existsSync(resolved)) {
const candidate = loadThemeFromPath(resolved, mode);
if (candidate) {
return candidate;
}
}
}
for (const dir of searchDirs) {
for (const ext of ["json", "toml"]) {
const path = join(dir, `${spec}.${ext}`);
if (!existsSync(path)) {
continue;
}
const candidate = loadThemeFromPath(path, mode);
if (candidate) {
return candidate;
}
}
}
const builtIn = OPENCODE_THEME_PACK[spec];
if (builtIn !== undefined) {
const theme = themeFromOpencodeJson(builtIn, mode);
if (theme) {
return {
theme,
name: spec
};
}
}
return null;
}
function loadThemeFromPath(path: string, mode: ThemeMode): ThemeCandidate | null {
const content = safeReadText(path);
if (!content) {
return null;
}
const lower = path.toLowerCase();
if (lower.endsWith(".toml")) {
try {
const parsed = toml.parse(content);
const theme = themeFromAny(parsed);
if (!theme) {
return null;
}
return {
theme,
name: themeNameFromPath(path)
};
} catch {
return null;
}
}
const value = parseJsonWithComments(content);
if (!value) {
return null;
}
const opencodeTheme = themeFromOpencodeJson(value, mode);
if (opencodeTheme) {
return {
theme: opencodeTheme,
name: themeNameFromPath(path)
};
}
const paletteTheme = themeFromAny(value);
if (!paletteTheme) {
return null;
}
return {
theme: paletteTheme,
name: themeNameFromPath(path)
};
}
function themeNameFromPath(path: string): string {
const base = path.split(/[\\/]/).pop() ?? path;
if (base.endsWith(".json") || base.endsWith(".toml")) {
return base.replace(/\.(json|toml)$/i, "");
}
return base;
}
function themeFromOpencodeValue(
value: unknown,
searchDirs: string[],
mode: ThemeMode,
baseDir: string
): ThemeCandidate | null {
if (typeof value === "string") {
return loadFromSpec(value, searchDirs, mode, baseDir);
}
if (!isObject(value)) {
return null;
}
if (value.theme !== undefined) {
const theme = themeFromOpencodeJson(value, mode);
if (theme) {
return {
theme,
name: typeof value.name === "string" ? value.name : "inline"
};
}
}
const paletteTheme = themeFromAny(value.colors ?? value.palette ?? value);
if (paletteTheme) {
return {
theme: paletteTheme,
name: typeof value.name === "string" ? value.name : "inline"
};
}
if (typeof value.name === "string") {
const named = loadFromSpec(value.name, searchDirs, mode, baseDir);
if (named) {
return named;
}
}
const pathLike = value.path ?? value.file;
if (typeof pathLike === "string") {
const resolved = resolvePath(pathLike, baseDir);
const candidate = loadThemeFromPath(resolved, mode);
if (candidate) {
return candidate;
}
}
return null;
}
function themeFromOpencodeJson(value: unknown, mode: ThemeMode): TuiTheme | null {
if (!isObject(value)) {
return null;
}
const themeMap = value.theme;
if (!isObject(themeMap)) {
return null;
}
const defs = isObject(value.defs) ? value.defs : {};
const background =
opencodeColor(themeMap, defs, mode, "background") ??
opencodeColor(themeMap, defs, mode, "backgroundPanel") ??
opencodeColor(themeMap, defs, mode, "backgroundElement") ??
DEFAULT_THEME.background;
const text = opencodeColor(themeMap, defs, mode, "text") ?? DEFAULT_THEME.text;
const muted = opencodeColor(themeMap, defs, mode, "textMuted") ?? DEFAULT_THEME.muted;
const highlightBg = opencodeColor(themeMap, defs, mode, "text") ?? text;
const highlightFg =
opencodeColor(themeMap, defs, mode, "backgroundElement") ??
opencodeColor(themeMap, defs, mode, "backgroundPanel") ??
opencodeColor(themeMap, defs, mode, "background") ??
DEFAULT_THEME.highlightFg;
const selectionBorder =
opencodeColor(themeMap, defs, mode, "secondary") ??
opencodeColor(themeMap, defs, mode, "accent") ??
opencodeColor(themeMap, defs, mode, "primary") ??
DEFAULT_THEME.selectionBorder;
const success = opencodeColor(themeMap, defs, mode, "success") ?? DEFAULT_THEME.success;
const warning = opencodeColor(themeMap, defs, mode, "warning") ?? DEFAULT_THEME.warning;
const error = opencodeColor(themeMap, defs, mode, "error") ?? DEFAULT_THEME.error;
const info = opencodeColor(themeMap, defs, mode, "info") ?? DEFAULT_THEME.info;
const diffAdd = opencodeColor(themeMap, defs, mode, "diffAdded") ?? success;
const diffDel = opencodeColor(themeMap, defs, mode, "diffRemoved") ?? error;
const diffSep =
opencodeColor(themeMap, defs, mode, "diffContext") ??
opencodeColor(themeMap, defs, mode, "diffHunkHeader") ??
muted;
return {
background,
text,
muted,
header: muted,
status: muted,
highlightBg,
highlightFg,
selectionBorder,
success,
warning,
error,
info,
diffAdd,
diffDel,
diffSep,
agentRunning: success,
agentIdle: warning,
agentNone: muted,
agentError: error,
prUnpushed: warning,
author: info,
ciRunning: warning,
ciPass: success,
ciFail: error,
ciNone: muted,
reviewApproved: success,
reviewChanges: error,
reviewPending: warning,
reviewNone: muted
};
}
function opencodeColor(themeMap: JsonObject, defs: JsonObject, mode: ThemeMode, key: string): string | null {
const raw = themeMap[key];
if (raw === undefined) {
return null;
}
return resolveOpencodeColor(raw, themeMap, defs, mode, 0);
}
function resolveOpencodeColor(
value: unknown,
themeMap: JsonObject,
defs: JsonObject,
mode: ThemeMode,
depth: number
): string | null {
if (depth > 12) {
return null;
}
if (typeof value === "string") {
const trimmed = value.trim();
if (!trimmed || trimmed.toLowerCase() === "transparent" || trimmed.toLowerCase() === "none") {
return null;
}
const fromDefs = defs[trimmed];
if (fromDefs !== undefined) {
return resolveOpencodeColor(fromDefs, themeMap, defs, mode, depth + 1);
}
const fromTheme = themeMap[trimmed];
if (fromTheme !== undefined) {
return resolveOpencodeColor(fromTheme, themeMap, defs, mode, depth + 1);
}
if (isColorLike(trimmed)) {
return trimmed;
}
return null;
}
if (isObject(value)) {
const nested = value[mode];
if (nested !== undefined) {
return resolveOpencodeColor(nested, themeMap, defs, mode, depth + 1);
}
}
return null;
}
function themeFromAny(value: unknown): TuiTheme | null {
const palette = extractPalette(value);
if (!palette) {
return null;
}
const pick = (keys: string[], fallback: string): string => {
for (const key of keys) {
const v = palette[normalizeKey(key)];
if (v && isColorLike(v)) {
return v;
}
}
return fallback;
};
const background = pick(["background", "bg", "base", "background_color"], DEFAULT_THEME.background);
const text = pick(["text", "foreground", "fg", "primary"], DEFAULT_THEME.text);
const muted = pick(["muted", "subtle", "secondary", "dim"], DEFAULT_THEME.muted);
const header = pick(["header", "header_text"], muted);
const status = pick(["status", "status_text"], muted);
const highlightBg = pick(["highlight_bg", "selection", "highlight", "accent_bg"], DEFAULT_THEME.highlightBg);
const highlightFg = pick(["highlight_fg", "selection_fg", "accent_fg"], text);
const selectionBorder = pick(["selection_border", "highlight_border", "accent", "secondary"], DEFAULT_THEME.selectionBorder);
const success = pick(["success", "green"], DEFAULT_THEME.success);
const warning = pick(["warning", "yellow"], DEFAULT_THEME.warning);
const error = pick(["error", "red"], DEFAULT_THEME.error);
const info = pick(["info", "cyan", "blue"], DEFAULT_THEME.info);
const diffAdd = pick(["diff_add", "diff_addition", "add"], success);
const diffDel = pick(["diff_del", "diff_deletion", "delete"], error);
const diffSep = pick(["diff_sep", "diff_separator", "separator"], muted);
return {
background,
text,
muted,
header,
status,
highlightBg,
highlightFg,
selectionBorder,
success,
warning,
error,
info,
diffAdd,
diffDel,
diffSep,
agentRunning: pick(["agent_running", "running"], success),
agentIdle: pick(["agent_idle", "idle"], warning),
agentNone: pick(["agent_none", "none"], muted),
agentError: pick(["agent_error", "agent_failed"], error),
prUnpushed: pick(["pr_unpushed", "unpushed"], warning),
author: pick(["author"], info),
ciRunning: pick(["ci_running"], warning),
ciPass: pick(["ci_pass", "ci_success"], success),
ciFail: pick(["ci_fail", "ci_error"], error),
ciNone: pick(["ci_none", "ci_unknown"], muted),
reviewApproved: pick(["review_approved", "approved"], success),
reviewChanges: pick(["review_changes", "changes"], error),
reviewPending: pick(["review_pending", "pending"], warning),
reviewNone: pick(["review_none", "review_unknown"], muted)
};
}
function extractPalette(value: unknown): Record<string, string> | null {
if (!isObject(value)) {
return null;
}
const colors = isObject(value.colors) ? value.colors : undefined;
const palette = isObject(value.palette) ? value.palette : undefined;
const source = colors ?? palette ?? value;
if (!isObject(source)) {
return null;
}
const out: Record<string, string> = {};
for (const [key, raw] of Object.entries(source)) {
if (typeof raw !== "string") {
continue;
}
out[normalizeKey(key)] = raw;
}
return Object.keys(out).length > 0 ? out : null;
}
function normalizeKey(key: string): string {
return key.toLowerCase().replace(/[\-\s.]/g, "_");
}
function isColorLike(value: string): boolean {
const lower = value.trim().toLowerCase();
if (!lower) {
return false;
}
if (/^#[0-9a-f]{3}$/.test(lower) || /^#[0-9a-f]{6}$/.test(lower) || /^#[0-9a-f]{8}$/.test(lower)) {
return true;
}
if (/^rgba?\(\s*\d+\s*,\s*\d+\s*,\s*\d+(\s*,\s*[\d.]+)?\s*\)$/.test(lower)) {
return true;
}
return /^[a-z_\-]+$/.test(lower);
}
function findOpencodeThemeValue(value: unknown): unknown {
if (!isObject(value)) {
return undefined;
}
if (value.theme !== undefined) {
return value.theme;
}
return pointer(value, ["ui", "theme"]) ?? pointer(value, ["tui", "theme"]) ?? pointer(value, ["options", "theme"]);
}
function pointer(obj: JsonObject, parts: string[]): unknown {
let current: unknown = obj;
for (const part of parts) {
if (!isObject(current)) {
return undefined;
}
current = current[part];
}
return current;
}
function opencodeConfigPaths(baseDir: string): string[] {
const paths: string[] = [];
const rootish = opencodeProjectConfigPaths(baseDir);
paths.push(...rootish);
const configDir = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
const opencodeDir = join(configDir, "opencode");
paths.push(join(opencodeDir, "opencode.json"));
paths.push(join(opencodeDir, "opencode.jsonc"));
paths.push(join(opencodeDir, "config.json"));
return paths;
}
function opencodeThemeDirs(configDir: string | undefined, baseDir: string): string[] {
const dirs: string[] = [];
if (configDir) {
dirs.push(join(configDir, "themes"));
}
const xdgConfig = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
dirs.push(join(xdgConfig, "opencode", "themes"));
dirs.push(join(homedir(), ".opencode", "themes"));
dirs.push(...opencodeProjectThemeDirs(baseDir));
return dirs;
}
function opencodeProjectConfigPaths(baseDir: string): string[] {
const dirs = ancestorDirs(baseDir);
const out: string[] = [];
for (const dir of dirs) {
out.push(join(dir, "opencode.json"));
out.push(join(dir, "opencode.jsonc"));
out.push(join(dir, ".opencode", "opencode.json"));
out.push(join(dir, ".opencode", "opencode.jsonc"));
}
return out;
}
function opencodeProjectThemeDirs(baseDir: string): string[] {
const dirs = ancestorDirs(baseDir);
const out: string[] = [];
for (const dir of dirs) {
out.push(join(dir, ".opencode", "themes"));
}
return out;
}
function ancestorDirs(start: string): string[] {
const out: string[] = [];
let current = resolve(start);
while (true) {
out.push(current);
const parent = dirname(current);
if (parent === current) {
break;
}
current = parent;
}
return out;
}
function opencodeStatePath(): string | null {
const stateHome = process.env.XDG_STATE_HOME || join(homedir(), ".local", "state");
return join(stateHome, "opencode", "kv.json");
}
function opencodeStateThemeMode(): ThemeMode | null {
const path = opencodeStatePath();
if (!path || !existsSync(path)) {
return null;
}
const value = readJsonWithComments(path);
if (!isObject(value)) {
return null;
}
const mode = value.theme_mode;
if (typeof mode !== "string") {
return null;
}
const lower = mode.toLowerCase();
if (lower === "dark" || lower === "light") {
return lower;
}
return null;
}
function parseJsonWithComments(content: string): unknown {
try {
return JSON.parse(content);
} catch {
// Fall through.
}
try {
return JSON.parse(stripJsoncComments(content));
} catch {
return null;
}
}
function readJsonWithComments(path: string): unknown {
const content = safeReadText(path);
if (!content) {
return null;
}
return parseJsonWithComments(content);
}
function stripJsoncComments(input: string): string {
let output = "";
let i = 0;
let inString = false;
let escaped = false;
while (i < input.length) {
const ch = input[i];
if (inString) {
output += ch;
if (escaped) {
escaped = false;
} else if (ch === "\\") {
escaped = true;
} else if (ch === '"') {
inString = false;
}
i += 1;
continue;
}
if (ch === '"') {
inString = true;
output += ch;
i += 1;
continue;
}
if (ch === "/" && input[i + 1] === "/") {
i += 2;
while (i < input.length && input[i] !== "\n") {
i += 1;
}
continue;
}
if (ch === "/" && input[i + 1] === "*") {
i += 2;
while (i < input.length) {
if (input[i] === "*" && input[i + 1] === "/") {
i += 2;
break;
}
i += 1;
}
continue;
}
output += ch;
i += 1;
}
return output;
}
function safeReadText(path: string): string | null {
try {
return readFileSync(path, "utf8");
} catch {
return null;
}
}
function resolvePath(path: string, baseDir: string): string {
if (path.startsWith("~/")) {
return join(homedir(), path.slice(2));
}
if (isAbsolute(path)) {
return path;
}
return resolve(baseDir, path);
}
function isPathLike(spec: string): boolean {
return spec.includes("/") || spec.includes("\\") || spec.endsWith(".json") || spec.endsWith(".toml");
}
function isDefaultThemeName(spec: string): boolean {
const lower = spec.toLowerCase();
return lower === "default" || lower === "opencode" || lower === "opencode-default" || lower === "system";
}
function isObject(value: unknown): value is JsonObject {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

File diff suppressed because it is too large Load diff

View file

@ -1,220 +0,0 @@
import { execFileSync, spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { homedir } from "node:os";
const SYMBOL_RUNNING = "▶";
const SYMBOL_IDLE = "✓";
const DEFAULT_OPENCODE_ENDPOINT = "http://127.0.0.1:4097/opencode";
export interface TmuxWindowMatch {
target: string;
windowName: string;
}
export interface SpawnCreateTmuxWindowInput {
branchName: string;
targetPath: string;
sessionId?: string | null;
opencodeEndpoint?: string;
}
export interface SpawnCreateTmuxWindowResult {
created: boolean;
reason:
| "created"
| "not-in-tmux"
| "not-local-path"
| "window-exists"
| "tmux-new-window-failed";
}
function isTmuxSession(): boolean {
return Boolean(process.env.TMUX);
}
function isAbsoluteLocalPath(path: string): boolean {
return path.startsWith("/");
}
function runTmux(args: string[]): boolean {
const result = spawnSync("tmux", args, { stdio: "ignore" });
return !result.error && result.status === 0;
}
function shellEscape(value: string): string {
if (value.length === 0) {
return "''";
}
return `'${value.replace(/'/g, `'\\''`)}'`;
}
function opencodeExistsOnPath(): boolean {
const probe = spawnSync("which", ["opencode"], { stdio: "ignore" });
return !probe.error && probe.status === 0;
}
function resolveOpencodeBinary(): string {
const envOverride = process.env.HF_OPENCODE_BIN?.trim();
if (envOverride) {
return envOverride;
}
if (opencodeExistsOnPath()) {
return "opencode";
}
const bundledCandidates = [
`${homedir()}/.local/share/sandbox-agent/bin/opencode`,
`${homedir()}/.opencode/bin/opencode`
];
for (const candidate of bundledCandidates) {
if (existsSync(candidate)) {
return candidate;
}
}
return "opencode";
}
function attachCommand(sessionId: string, targetPath: string, endpoint: string): string {
const opencode = resolveOpencodeBinary();
return [
shellEscape(opencode),
"attach",
shellEscape(endpoint),
"--session",
shellEscape(sessionId),
"--dir",
shellEscape(targetPath)
].join(" ");
}
export function stripStatusPrefix(windowName: string): string {
return windowName
.trimStart()
.replace(new RegExp(`^${SYMBOL_RUNNING}\\s+`), "")
.replace(new RegExp(`^${SYMBOL_IDLE}\\s+`), "")
.trim();
}
export function findTmuxWindowsByBranch(branchName: string): TmuxWindowMatch[] {
const output = spawnSync(
"tmux",
["list-windows", "-a", "-F", "#{session_name}:#{window_id}:#{window_name}"],
{ encoding: "utf8" }
);
if (output.error || output.status !== 0 || !output.stdout) {
return [];
}
const lines = output.stdout.split(/\r?\n/).filter((line) => line.trim().length > 0);
const matches: TmuxWindowMatch[] = [];
for (const line of lines) {
const parts = line.split(":", 3);
if (parts.length !== 3) {
continue;
}
const sessionName = parts[0] ?? "";
const windowId = parts[1] ?? "";
const windowName = parts[2] ?? "";
const clean = stripStatusPrefix(windowName);
if (clean !== branchName) {
continue;
}
matches.push({
target: `${sessionName}:${windowId}`,
windowName
});
}
return matches;
}
export function spawnCreateTmuxWindow(
input: SpawnCreateTmuxWindowInput
): SpawnCreateTmuxWindowResult {
if (!isTmuxSession()) {
return { created: false, reason: "not-in-tmux" };
}
if (!isAbsoluteLocalPath(input.targetPath)) {
return { created: false, reason: "not-local-path" };
}
if (findTmuxWindowsByBranch(input.branchName).length > 0) {
return { created: false, reason: "window-exists" };
}
const windowName = input.sessionId ? `${SYMBOL_RUNNING} ${input.branchName}` : input.branchName;
const endpoint = input.opencodeEndpoint ?? DEFAULT_OPENCODE_ENDPOINT;
let output = "";
try {
output = execFileSync(
"tmux",
[
"new-window",
"-d",
"-P",
"-F",
"#{window_id}",
"-n",
windowName,
"-c",
input.targetPath
],
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }
);
} catch {
return { created: false, reason: "tmux-new-window-failed" };
}
const windowId = output.trim();
if (!windowId) {
return { created: false, reason: "tmux-new-window-failed" };
}
if (input.sessionId) {
const leftPane = `${windowId}.0`;
// Split left pane horizontally → creates right pane; capture its pane ID
let rightPane: string;
try {
rightPane = execFileSync(
"tmux",
["split-window", "-h", "-P", "-F", "#{pane_id}", "-t", leftPane, "-c", input.targetPath],
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }
).trim();
} catch {
return { created: true, reason: "created" };
}
if (!rightPane) {
return { created: true, reason: "created" };
}
// Split right pane vertically → top-right (rightPane) + bottom-right (new)
runTmux(["split-window", "-v", "-t", rightPane, "-c", input.targetPath]);
// Left pane 60% width, top-right pane 70% height
runTmux(["resize-pane", "-t", leftPane, "-x", "60%"]);
runTmux(["resize-pane", "-t", rightPane, "-y", "70%"]);
// Editor in left pane, agent attach in top-right pane
runTmux(["send-keys", "-t", leftPane, "nvim .", "Enter"]);
runTmux([
"send-keys",
"-t",
rightPane,
attachCommand(input.sessionId, input.targetPath, endpoint),
"Enter"
]);
runTmux(["select-pane", "-t", rightPane]);
}
return { created: true, reason: "created" };
}

View file

@ -1,640 +0,0 @@
import type { AppConfig, HandoffRecord } from "@sandbox-agent/factory-shared";
import { spawnSync } from "node:child_process";
import {
createBackendClientFromConfig,
filterHandoffs,
formatRelativeAge,
groupHandoffStatus
} from "@sandbox-agent/factory-client";
import { CLI_BUILD_ID } from "./build-id.js";
import { resolveTuiTheme, type TuiTheme } from "./theme.js";
interface KeyEventLike {
name?: string;
ctrl?: boolean;
meta?: boolean;
}
const HELP_LINES = [
"Shortcuts",
"Ctrl-H toggle cheatsheet",
"Enter switch to branch",
"Ctrl-A attach to session",
"Ctrl-O open PR in browser",
"Ctrl-X archive branch / close PR",
"Ctrl-Y merge highlighted PR",
"Ctrl-S sync handoff with remote",
"Ctrl-N / Down next row",
"Ctrl-P / Up previous row",
"Backspace delete filter",
"Type filter by branch/PR/author",
"Esc / Ctrl-C cancel",
"",
"Legend",
"Agent: \u{1F916} running \u{1F4AC} idle \u25CC queued"
];
const COLUMN_WIDTHS = {
diff: 10,
agent: 5,
pr: 6,
author: 10,
ci: 7,
review: 8,
age: 5
} as const;
interface DisplayRow {
name: string;
diff: string;
agent: string;
pr: string;
author: string;
ci: string;
review: string;
age: string;
}
interface RenderOptions {
width?: number;
height?: number;
}
function pad(input: string, width: number): string {
if (width <= 0) {
return "";
}
const chars = Array.from(input);
const text = chars.length > width ? `${chars.slice(0, Math.max(1, width - 1)).join("")}` : input;
return text.padEnd(width, " ");
}
function truncateToLen(input: string, maxLen: number): string {
if (maxLen <= 0) {
return "";
}
return Array.from(input).slice(0, maxLen).join("");
}
function fitLine(input: string, width: number): string {
if (width <= 0) {
return "";
}
const clipped = truncateToLen(input, width);
const len = Array.from(clipped).length;
if (len >= width) {
return clipped;
}
return `${clipped}${" ".repeat(width - len)}`;
}
function overlayLine(base: string, overlay: string, startCol: number, width: number): string {
const out = Array.from(fitLine(base, width));
const src = Array.from(truncateToLen(overlay, Math.max(0, width - startCol)));
for (let i = 0; i < src.length; i += 1) {
const col = startCol + i;
if (col >= 0 && col < out.length) {
out[col] = src[i] ?? " ";
}
}
return out.join("");
}
function buildFooterLine(width: number, segments: string[], right: string): string {
if (width <= 0) {
return "";
}
const rightLen = Array.from(right).length;
if (width <= rightLen + 1) {
return truncateToLen(right, width);
}
const leftMax = width - rightLen - 1;
let used = 0;
let left = "";
let first = true;
for (const segment of segments) {
const chunk = first ? segment : ` | ${segment}`;
const clipped = truncateToLen(chunk, leftMax - used);
if (!clipped) {
break;
}
left += clipped;
used += Array.from(clipped).length;
first = false;
if (used >= leftMax) {
break;
}
}
const padding = " ".repeat(Math.max(0, leftMax - used) + 1);
return `${left}${padding}${right}`;
}
function agentSymbol(status: HandoffRecord["status"]): string {
const group = groupHandoffStatus(status);
if (group === "running") return "🤖";
if (group === "idle") return "💬";
if (group === "error") return "⚠";
if (group === "queued") return "◌";
return "-";
}
function toDisplayRow(row: HandoffRecord): DisplayRow {
const conflictPrefix = row.conflictsWithMain === "true" ? "\u26A0 " : "";
const prLabel = row.prUrl
? `#${row.prUrl.match(/\/pull\/(\d+)/)?.[1] ?? "?"}`
: row.prSubmitted ? "sub" : "-";
const ciLabel = row.ciStatus ?? "-";
const reviewLabel = row.reviewStatus
? row.reviewStatus === "approved" ? "ok"
: row.reviewStatus === "changes_requested" ? "chg"
: row.reviewStatus === "pending" ? "..." : row.reviewStatus
: "-";
return {
name: `${conflictPrefix}${row.title || row.branchName}`,
diff: row.diffStat ?? "-",
agent: agentSymbol(row.status),
pr: prLabel,
author: row.prAuthor ?? "-",
ci: ciLabel,
review: reviewLabel,
age: formatRelativeAge(row.updatedAt)
};
}
function helpLines(width: number): string[] {
const popupWidth = Math.max(40, Math.min(width - 2, 100));
const innerWidth = Math.max(2, popupWidth - 2);
const borderTop = `${"─".repeat(innerWidth)}`;
const borderBottom = `${"─".repeat(innerWidth)}`;
const lines = [borderTop];
for (const line of HELP_LINES) {
lines.push(`${pad(line, innerWidth)}`);
}
lines.push(borderBottom);
return lines;
}
export function formatRows(
rows: HandoffRecord[],
selected: number,
workspaceId: string,
status: string,
searchQuery = "",
showHelp = false,
options: RenderOptions = {}
): string {
const totalWidth = options.width ?? process.stdout.columns ?? 120;
const totalHeight = Math.max(6, options.height ?? process.stdout.rows ?? 24);
const fixedWidth =
COLUMN_WIDTHS.diff +
COLUMN_WIDTHS.agent +
COLUMN_WIDTHS.pr +
COLUMN_WIDTHS.author +
COLUMN_WIDTHS.ci +
COLUMN_WIDTHS.review +
COLUMN_WIDTHS.age;
const separators = 7;
const prefixWidth = 2;
const branchWidth = Math.max(20, totalWidth - (fixedWidth + separators + prefixWidth));
const branchHeader = searchQuery ? `Branch/PR: ${searchQuery}_` : "Branch/PR (type to filter)";
const header = [
` ${pad(branchHeader, branchWidth)} ${pad("Diff", COLUMN_WIDTHS.diff)} ${pad("Agent", COLUMN_WIDTHS.agent)} ${pad("PR", COLUMN_WIDTHS.pr)} ${pad("Author", COLUMN_WIDTHS.author)} ${pad("CI", COLUMN_WIDTHS.ci)} ${pad("Review", COLUMN_WIDTHS.review)} ${pad("Age", COLUMN_WIDTHS.age)}`,
"-".repeat(Math.max(24, Math.min(totalWidth, 180)))
];
const body =
rows.length === 0
? ["No branches found."]
: rows.map((row, index) => {
const marker = index === selected ? "┃ " : " ";
const display = toDisplayRow(row);
return `${marker}${pad(display.name, branchWidth)} ${pad(display.diff, COLUMN_WIDTHS.diff)} ${pad(display.agent, COLUMN_WIDTHS.agent)} ${pad(display.pr, COLUMN_WIDTHS.pr)} ${pad(display.author, COLUMN_WIDTHS.author)} ${pad(display.ci, COLUMN_WIDTHS.ci)} ${pad(display.review, COLUMN_WIDTHS.review)} ${pad(display.age, COLUMN_WIDTHS.age)}`;
});
const footer = fitLine(
buildFooterLine(
totalWidth,
["Ctrl-H:cheatsheet", `workspace:${workspaceId}`, status],
`v${CLI_BUILD_ID}`
),
totalWidth,
);
const contentHeight = totalHeight - 1;
const lines = [...header, ...body].map((line) => fitLine(line, totalWidth));
const page = lines.slice(0, contentHeight);
while (page.length < contentHeight) {
page.push(" ".repeat(totalWidth));
}
if (showHelp) {
const popup = helpLines(totalWidth);
const startRow = Math.max(0, Math.floor((contentHeight - popup.length) / 2));
for (let i = 0; i < popup.length; i += 1) {
const target = startRow + i;
if (target >= page.length) {
break;
}
const popupLine = popup[i] ?? "";
const popupLen = Array.from(popupLine).length;
const startCol = Math.max(0, Math.floor((totalWidth - popupLen) / 2));
page[target] = overlayLine(page[target] ?? "", popupLine, startCol, totalWidth);
}
}
return [...page, footer].join("\n");
}
interface OpenTuiLike {
createCliRenderer?: (options?: Record<string, unknown>) => Promise<any>;
TextRenderable?: new (ctx: any, options: { id: string; content: string }) => {
content: unknown;
fg?: string;
bg?: string;
};
fg?: (color: string) => (input: unknown) => unknown;
bg?: (color: string) => (input: unknown) => unknown;
StyledText?: new (chunks: unknown[]) => unknown;
}
interface StyledTextApi {
fg: (color: string) => (input: unknown) => unknown;
bg: (color: string) => (input: unknown) => unknown;
StyledText: new (chunks: unknown[]) => unknown;
}
function buildStyledContent(content: string, theme: TuiTheme, api: StyledTextApi): unknown {
const lines = content.split("\n");
const chunks: unknown[] = [];
const footerIndex = Math.max(0, lines.length - 1);
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i] ?? "";
let fgColor = theme.text;
let bgColor: string | undefined;
if (line.startsWith("┃ ")) {
const marker = "┃ ";
const rest = line.slice(marker.length);
bgColor = theme.highlightBg;
const markerChunk = api.bg(bgColor)(api.fg(theme.selectionBorder)(marker));
const restChunk = api.bg(bgColor)(api.fg(theme.highlightFg)(rest));
chunks.push(markerChunk);
chunks.push(restChunk);
if (i < lines.length - 1) {
chunks.push(api.fg(theme.text)("\n"));
}
continue;
}
if (i === 0) {
fgColor = theme.header;
} else if (i === 1) {
fgColor = theme.muted;
} else if (i === footerIndex) {
fgColor = theme.status;
} else if (line.startsWith("┌") || line.startsWith("│") || line.startsWith("└")) {
fgColor = theme.info;
}
let chunk: unknown = api.fg(fgColor)(line);
if (bgColor) {
chunk = api.bg(bgColor)(chunk);
}
chunks.push(chunk);
if (i < lines.length - 1) {
chunks.push(api.fg(theme.text)("\n"));
}
}
return new api.StyledText(chunks);
}
export async function runTui(config: AppConfig, workspaceId: string): Promise<void> {
const core = (await import("@opentui/core")) as OpenTuiLike;
const createCliRenderer = core.createCliRenderer;
const TextRenderable = core.TextRenderable;
const styleApi =
core.fg && core.bg && core.StyledText
? { fg: core.fg, bg: core.bg, StyledText: core.StyledText }
: null;
if (!createCliRenderer || !TextRenderable) {
throw new Error("OpenTUI runtime missing createCliRenderer/TextRenderable exports");
}
const themeResolution = resolveTuiTheme(config);
const client = createBackendClientFromConfig(config);
const renderer = await createCliRenderer({ exitOnCtrlC: false });
const text = new TextRenderable(renderer, {
id: "factory-switch",
content: "Loading..."
});
text.fg = themeResolution.theme.text;
text.bg = themeResolution.theme.background;
renderer.root.add(text);
renderer.start();
let allRows: HandoffRecord[] = [];
let filteredRows: HandoffRecord[] = [];
let selected = 0;
let searchQuery = "";
let showHelp = false;
let status = "loading...";
let busy = false;
let closed = false;
let timer: ReturnType<typeof setInterval> | null = null;
const clampSelected = (): void => {
if (filteredRows.length === 0) {
selected = 0;
return;
}
if (selected < 0) {
selected = 0;
return;
}
if (selected >= filteredRows.length) {
selected = filteredRows.length - 1;
}
};
const render = (): void => {
if (closed) {
return;
}
const output = formatRows(filteredRows, selected, workspaceId, status, searchQuery, showHelp, {
width: renderer.width ?? process.stdout.columns,
height: renderer.height ?? process.stdout.rows
});
text.content = styleApi
? buildStyledContent(output, themeResolution.theme, styleApi)
: output;
renderer.requestRender();
};
const refresh = async (): Promise<void> => {
if (closed) {
return;
}
try {
allRows = await client.listHandoffs(workspaceId);
if (closed) {
return;
}
filteredRows = filterHandoffs(allRows, searchQuery);
clampSelected();
status = `handoffs=${allRows.length} filtered=${filteredRows.length}`;
} catch (err) {
if (closed) {
return;
}
status = err instanceof Error ? err.message : String(err);
}
render();
};
const selectedRow = (): HandoffRecord | null => {
if (filteredRows.length === 0) {
return null;
}
return filteredRows[selected] ?? null;
};
let resolveDone: () => void = () => {};
const done = new Promise<void>((resolve) => {
resolveDone = () => resolve();
});
const close = (output?: string): void => {
if (closed) {
return;
}
closed = true;
if (timer) {
clearInterval(timer);
timer = null;
}
process.off("SIGINT", handleSignal);
process.off("SIGTERM", handleSignal);
renderer.destroy();
if (output) {
console.log(output);
}
resolveDone();
};
const handleSignal = (): void => {
close();
};
const runActionWithRefresh = async (
label: string,
fn: () => Promise<void>,
success: string
): Promise<void> => {
if (busy) {
return;
}
busy = true;
status = `${label}...`;
render();
try {
await fn();
status = success;
await refresh();
} catch (err) {
status = err instanceof Error ? err.message : String(err);
render();
} finally {
busy = false;
}
};
await refresh();
timer = setInterval(() => {
void refresh();
}, 10_000);
process.once("SIGINT", handleSignal);
process.once("SIGTERM", handleSignal);
const keyInput = (renderer.keyInput ?? renderer.keyHandler) as
| { on: (name: string, cb: (event: KeyEventLike) => void) => void }
| undefined;
if (!keyInput) {
clearInterval(timer);
renderer.destroy();
throw new Error("OpenTUI key input handler is unavailable");
}
keyInput.on("keypress", (event: KeyEventLike) => {
if (closed) {
return;
}
const name = event.name ?? "";
const ctrl = Boolean(event.ctrl);
if (ctrl && name === "h") {
showHelp = !showHelp;
render();
return;
}
if (showHelp) {
if (name === "escape") {
showHelp = false;
render();
}
return;
}
if (name === "q" || name === "escape" || (ctrl && name === "c")) {
close();
return;
}
if ((ctrl && name === "n") || name === "down") {
if (filteredRows.length > 0) {
selected = selected >= filteredRows.length - 1 ? 0 : selected + 1;
render();
}
return;
}
if ((ctrl && name === "p") || name === "up") {
if (filteredRows.length > 0) {
selected = selected <= 0 ? filteredRows.length - 1 : selected - 1;
render();
}
return;
}
if (name === "backspace") {
searchQuery = searchQuery.slice(0, -1);
filteredRows = filterHandoffs(allRows, searchQuery);
selected = 0;
render();
return;
}
if (name === "return" || name === "enter") {
const row = selectedRow();
if (!row || busy) {
return;
}
busy = true;
status = `switching ${row.handoffId}...`;
render();
void (async () => {
try {
const result = await client.switchHandoff(workspaceId, row.handoffId);
close(`cd ${result.switchTarget}`);
} catch (err) {
busy = false;
status = err instanceof Error ? err.message : String(err);
render();
}
})();
return;
}
if (ctrl && name === "a") {
const row = selectedRow();
if (!row || busy) {
return;
}
busy = true;
status = `attaching ${row.handoffId}...`;
render();
void (async () => {
try {
const result = await client.attachHandoff(workspaceId, row.handoffId);
close(`target=${result.target} session=${result.sessionId ?? "none"}`);
} catch (err) {
busy = false;
status = err instanceof Error ? err.message : String(err);
render();
}
})();
return;
}
if (ctrl && name === "x") {
const row = selectedRow();
if (!row) {
return;
}
void runActionWithRefresh(
`archiving ${row.handoffId}`,
async () => client.runAction(workspaceId, row.handoffId, "archive"),
`archived ${row.handoffId}`
);
return;
}
if (ctrl && name === "s") {
const row = selectedRow();
if (!row) {
return;
}
void runActionWithRefresh(
`syncing ${row.handoffId}`,
async () => client.runAction(workspaceId, row.handoffId, "sync"),
`synced ${row.handoffId}`
);
return;
}
if (ctrl && name === "y") {
const row = selectedRow();
if (!row) {
return;
}
void runActionWithRefresh(
`merging ${row.handoffId}`,
async () => {
await client.runAction(workspaceId, row.handoffId, "merge");
await client.runAction(workspaceId, row.handoffId, "archive");
},
`merged+archived ${row.handoffId}`
);
return;
}
if (ctrl && name === "o") {
const row = selectedRow();
if (!row?.prUrl) {
status = "no PR URL available for this handoff";
render();
return;
}
const openCmd = process.platform === "darwin" ? "open" : "xdg-open";
spawnSync(openCmd, [row.prUrl], { stdio: "ignore" });
status = `opened ${row.prUrl}`;
render();
return;
}
if (!ctrl && !event.meta && name.length === 1) {
searchQuery += name;
filteredRows = filterHandoffs(allRows, searchQuery);
selected = 0;
render();
}
});
await done;
}

View file

@ -1,25 +0,0 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
import { homedir } from "node:os";
import * as toml from "@iarna/toml";
import { ConfigSchema, resolveWorkspaceId, type AppConfig } from "@sandbox-agent/factory-shared";
export const CONFIG_PATH = `${homedir()}/.config/sandbox-agent-factory/config.toml`;
export function loadConfig(path = CONFIG_PATH): AppConfig {
if (!existsSync(path)) {
return ConfigSchema.parse({});
}
const raw = readFileSync(path, "utf8");
return ConfigSchema.parse(toml.parse(raw));
}
export function saveConfig(config: AppConfig, path = CONFIG_PATH): void {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, toml.stringify(config), "utf8");
}
export function resolveWorkspace(flagWorkspace: string | undefined, config: AppConfig): string {
return resolveWorkspaceId(flagWorkspace, config);
}

View file

@ -1,167 +0,0 @@
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { EventEmitter } from "node:events";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ChildProcess } from "node:child_process";
const { spawnMock, execFileSyncMock } = vi.hoisted(() => ({
spawnMock: vi.fn(),
execFileSyncMock: vi.fn()
}));
vi.mock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
return {
...actual,
spawn: spawnMock,
execFileSync: execFileSyncMock
};
});
import { ensureBackendRunning, parseBackendPort } from "../src/backend/manager.js";
import { ConfigSchema, type AppConfig } from "@sandbox-agent/factory-shared";
function backendStateFile(baseDir: string, host: string, port: number, suffix: string): string {
const sanitized = host
.split("")
.map((ch) => (/[a-zA-Z0-9]/.test(ch) ? ch : "-"))
.join("");
return join(baseDir, `backend-${sanitized}-${port}.${suffix}`);
}
function healthyMetadataResponse(): { ok: boolean; json: () => Promise<unknown> } {
return {
ok: true,
json: async () => ({
runtime: "rivetkit",
actorNames: {
workspace: {}
}
})
};
}
function unhealthyMetadataResponse(): { ok: boolean; json: () => Promise<unknown> } {
return {
ok: false,
json: async () => ({})
};
}
describe("backend manager", () => {
const originalFetch = globalThis.fetch;
const originalStateDir = process.env.HF_BACKEND_STATE_DIR;
const originalBuildId = process.env.HF_BUILD_ID;
const config: AppConfig = ConfigSchema.parse({
auto_submit: true,
notify: ["terminal"],
workspace: { default: "default" },
backend: {
host: "127.0.0.1",
port: 7741,
dbPath: "~/.local/share/sandbox-agent-factory/handoff.db",
opencode_poll_interval: 2,
github_poll_interval: 30,
backup_interval_secs: 3600,
backup_retention_days: 7
},
providers: {
daytona: { image: "ubuntu:24.04" }
}
});
beforeEach(() => {
process.env.HF_BUILD_ID = "test-build";
});
afterEach(() => {
vi.restoreAllMocks();
spawnMock.mockReset();
execFileSyncMock.mockReset();
globalThis.fetch = originalFetch;
if (originalStateDir === undefined) {
delete process.env.HF_BACKEND_STATE_DIR;
} else {
process.env.HF_BACKEND_STATE_DIR = originalStateDir;
}
if (originalBuildId === undefined) {
delete process.env.HF_BUILD_ID;
} else {
process.env.HF_BUILD_ID = originalBuildId;
}
});
it("restarts backend when healthy but build is outdated", async () => {
const stateDir = mkdtempSync(join(tmpdir(), "hf-backend-test-"));
process.env.HF_BACKEND_STATE_DIR = stateDir;
const pidPath = backendStateFile(stateDir, config.backend.host, config.backend.port, "pid");
const versionPath = backendStateFile(stateDir, config.backend.host, config.backend.port, "version");
mkdirSync(stateDir, { recursive: true });
writeFileSync(pidPath, "999999", "utf8");
writeFileSync(versionPath, "old-build", "utf8");
const fetchMock = vi
.fn<() => Promise<{ ok: boolean; json: () => Promise<unknown> }>>()
.mockResolvedValueOnce(healthyMetadataResponse())
.mockResolvedValueOnce(unhealthyMetadataResponse())
.mockResolvedValue(healthyMetadataResponse());
globalThis.fetch = fetchMock as unknown as typeof fetch;
const fakeChild = Object.assign(new EventEmitter(), {
pid: process.pid,
unref: vi.fn()
}) as unknown as ChildProcess;
spawnMock.mockReturnValue(fakeChild);
await ensureBackendRunning(config);
expect(spawnMock).toHaveBeenCalledTimes(1);
const launchCommand = spawnMock.mock.calls[0]?.[0];
const launchArgs = spawnMock.mock.calls[0]?.[1] as string[] | undefined;
expect(
launchCommand === "pnpm" ||
launchCommand === "bun" ||
(typeof launchCommand === "string" && launchCommand.endsWith("/bun"))
).toBe(true);
expect(launchArgs).toEqual(
expect.arrayContaining(["start", "--host", config.backend.host, "--port", String(config.backend.port)])
);
if (launchCommand === "pnpm") {
expect(launchArgs).toEqual(expect.arrayContaining(["exec", "bun", "src/index.ts"]));
}
expect(readFileSync(pidPath, "utf8").trim()).toBe(String(process.pid));
expect(readFileSync(versionPath, "utf8").trim()).toBe("test-build");
});
it("does not restart when backend is healthy and build is current", async () => {
const stateDir = mkdtempSync(join(tmpdir(), "hf-backend-test-"));
process.env.HF_BACKEND_STATE_DIR = stateDir;
const versionPath = backendStateFile(stateDir, config.backend.host, config.backend.port, "version");
mkdirSync(stateDir, { recursive: true });
writeFileSync(versionPath, "test-build", "utf8");
const fetchMock = vi
.fn<() => Promise<{ ok: boolean; json: () => Promise<unknown> }>>()
.mockResolvedValue(healthyMetadataResponse());
globalThis.fetch = fetchMock as unknown as typeof fetch;
await ensureBackendRunning(config);
expect(spawnMock).not.toHaveBeenCalled();
});
it("validates backend port parsing", () => {
expect(parseBackendPort(undefined, 7741)).toBe(7741);
expect(parseBackendPort("8080", 7741)).toBe(8080);
expect(() => parseBackendPort("0", 7741)).toThrow("Invalid backend port");
expect(() => parseBackendPort("abc", 7741)).toThrow("Invalid backend port");
});
});

View file

@ -1,26 +0,0 @@
import { describe, expect, it } from "vitest";
import { sanitizeEditorTask } from "../src/task-editor.js";
describe("task editor helpers", () => {
it("strips comment lines and trims whitespace", () => {
const value = sanitizeEditorTask(`
# comment
Implement feature
# another comment
with more detail
`);
expect(value).toBe("Implement feature\n\nwith more detail");
});
it("returns empty string when only comments are present", () => {
const value = sanitizeEditorTask(`
# hello
# world
`);
expect(value).toBe("");
});
});

View file

@ -1,112 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { ConfigSchema, type AppConfig } from "@sandbox-agent/factory-shared";
import { resolveTuiTheme } from "../src/theme.js";
function withEnv(key: string, value: string | undefined): void {
if (value === undefined) {
delete process.env[key];
return;
}
process.env[key] = value;
}
describe("resolveTuiTheme", () => {
let tempDir: string | null = null;
const originalState = process.env.XDG_STATE_HOME;
const originalConfig = process.env.XDG_CONFIG_HOME;
const baseConfig: AppConfig = ConfigSchema.parse({
auto_submit: true,
notify: ["terminal"],
workspace: { default: "default" },
backend: {
host: "127.0.0.1",
port: 7741,
dbPath: "~/.local/share/sandbox-agent-factory/handoff.db",
opencode_poll_interval: 2,
github_poll_interval: 30,
backup_interval_secs: 3600,
backup_retention_days: 7
},
providers: {
daytona: { image: "ubuntu:24.04" }
}
});
afterEach(() => {
withEnv("XDG_STATE_HOME", originalState);
withEnv("XDG_CONFIG_HOME", originalConfig);
if (tempDir) {
rmSync(tempDir, { recursive: true, force: true });
tempDir = null;
}
});
it("falls back to default theme when no theme sources are present", () => {
tempDir = mkdtempSync(join(tmpdir(), "hf-theme-test-"));
withEnv("XDG_STATE_HOME", join(tempDir, "state"));
withEnv("XDG_CONFIG_HOME", join(tempDir, "config"));
const resolution = resolveTuiTheme(baseConfig, tempDir);
expect(resolution.name).toBe("opencode-default");
expect(resolution.source).toBe("default");
expect(resolution.theme.text).toBe("#ffffff");
});
it("loads theme from opencode state when configured", () => {
tempDir = mkdtempSync(join(tmpdir(), "hf-theme-test-"));
const stateHome = join(tempDir, "state");
const configHome = join(tempDir, "config");
withEnv("XDG_STATE_HOME", stateHome);
withEnv("XDG_CONFIG_HOME", configHome);
mkdirSync(join(stateHome, "opencode"), { recursive: true });
writeFileSync(
join(stateHome, "opencode", "kv.json"),
JSON.stringify({ theme: "gruvbox", theme_mode: "dark" }),
"utf8"
);
const resolution = resolveTuiTheme(baseConfig, tempDir);
expect(resolution.name).toBe("gruvbox");
expect(resolution.source).toContain("opencode state");
expect(resolution.mode).toBe("dark");
expect(resolution.theme.selectionBorder.toLowerCase()).not.toContain("dark");
});
it("resolves OpenCode token references in theme defs", () => {
tempDir = mkdtempSync(join(tmpdir(), "hf-theme-test-"));
const stateHome = join(tempDir, "state");
const configHome = join(tempDir, "config");
withEnv("XDG_STATE_HOME", stateHome);
withEnv("XDG_CONFIG_HOME", configHome);
mkdirSync(join(stateHome, "opencode"), { recursive: true });
writeFileSync(
join(stateHome, "opencode", "kv.json"),
JSON.stringify({ theme: "orng", theme_mode: "dark" }),
"utf8"
);
const resolution = resolveTuiTheme(baseConfig, tempDir);
expect(resolution.name).toBe("orng");
expect(resolution.theme.selectionBorder).toBe("#EE7948");
expect(resolution.theme.background).toBe("#0a0a0a");
});
it("prefers explicit factory theme override from config", () => {
tempDir = mkdtempSync(join(tmpdir(), "hf-theme-test-"));
withEnv("XDG_STATE_HOME", join(tempDir, "state"));
withEnv("XDG_CONFIG_HOME", join(tempDir, "config"));
const config = { ...baseConfig, theme: "default" } as AppConfig & { theme: string };
const resolution = resolveTuiTheme(config, tempDir);
expect(resolution.name).toBe("opencode-default");
expect(resolution.source).toBe("factory config");
});
});

View file

@ -1,10 +0,0 @@
import { describe, expect, it } from "vitest";
import { stripStatusPrefix } from "../src/tmux.js";
describe("tmux helpers", () => {
it("strips running and idle markers from window names", () => {
expect(stripStatusPrefix("▶ feature/auth")).toBe("feature/auth");
expect(stripStatusPrefix("✓ feature/auth")).toBe("feature/auth");
expect(stripStatusPrefix("feature/auth")).toBe("feature/auth");
});
});

View file

@ -1,93 +0,0 @@
import { describe, expect, it } from "vitest";
import type { HandoffRecord } from "@sandbox-agent/factory-shared";
import { filterHandoffs, fuzzyMatch } from "@sandbox-agent/factory-client";
import { formatRows } from "../src/tui.js";
const sample: HandoffRecord = {
workspaceId: "default",
repoId: "repo-a",
repoRemote: "https://example.com/repo-a.git",
handoffId: "handoff-1",
branchName: "feature/test",
title: "Test Title",
task: "Do test",
providerId: "daytona",
status: "running",
statusMessage: null,
activeSandboxId: "sandbox-1",
activeSessionId: "session-1",
sandboxes: [
{
sandboxId: "sandbox-1",
providerId: "daytona",
switchTarget: "daytona://sandbox-1",
cwd: null,
createdAt: 1,
updatedAt: 1
}
],
agentType: null,
prSubmitted: false,
diffStat: null,
prUrl: null,
prAuthor: null,
ciStatus: null,
reviewStatus: null,
reviewer: null,
conflictsWithMain: null,
hasUnpushed: null,
parentBranch: null,
createdAt: 1,
updatedAt: 1
};
describe("formatRows", () => {
it("renders rust-style table header and empty state", () => {
const output = formatRows([], 0, "default", "ok");
expect(output).toContain("Branch/PR (type to filter)");
expect(output).toContain("No branches found.");
expect(output).toContain("Ctrl-H:cheatsheet");
expect(output).toContain("ok");
});
it("marks selected row with highlight", () => {
const output = formatRows([sample], 0, "default", "ready");
expect(output).toContain("┃ ");
expect(output).toContain("Test Title");
expect(output).toContain("Ctrl-H:cheatsheet");
});
it("pins footer to the last terminal row", () => {
const output = formatRows([sample], 0, "default", "ready", "", false, {
width: 80,
height: 12
});
const lines = output.split("\n");
expect(lines).toHaveLength(12);
expect(lines[11]).toContain("Ctrl-H:cheatsheet");
expect(lines[11]).toContain("v");
});
});
describe("search", () => {
it("supports ordered fuzzy matching", () => {
expect(fuzzyMatch("feature/test-branch", "ftb")).toBe(true);
expect(fuzzyMatch("feature/test-branch", "fbt")).toBe(false);
});
it("filters rows across branch and title", () => {
const rows: HandoffRecord[] = [
sample,
{
...sample,
handoffId: "handoff-2",
branchName: "docs/update-intro",
title: "Docs Intro Refresh",
status: "idle"
}
];
expect(filterHandoffs(rows, "doc")).toHaveLength(1);
expect(filterHandoffs(rows, "h2")).toHaveLength(1);
expect(filterHandoffs(rows, "test")).toHaveLength(2);
});
});

View file

@ -1,28 +0,0 @@
import { describe, expect, it } from "vitest";
import { ConfigSchema } from "@sandbox-agent/factory-shared";
import { resolveWorkspace } from "../src/workspace/config.js";
describe("cli workspace resolution", () => {
it("uses default workspace when no flag", () => {
const config = ConfigSchema.parse({
auto_submit: true as const,
notify: ["terminal" as const],
workspace: { default: "team" },
backend: {
host: "127.0.0.1",
port: 7741,
dbPath: "~/.local/share/sandbox-agent-factory/handoff.db",
opencode_poll_interval: 2,
github_poll_interval: 30,
backup_interval_secs: 3600,
backup_retention_days: 7
},
providers: {
daytona: { image: "ubuntu:24.04" }
}
});
expect(resolveWorkspace(undefined, config)).toBe("team");
expect(resolveWorkspace("alpha", config)).toBe("alpha");
});
});

View file

@ -1,7 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist"
},
"include": ["src", "test"]
}

View file

@ -1,54 +0,0 @@
import { execSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { defineConfig } from "tsup";
function packageVersion(): string {
try {
const packageJsonPath = resolve(process.cwd(), "package.json");
const parsed = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { version?: unknown };
if (typeof parsed.version === "string" && parsed.version.trim()) {
return parsed.version.trim();
}
} catch {
// Fall through.
}
return "dev";
}
function sourceId(): string {
try {
const raw = execSync("git rev-parse --short HEAD", {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"]
}).trim();
if (raw.length > 0) {
return raw;
}
} catch {
// Fall through.
}
return packageVersion();
}
function resolveBuildId(): string {
const override = process.env.HF_BUILD_ID?.trim();
if (override) {
return override;
}
// Match sandbox-agent semantics: source id + unique build timestamp.
return `${sourceId()}-${Date.now().toString()}`;
}
const buildId = resolveBuildId();
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm"],
dts: true,
define: {
__HF_BUILD_ID__: JSON.stringify(buildId)
}
});

Some files were not shown because too many files have changed in this diff Show more