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(