mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-16 22:03:52 +00:00
factory: rename project and handoff actors
This commit is contained in:
parent
3022bce2ad
commit
ea7c36a8e7
147 changed files with 6313 additions and 14364 deletions
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
|
||||
|
|
@ -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),
|
||||
});
|
||||
|
|
@ -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)
|
||||
});
|
||||
|
|
@ -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)
|
||||
});
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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),
|
||||
|
|
@ -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",
|
||||
});
|
||||
|
||||
|
|
@ -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,
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
28
factory/packages/backend/src/actors/repo/index.ts
Normal file
28
factory/packages/backend/src/actors/repo/index.ts
Normal 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),
|
||||
});
|
||||
|
|
@ -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)
|
||||
});
|
||||
|
|
@ -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),
|
||||
|
|
@ -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",
|
||||
});
|
||||
|
||||
|
|
@ -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,
|
||||
|
|
@ -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(),
|
||||
400
factory/packages/backend/src/actors/task/index.ts
Normal file
400
factory/packages/backend/src/actors/task/index.ts
Normal 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 };
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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
|
||||
});
|
||||
|
|
@ -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
|
||||
31
factory/packages/backend/src/actors/task/workflow/queue.ts
Normal file
31
factory/packages/backend/src/actors/task/workflow/queue.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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
|
||||
});
|
||||
|
|
@ -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 });
|
||||
}
|
||||
};
|
||||
|
|
|
|||
1472
factory/packages/backend/src/actors/workspace/app-shell.ts
Normal file
1472
factory/packages/backend/src/actors/workspace/app-shell.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,4 +1,4 @@
|
|||
CREATE TABLE `handoff_lookup` (
|
||||
CREATE TABLE `task_lookup` (
|
||||
`handoff_id` text PRIMARY KEY NOT NULL,
|
||||
`repo_id` text NOT NULL
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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/")
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
503
factory/packages/backend/src/services/app-github.ts
Normal file
503
factory/packages/backend/src/services/app-github.ts
Normal 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, "");
|
||||
}
|
||||
68
factory/packages/backend/src/services/app-shell-runtime.ts
Normal file
68
factory/packages/backend/src/services/app-shell-runtime.ts
Normal 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,
|
||||
};
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
308
factory/packages/backend/src/services/app-stripe.ts
Normal file
308
factory/packages/backend/src/services/app-stripe.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
248
factory/packages/backend/test/app-state.test.ts
Normal file
248
factory/packages/backend/test/app-state.test.ts
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { setupTest } from "rivetkit/test";
|
||||
import { registry } from "../src/actors/index.js";
|
||||
import { workspaceKey } from "../src/actors/keys.js";
|
||||
import { APP_SHELL_WORKSPACE_ID } from "../src/actors/workspace/app-shell.js";
|
||||
import { createTestRuntimeContext } from "./helpers/test-context.js";
|
||||
import { createTestDriver } from "./helpers/test-driver.js";
|
||||
|
||||
function createGithubService(overrides?: Record<string, unknown>) {
|
||||
return {
|
||||
isAppConfigured: () => true,
|
||||
isWebhookConfigured: () => false,
|
||||
verifyWebhookEvent: () => { throw new Error("GitHub webhook not configured in test"); },
|
||||
buildAuthorizeUrl: (state: string) => `https://github.example/login/oauth/authorize?state=${encodeURIComponent(state)}`,
|
||||
exchangeCode: async () => ({
|
||||
accessToken: "gho_live",
|
||||
scopes: ["read:user", "user:email", "read:org"],
|
||||
}),
|
||||
getViewer: async () => ({
|
||||
id: "1001",
|
||||
login: "nathan",
|
||||
name: "Nathan",
|
||||
email: "nathan@acme.dev",
|
||||
}),
|
||||
listOrganizations: async () => [
|
||||
{
|
||||
id: "2001",
|
||||
login: "acme",
|
||||
name: "Acme",
|
||||
},
|
||||
],
|
||||
listInstallations: async () => [
|
||||
{
|
||||
id: 3001,
|
||||
accountLogin: "acme",
|
||||
},
|
||||
],
|
||||
listUserRepositories: async () => [
|
||||
{
|
||||
fullName: "nathan/personal-site",
|
||||
cloneUrl: "https://github.com/nathan/personal-site.git",
|
||||
private: false,
|
||||
},
|
||||
],
|
||||
listInstallationRepositories: async () => [
|
||||
{
|
||||
fullName: "acme/backend",
|
||||
cloneUrl: "https://github.com/acme/backend.git",
|
||||
private: true,
|
||||
},
|
||||
{
|
||||
fullName: "acme/frontend",
|
||||
cloneUrl: "https://github.com/acme/frontend.git",
|
||||
private: false,
|
||||
},
|
||||
],
|
||||
buildInstallationUrl: async () => "https://github.example/apps/sandbox/installations/new",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createStripeService(overrides?: Record<string, unknown>) {
|
||||
return {
|
||||
isConfigured: () => false,
|
||||
createCustomer: async () => ({ id: "cus_test" }),
|
||||
createCheckoutSession: async () => ({ id: "cs_test", url: "https://billing.example/checkout/cs_test" }),
|
||||
retrieveCheckoutCompletion: async () => ({
|
||||
customerId: "cus_test",
|
||||
subscriptionId: "sub_test",
|
||||
planId: "team" as const,
|
||||
paymentMethodLabel: "Visa ending in 4242",
|
||||
}),
|
||||
retrieveSubscription: async () => ({
|
||||
id: "sub_test",
|
||||
customerId: "cus_test",
|
||||
priceId: "price_team",
|
||||
status: "active",
|
||||
cancelAtPeriodEnd: false,
|
||||
currentPeriodEnd: 1741564800,
|
||||
trialEnd: null,
|
||||
defaultPaymentMethodLabel: "Visa ending in 4242",
|
||||
}),
|
||||
createPortalSession: async () => ({ url: "https://billing.example/portal" }),
|
||||
updateSubscriptionCancellation: async (_subscriptionId: string, cancelAtPeriodEnd: boolean) => ({
|
||||
id: "sub_test",
|
||||
customerId: "cus_test",
|
||||
priceId: "price_team",
|
||||
status: "active",
|
||||
cancelAtPeriodEnd,
|
||||
currentPeriodEnd: 1741564800,
|
||||
trialEnd: null,
|
||||
defaultPaymentMethodLabel: "Visa ending in 4242",
|
||||
}),
|
||||
verifyWebhookEvent: (payload: string) => JSON.parse(payload),
|
||||
planIdForPriceId: (priceId: string) => (priceId === "price_team" ? ("team" as const) : null),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function getAppWorkspace(client: any) {
|
||||
return await client.workspace.getOrCreate(workspaceKey(APP_SHELL_WORKSPACE_ID), {
|
||||
createWithInput: APP_SHELL_WORKSPACE_ID,
|
||||
});
|
||||
}
|
||||
|
||||
describe("app shell actors", () => {
|
||||
it("restores a GitHub session and imports repos into actor-owned workspace state", async (t) => {
|
||||
createTestRuntimeContext(createTestDriver(), undefined, {
|
||||
appUrl: "http://localhost:4173",
|
||||
github: createGithubService(),
|
||||
stripe: createStripeService(),
|
||||
});
|
||||
|
||||
const { client } = await setupTest(t, registry);
|
||||
const app = await getAppWorkspace(client);
|
||||
|
||||
const { sessionId } = await app.ensureAppSession({});
|
||||
const authStart = await app.startAppGithubAuth({ sessionId });
|
||||
const state = new URL(authStart.url).searchParams.get("state");
|
||||
expect(state).toBeTruthy();
|
||||
|
||||
const callback = await app.completeAppGithubAuth({
|
||||
code: "oauth-code",
|
||||
state,
|
||||
});
|
||||
expect(callback.redirectTo).toContain("factorySession=");
|
||||
|
||||
const snapshot = await app.selectAppOrganization({
|
||||
sessionId,
|
||||
organizationId: "acme",
|
||||
});
|
||||
|
||||
expect(snapshot.auth.status).toBe("signed_in");
|
||||
expect(snapshot.activeOrganizationId).toBe("acme");
|
||||
expect(snapshot.users[0]?.githubLogin).toBe("nathan");
|
||||
expect(snapshot.organizations.map((organization: any) => organization.id)).toEqual(["personal-nathan", "acme"]);
|
||||
|
||||
const acme = snapshot.organizations.find((organization: any) => organization.id === "acme");
|
||||
expect(acme.github.syncStatus).toBe("synced");
|
||||
expect(acme.github.installationStatus).toBe("connected");
|
||||
expect(acme.repoCatalog).toEqual(["acme/backend", "acme/frontend"]);
|
||||
|
||||
const orgWorkspace = await client.workspace.getOrCreate(workspaceKey("acme"), {
|
||||
createWithInput: "acme",
|
||||
});
|
||||
const repos = await orgWorkspace.listRepos({ workspaceId: "acme" });
|
||||
expect(repos.map((repo: any) => repo.remoteUrl).sort()).toEqual([
|
||||
"https://github.com/acme/backend.git",
|
||||
"https://github.com/acme/frontend.git",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps install-required orgs in actor state when the GitHub App installation is missing", async (t) => {
|
||||
createTestRuntimeContext(createTestDriver(), undefined, {
|
||||
appUrl: "http://localhost:4173",
|
||||
github: createGithubService({
|
||||
listInstallations: async () => [],
|
||||
}),
|
||||
stripe: createStripeService(),
|
||||
});
|
||||
|
||||
const { client } = await setupTest(t, registry);
|
||||
const app = await getAppWorkspace(client);
|
||||
|
||||
const { sessionId } = await app.ensureAppSession({});
|
||||
const authStart = await app.startAppGithubAuth({ sessionId });
|
||||
const state = new URL(authStart.url).searchParams.get("state");
|
||||
await app.completeAppGithubAuth({
|
||||
code: "oauth-code",
|
||||
state,
|
||||
});
|
||||
|
||||
const snapshot = await app.triggerAppRepoImport({
|
||||
sessionId,
|
||||
organizationId: "acme",
|
||||
});
|
||||
|
||||
const acme = snapshot.organizations.find((organization: any) => organization.id === "acme");
|
||||
expect(acme.github.installationStatus).toBe("install_required");
|
||||
expect(acme.github.syncStatus).toBe("error");
|
||||
expect(acme.github.lastSyncLabel).toContain("installation required");
|
||||
});
|
||||
|
||||
it("maps Stripe checkout and invoice events back into organization actors", async (t) => {
|
||||
createTestRuntimeContext(createTestDriver(), undefined, {
|
||||
appUrl: "http://localhost:4173",
|
||||
github: createGithubService({
|
||||
listInstallationRepositories: async () => [],
|
||||
}),
|
||||
stripe: createStripeService({
|
||||
isConfigured: () => true,
|
||||
}),
|
||||
});
|
||||
|
||||
const { client } = await setupTest(t, registry);
|
||||
const app = await getAppWorkspace(client);
|
||||
|
||||
const { sessionId } = await app.ensureAppSession({});
|
||||
const authStart = await app.startAppGithubAuth({ sessionId });
|
||||
const state = new URL(authStart.url).searchParams.get("state");
|
||||
await app.completeAppGithubAuth({
|
||||
code: "oauth-code",
|
||||
state,
|
||||
});
|
||||
|
||||
const checkout = await app.createAppCheckoutSession({
|
||||
sessionId,
|
||||
organizationId: "acme",
|
||||
planId: "team",
|
||||
});
|
||||
expect(checkout.url).toBe("https://billing.example/checkout/cs_test");
|
||||
|
||||
const completion = await app.finalizeAppCheckoutSession({
|
||||
sessionId,
|
||||
organizationId: "acme",
|
||||
checkoutSessionId: "cs_test",
|
||||
});
|
||||
expect(completion.redirectTo).toContain("/organizations/acme/billing");
|
||||
|
||||
await app.handleAppStripeWebhook({
|
||||
payload: JSON.stringify({
|
||||
id: "evt_1",
|
||||
type: "invoice.paid",
|
||||
data: {
|
||||
object: {
|
||||
id: "in_1",
|
||||
customer: "cus_test",
|
||||
number: "0001",
|
||||
amount_paid: 24000,
|
||||
created: 1741564800,
|
||||
},
|
||||
},
|
||||
}),
|
||||
signatureHeader: "sig",
|
||||
});
|
||||
|
||||
const snapshot = await app.getAppSnapshot({ sessionId });
|
||||
const acme = snapshot.organizations.find((organization: any) => organization.id === "acme");
|
||||
expect(acme.billing.planId).toBe("team");
|
||||
expect(acme.billing.status).toBe("active");
|
||||
expect(acme.billing.paymentMethodLabel).toBe("Visa ending in 4242");
|
||||
expect(acme.billing.invoices[0]).toMatchObject({
|
||||
id: "in_1",
|
||||
amountUsd: 240,
|
||||
status: "paid",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { applyDevelopmentEnvDefaults, loadDevelopmentEnvFiles } from "../src/config/env.js";
|
||||
|
|
@ -10,6 +10,7 @@ const ENV_KEYS = [
|
|||
"BETTER_AUTH_URL",
|
||||
"BETTER_AUTH_SECRET",
|
||||
"GITHUB_REDIRECT_URI",
|
||||
"GITHUB_APP_PRIVATE_KEY",
|
||||
] as const;
|
||||
|
||||
const ORIGINAL_ENV = new Map<string, string | undefined>(
|
||||
|
|
@ -45,6 +46,21 @@ describe("development env loading", () => {
|
|||
expect(process.env.APP_URL).toBe("http://localhost:4999");
|
||||
});
|
||||
|
||||
test("walks parent directories to find repo-level development env files", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "factory-env-"));
|
||||
const nested = join(dir, "factory", "packages", "backend");
|
||||
mkdirSync(nested, { recursive: true });
|
||||
writeFileSync(join(dir, ".env.development.local"), "APP_URL=http://localhost:4888\n", "utf8");
|
||||
|
||||
process.env.NODE_ENV = "development";
|
||||
delete process.env.APP_URL;
|
||||
|
||||
const loaded = loadDevelopmentEnvFiles(nested);
|
||||
|
||||
expect(loaded).toContain(join(dir, ".env.development.local"));
|
||||
expect(process.env.APP_URL).toBe("http://localhost:4888");
|
||||
});
|
||||
|
||||
test("skips dotenv files outside development", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "factory-env-"));
|
||||
writeFileSync(join(dir, ".env.development"), "APP_URL=http://localhost:4999\n", "utf8");
|
||||
|
|
@ -72,4 +88,20 @@ describe("development env loading", () => {
|
|||
expect(process.env.BETTER_AUTH_SECRET).toBe("sandbox-agent-factory-development-only-change-me");
|
||||
expect(process.env.GITHUB_REDIRECT_URI).toBe("http://localhost:4173/api/rivet/app/auth/github/callback");
|
||||
});
|
||||
|
||||
test("decodes escaped newlines for quoted env values", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "factory-env-"));
|
||||
writeFileSync(
|
||||
join(dir, ".env.development"),
|
||||
'GITHUB_APP_PRIVATE_KEY="line-1\\nline-2\\n"\n',
|
||||
"utf8",
|
||||
);
|
||||
|
||||
process.env.NODE_ENV = "development";
|
||||
delete process.env.GITHUB_APP_PRIVATE_KEY;
|
||||
|
||||
loadDevelopmentEnvFiles(dir);
|
||||
|
||||
expect(process.env.GITHUB_APP_PRIVATE_KEY).toBe("line-1\nline-2\n");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ describe("create flow decision", () => {
|
|||
const resolved = resolveCreateFlowDecision({
|
||||
task: "Add auth",
|
||||
localBranches: ["feat-add-auth"],
|
||||
handoffBranches: ["feat-add-auth-2"]
|
||||
taskBranches: ["feat-add-auth-2"]
|
||||
});
|
||||
|
||||
expect(resolved.title).toBe("feat: Add auth");
|
||||
|
|
@ -38,7 +38,7 @@ describe("create flow decision", () => {
|
|||
task: "new task",
|
||||
explicitBranchName: "existing-branch",
|
||||
localBranches: ["existing-branch"],
|
||||
handoffBranches: []
|
||||
taskBranches: []
|
||||
})
|
||||
).toThrow("already exists");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ describe("daytona provider snapshot image behavior", () => {
|
|||
repoId: "repo-1",
|
||||
repoRemote: "https://github.com/acme/repo.git",
|
||||
branchName: "feature/test",
|
||||
handoffId: "handoff-1",
|
||||
taskId: "task-1",
|
||||
});
|
||||
|
||||
expect(client.createSandboxCalls).toHaveLength(1);
|
||||
|
|
@ -94,7 +94,7 @@ describe("daytona provider snapshot image behavior", () => {
|
|||
|
||||
expect(handle.metadata.snapshot).toBe("snapshot-factory");
|
||||
expect(handle.metadata.image).toBe("ubuntu:24.04");
|
||||
expect(handle.metadata.cwd).toBe("/home/daytona/sandbox-agent-factory/default/repo-1/handoff-1/repo");
|
||||
expect(handle.metadata.cwd).toBe("/home/daytona/sandbox-agent-factory/default/repo-1/task-1/repo");
|
||||
expect(client.executedCommands.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
|
|
@ -154,7 +154,7 @@ describe("daytona provider snapshot image behavior", () => {
|
|||
repoId: "repo-1",
|
||||
repoRemote: "https://github.com/acme/repo.git",
|
||||
branchName: "feature/test",
|
||||
handoffId: "handoff-timeout",
|
||||
taskId: "task-timeout",
|
||||
})).rejects.toThrow("daytona create sandbox timed out after 120ms");
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { ConfigSchema, type AppConfig } from "@sandbox-agent/factory-shared";
|
|||
import type { BackendDriver } from "../../src/driver.js";
|
||||
import { initActorRuntimeContext } from "../../src/actors/context.js";
|
||||
import { createProviderRegistry } from "../../src/providers/index.js";
|
||||
import { createDefaultAppShellServices, type AppShellServices } from "../../src/services/app-shell-runtime.js";
|
||||
|
||||
export function createTestConfig(overrides?: Partial<AppConfig>): AppConfig {
|
||||
return ConfigSchema.parse({
|
||||
|
|
@ -31,10 +32,21 @@ export function createTestConfig(overrides?: Partial<AppConfig>): AppConfig {
|
|||
|
||||
export function createTestRuntimeContext(
|
||||
driver: BackendDriver,
|
||||
configOverrides?: Partial<AppConfig>
|
||||
configOverrides?: Partial<AppConfig>,
|
||||
appShellOverrides?: Partial<AppShellServices>
|
||||
): { config: AppConfig } {
|
||||
const config = createTestConfig(configOverrides);
|
||||
const providers = createProviderRegistry(config, driver);
|
||||
initActorRuntimeContext(config, providers, undefined, driver);
|
||||
initActorRuntimeContext(
|
||||
config,
|
||||
providers,
|
||||
undefined,
|
||||
driver,
|
||||
createDefaultAppShellServices({
|
||||
appUrl: appShellOverrides?.appUrl,
|
||||
github: appShellOverrides?.github,
|
||||
stripe: appShellOverrides?.stripe,
|
||||
}),
|
||||
);
|
||||
return { config };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
handoffKey,
|
||||
handoffStatusSyncKey,
|
||||
taskKey,
|
||||
taskStatusSyncKey,
|
||||
historyKey,
|
||||
projectBranchSyncKey,
|
||||
projectKey,
|
||||
projectPrSyncKey,
|
||||
repoBranchSyncKey,
|
||||
repoKey,
|
||||
repoPrSyncKey,
|
||||
sandboxInstanceKey,
|
||||
workspaceKey
|
||||
} from "../src/actors/keys.js";
|
||||
|
|
@ -14,13 +14,13 @@ describe("actor keys", () => {
|
|||
it("prefixes every key with workspace namespace", () => {
|
||||
const keys = [
|
||||
workspaceKey("default"),
|
||||
projectKey("default", "repo"),
|
||||
handoffKey("default", "repo", "handoff"),
|
||||
repoKey("default", "repo"),
|
||||
taskKey("default", "task"),
|
||||
sandboxInstanceKey("default", "daytona", "sbx"),
|
||||
historyKey("default", "repo"),
|
||||
projectPrSyncKey("default", "repo"),
|
||||
projectBranchSyncKey("default", "repo"),
|
||||
handoffStatusSyncKey("default", "repo", "handoff", "sandbox-1", "session-1")
|
||||
repoPrSyncKey("default", "repo"),
|
||||
repoBranchSyncKey("default", "repo"),
|
||||
taskStatusSyncKey("default", "repo", "task", "sandbox-1", "session-1")
|
||||
];
|
||||
|
||||
for (const key of keys) {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ function makeConfig(): AppConfig {
|
|||
backend: {
|
||||
host: "127.0.0.1",
|
||||
port: 7741,
|
||||
dbPath: "~/.local/share/sandbox-agent-factory/handoff.db",
|
||||
dbPath: "~/.local/share/sandbox-agent-factory/task.db",
|
||||
opencode_poll_interval: 2,
|
||||
github_poll_interval: 30,
|
||||
backup_interval_secs: 3600,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import {
|
|||
normalizeParentBranch,
|
||||
parentLookupFromStack,
|
||||
sortBranchesForOverview,
|
||||
} from "../src/actors/project/stack-model.js";
|
||||
} from "../src/actors/repo/stack-model.js";
|
||||
|
||||
describe("stack-model", () => {
|
||||
it("normalizes self-parent references to null", () => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { shouldMarkSessionUnreadForStatus } from "../src/actors/handoff/workbench.js";
|
||||
import { shouldMarkSessionUnreadForStatus } from "../src/actors/task/workbench.js";
|
||||
|
||||
describe("workbench unread status transitions", () => {
|
||||
it("marks unread when a running session first becomes idle", () => {
|
||||
|
|
|
|||
|
|
@ -30,18 +30,18 @@ async function waitForWorkspaceRows(
|
|||
expectedCount: number
|
||||
) {
|
||||
for (let attempt = 0; attempt < 40; attempt += 1) {
|
||||
const rows = await ws.listHandoffs({ workspaceId });
|
||||
const rows = await ws.listTasks({ workspaceId });
|
||||
if (rows.length >= expectedCount) {
|
||||
return rows;
|
||||
}
|
||||
await delay(50);
|
||||
}
|
||||
return ws.listHandoffs({ workspaceId });
|
||||
return ws.listTasks({ workspaceId });
|
||||
}
|
||||
|
||||
describe("workspace isolation", () => {
|
||||
it.skipIf(!runActorIntegration)(
|
||||
"keeps handoff lists isolated by workspace",
|
||||
"keeps task lists isolated by workspace",
|
||||
async (t) => {
|
||||
const testDriver = createTestDriver();
|
||||
createTestRuntimeContext(testDriver);
|
||||
|
|
@ -58,7 +58,7 @@ describe("workspace isolation", () => {
|
|||
const repoA = await wsA.addRepo({ workspaceId: "alpha", remoteUrl: repoPath });
|
||||
const repoB = await wsB.addRepo({ workspaceId: "beta", remoteUrl: repoPath });
|
||||
|
||||
await wsA.createHandoff({
|
||||
await wsA.createTask({
|
||||
workspaceId: "alpha",
|
||||
repoId: repoA.repoId,
|
||||
task: "task A",
|
||||
|
|
@ -67,7 +67,7 @@ describe("workspace isolation", () => {
|
|||
explicitTitle: "A"
|
||||
});
|
||||
|
||||
await wsB.createHandoff({
|
||||
await wsB.createTask({
|
||||
workspaceId: "beta",
|
||||
repoId: repoB.repoId,
|
||||
task: "task B",
|
||||
|
|
@ -83,7 +83,7 @@ describe("workspace isolation", () => {
|
|||
expect(bRows.length).toBe(1);
|
||||
expect(aRows[0]?.workspaceId).toBe("alpha");
|
||||
expect(bRows[0]?.workspaceId).toBe("beta");
|
||||
expect(aRows[0]?.handoffId).not.toBe(bRows[0]?.handoffId);
|
||||
expect(aRows[0]?.taskId).not.toBe(bRows[0]?.taskId);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,26 +0,0 @@
|
|||
{
|
||||
"name": "@sandbox-agent/factory-cli",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"bin": {
|
||||
"hf": "dist/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup --config tsup.config.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"@opentui/core": "^0.1.77",
|
||||
"@sandbox-agent/factory-client": "workspace:*",
|
||||
"@sandbox-agent/factory-shared": "workspace:*",
|
||||
"zod": "^4.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsup": "^8.5.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,446 +0,0 @@
|
|||
import * as childProcess from "node:child_process";
|
||||
import {
|
||||
closeSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync
|
||||
} from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { checkBackendHealth } from "@sandbox-agent/factory-client";
|
||||
import type { AppConfig } from "@sandbox-agent/factory-shared";
|
||||
import { CLI_BUILD_ID } from "../build-id.js";
|
||||
|
||||
const HEALTH_TIMEOUT_MS = 1_500;
|
||||
const START_TIMEOUT_MS = 30_000;
|
||||
const STOP_TIMEOUT_MS = 5_000;
|
||||
const POLL_INTERVAL_MS = 150;
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
|
||||
}
|
||||
|
||||
function sanitizeHost(host: string): string {
|
||||
return host
|
||||
.split("")
|
||||
.map((ch) => (/[a-zA-Z0-9]/.test(ch) ? ch : "-"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
function backendStateDir(): string {
|
||||
const override = process.env.HF_BACKEND_STATE_DIR?.trim();
|
||||
if (override) {
|
||||
return override;
|
||||
}
|
||||
|
||||
const xdgDataHome = process.env.XDG_DATA_HOME?.trim();
|
||||
if (xdgDataHome) {
|
||||
return join(xdgDataHome, "sandbox-agent-factory", "backend");
|
||||
}
|
||||
|
||||
return join(homedir(), ".local", "share", "sandbox-agent-factory", "backend");
|
||||
}
|
||||
|
||||
function backendPidPath(host: string, port: number): string {
|
||||
return join(backendStateDir(), `backend-${sanitizeHost(host)}-${port}.pid`);
|
||||
}
|
||||
|
||||
function backendVersionPath(host: string, port: number): string {
|
||||
return join(backendStateDir(), `backend-${sanitizeHost(host)}-${port}.version`);
|
||||
}
|
||||
|
||||
function backendLogPath(host: string, port: number): string {
|
||||
return join(backendStateDir(), `backend-${sanitizeHost(host)}-${port}.log`);
|
||||
}
|
||||
|
||||
function readText(path: string): string | null {
|
||||
try {
|
||||
return readFileSync(path, "utf8").trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readPid(host: string, port: number): number | null {
|
||||
const raw = readText(backendPidPath(host, port));
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pid = Number.parseInt(raw, 10);
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
return null;
|
||||
}
|
||||
return pid;
|
||||
}
|
||||
|
||||
function writePid(host: string, port: number, pid: number): void {
|
||||
const path = backendPidPath(host, port);
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, String(pid), "utf8");
|
||||
}
|
||||
|
||||
function removePid(host: string, port: number): void {
|
||||
const path = backendPidPath(host, port);
|
||||
if (existsSync(path)) {
|
||||
rmSync(path);
|
||||
}
|
||||
}
|
||||
|
||||
function readBackendVersion(host: string, port: number): string | null {
|
||||
return readText(backendVersionPath(host, port));
|
||||
}
|
||||
|
||||
function writeBackendVersion(host: string, port: number, buildId: string): void {
|
||||
const path = backendVersionPath(host, port);
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, buildId, "utf8");
|
||||
}
|
||||
|
||||
function removeBackendVersion(host: string, port: number): void {
|
||||
const path = backendVersionPath(host, port);
|
||||
if (existsSync(path)) {
|
||||
rmSync(path);
|
||||
}
|
||||
}
|
||||
|
||||
function readCliBuildId(): string {
|
||||
const override = process.env.HF_BUILD_ID?.trim();
|
||||
if (override) {
|
||||
return override;
|
||||
}
|
||||
|
||||
return CLI_BUILD_ID;
|
||||
}
|
||||
|
||||
function isVersionCurrent(host: string, port: number): boolean {
|
||||
return readBackendVersion(host, port) === readCliBuildId();
|
||||
}
|
||||
|
||||
function isProcessRunning(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException | undefined)?.code === "EPERM") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function removeStateFiles(host: string, port: number): void {
|
||||
removePid(host, port);
|
||||
removeBackendVersion(host, port);
|
||||
}
|
||||
|
||||
async function checkHealth(host: string, port: number): Promise<boolean> {
|
||||
return await checkBackendHealth({
|
||||
endpoint: `http://${host}:${port}/api/rivet`,
|
||||
timeoutMs: HEALTH_TIMEOUT_MS
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForHealth(host: string, port: number, timeoutMs: number, pid?: number): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (pid && !isProcessRunning(pid)) {
|
||||
throw new Error(`backend process ${pid} exited before becoming healthy`);
|
||||
}
|
||||
|
||||
if (await checkHealth(host, port)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
throw new Error(`backend did not become healthy within ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
async function waitForChildPid(child: childProcess.ChildProcess): Promise<number | null> {
|
||||
if (child.pid && child.pid > 0) {
|
||||
return child.pid;
|
||||
}
|
||||
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
await sleep(50);
|
||||
if (child.pid && child.pid > 0) {
|
||||
return child.pid;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
interface LaunchSpec {
|
||||
command: string;
|
||||
args: string[];
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
function resolveBunCommand(): string {
|
||||
const override = process.env.HF_BUN?.trim();
|
||||
if (override && (override === "bun" || existsSync(override))) {
|
||||
return override;
|
||||
}
|
||||
|
||||
const homeBun = join(homedir(), ".bun", "bin", "bun");
|
||||
if (existsSync(homeBun)) {
|
||||
return homeBun;
|
||||
}
|
||||
|
||||
return "bun";
|
||||
}
|
||||
|
||||
function resolveLaunchSpec(host: string, port: number): LaunchSpec {
|
||||
const repoRoot = resolve(fileURLToPath(new URL("../../..", import.meta.url)));
|
||||
const backendEntry = resolve(fileURLToPath(new URL("../../backend/dist/index.js", import.meta.url)));
|
||||
|
||||
if (existsSync(backendEntry)) {
|
||||
return {
|
||||
command: resolveBunCommand(),
|
||||
args: [backendEntry, "start", "--host", host, "--port", String(port)],
|
||||
cwd: repoRoot
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
command: "pnpm",
|
||||
args: [
|
||||
"--filter",
|
||||
"@sandbox-agent/factory-backend",
|
||||
"exec",
|
||||
"bun",
|
||||
"src/index.ts",
|
||||
"start",
|
||||
"--host",
|
||||
host,
|
||||
"--port",
|
||||
String(port)
|
||||
],
|
||||
cwd: repoRoot
|
||||
};
|
||||
}
|
||||
|
||||
async function startBackend(host: string, port: number): Promise<void> {
|
||||
if (await checkHealth(host, port)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingPid = readPid(host, port);
|
||||
if (existingPid && isProcessRunning(existingPid)) {
|
||||
await waitForHealth(host, port, START_TIMEOUT_MS, existingPid);
|
||||
return;
|
||||
}
|
||||
|
||||
if (existingPid) {
|
||||
removeStateFiles(host, port);
|
||||
}
|
||||
|
||||
const logPath = backendLogPath(host, port);
|
||||
mkdirSync(dirname(logPath), { recursive: true });
|
||||
const fd = openSync(logPath, "a");
|
||||
|
||||
const launch = resolveLaunchSpec(host, port);
|
||||
const child = childProcess.spawn(launch.command, launch.args, {
|
||||
cwd: launch.cwd,
|
||||
detached: true,
|
||||
stdio: ["ignore", fd, fd],
|
||||
env: process.env
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
console.error(`failed to launch backend: ${String(error)}`);
|
||||
});
|
||||
|
||||
child.unref();
|
||||
closeSync(fd);
|
||||
|
||||
const pid = await waitForChildPid(child);
|
||||
|
||||
writeBackendVersion(host, port, readCliBuildId());
|
||||
if (pid) {
|
||||
writePid(host, port, pid);
|
||||
}
|
||||
|
||||
try {
|
||||
await waitForHealth(host, port, START_TIMEOUT_MS, pid ?? undefined);
|
||||
} catch (error) {
|
||||
if (pid) {
|
||||
removeStateFiles(host, port);
|
||||
} else {
|
||||
removeBackendVersion(host, port);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function trySignal(pid: number, signal: NodeJS.Signals): boolean {
|
||||
try {
|
||||
process.kill(pid, signal);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException | undefined)?.code === "ESRCH") {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function findProcessOnPort(port: number): number | null {
|
||||
try {
|
||||
const out = childProcess
|
||||
.execFileSync("lsof", ["-i", `:${port}`, "-t", "-sTCP:LISTEN"], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"]
|
||||
})
|
||||
.trim();
|
||||
|
||||
const pidRaw = out.split("\n")[0]?.trim();
|
||||
if (!pidRaw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pid = Number.parseInt(pidRaw, 10);
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return pid;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopBackend(host: string, port: number): Promise<void> {
|
||||
let pid = readPid(host, port);
|
||||
|
||||
if (!pid) {
|
||||
if (!(await checkHealth(host, port))) {
|
||||
removeStateFiles(host, port);
|
||||
return;
|
||||
}
|
||||
|
||||
pid = findProcessOnPort(port);
|
||||
if (!pid) {
|
||||
throw new Error(`backend is healthy at ${host}:${port} but no PID could be resolved`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isProcessRunning(pid)) {
|
||||
removeStateFiles(host, port);
|
||||
return;
|
||||
}
|
||||
|
||||
trySignal(pid, "SIGTERM");
|
||||
|
||||
const deadline = Date.now() + STOP_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
if (!isProcessRunning(pid)) {
|
||||
removeStateFiles(host, port);
|
||||
return;
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
|
||||
trySignal(pid, "SIGKILL");
|
||||
removeStateFiles(host, port);
|
||||
}
|
||||
|
||||
export interface BackendStatus {
|
||||
running: boolean;
|
||||
pid: number | null;
|
||||
version: string | null;
|
||||
versionCurrent: boolean;
|
||||
logPath: string;
|
||||
}
|
||||
|
||||
export async function getBackendStatus(host: string, port: number): Promise<BackendStatus> {
|
||||
const logPath = backendLogPath(host, port);
|
||||
const pid = readPid(host, port);
|
||||
|
||||
if (pid) {
|
||||
if (isProcessRunning(pid)) {
|
||||
return {
|
||||
running: true,
|
||||
pid,
|
||||
version: readBackendVersion(host, port),
|
||||
versionCurrent: isVersionCurrent(host, port),
|
||||
logPath
|
||||
};
|
||||
}
|
||||
removeStateFiles(host, port);
|
||||
}
|
||||
|
||||
if (await checkHealth(host, port)) {
|
||||
return {
|
||||
running: true,
|
||||
pid: null,
|
||||
version: readBackendVersion(host, port),
|
||||
versionCurrent: isVersionCurrent(host, port),
|
||||
logPath
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
running: false,
|
||||
pid: null,
|
||||
version: readBackendVersion(host, port),
|
||||
versionCurrent: false,
|
||||
logPath
|
||||
};
|
||||
}
|
||||
|
||||
export async function ensureBackendRunning(config: AppConfig): Promise<void> {
|
||||
const host = config.backend.host;
|
||||
const port = config.backend.port;
|
||||
|
||||
if (await checkHealth(host, port)) {
|
||||
if (!isVersionCurrent(host, port)) {
|
||||
await stopBackend(host, port);
|
||||
await startBackend(host, port);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const pid = readPid(host, port);
|
||||
if (pid && isProcessRunning(pid)) {
|
||||
try {
|
||||
await waitForHealth(host, port, START_TIMEOUT_MS, pid);
|
||||
if (!isVersionCurrent(host, port)) {
|
||||
await stopBackend(host, port);
|
||||
await startBackend(host, port);
|
||||
}
|
||||
return;
|
||||
} catch {
|
||||
await stopBackend(host, port);
|
||||
await startBackend(host, port);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (pid) {
|
||||
removeStateFiles(host, port);
|
||||
}
|
||||
|
||||
await startBackend(host, port);
|
||||
}
|
||||
|
||||
export function parseBackendPort(value: string | undefined, fallback: number): number {
|
||||
if (!value) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const port = Number(value);
|
||||
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
||||
throw new Error(`Invalid backend port: ${value}`);
|
||||
}
|
||||
|
||||
return port;
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
declare const __HF_BUILD_ID__: string | undefined;
|
||||
|
||||
export const CLI_BUILD_ID =
|
||||
typeof __HF_BUILD_ID__ === "string" && __HF_BUILD_ID__.trim().length > 0
|
||||
? __HF_BUILD_ID__.trim()
|
||||
: "dev";
|
||||
|
||||
|
|
@ -1,754 +0,0 @@
|
|||
#!/usr/bin/env bun
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { AgentTypeSchema, CreateHandoffInputSchema, type HandoffRecord } from "@sandbox-agent/factory-shared";
|
||||
import {
|
||||
readBackendMetadata,
|
||||
createBackendClientFromConfig,
|
||||
formatRelativeAge,
|
||||
groupHandoffStatus,
|
||||
summarizeHandoffs
|
||||
} from "@sandbox-agent/factory-client";
|
||||
import {
|
||||
ensureBackendRunning,
|
||||
getBackendStatus,
|
||||
parseBackendPort,
|
||||
stopBackend
|
||||
} from "./backend/manager.js";
|
||||
import { openEditorForTask } from "./task-editor.js";
|
||||
import { spawnCreateTmuxWindow } from "./tmux.js";
|
||||
import { loadConfig, resolveWorkspace, saveConfig } from "./workspace/config.js";
|
||||
|
||||
async function ensureBunRuntime(): Promise<void> {
|
||||
if (typeof (globalThis as { Bun?: unknown }).Bun !== "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const preferred = process.env.HF_BUN?.trim();
|
||||
const candidates = [
|
||||
preferred,
|
||||
`${homedir()}/.bun/bin/bun`,
|
||||
"bun"
|
||||
].filter((item): item is string => Boolean(item && item.length > 0));
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const command = candidate;
|
||||
const canExec = command === "bun" || existsSync(command);
|
||||
if (!canExec) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const child = spawnSync(command, [process.argv[1] ?? "", ...process.argv.slice(2)], {
|
||||
stdio: "inherit",
|
||||
env: process.env
|
||||
});
|
||||
|
||||
if (child.error) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const code = child.status ?? 1;
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
throw new Error("hf requires Bun runtime. Set HF_BUN or install Bun at ~/.bun/bin/bun.");
|
||||
}
|
||||
|
||||
async function runTuiCommand(config: ReturnType<typeof loadConfig>, workspaceId: string): Promise<void> {
|
||||
const mod = await import("./tui.js");
|
||||
await mod.runTui(config, workspaceId);
|
||||
}
|
||||
|
||||
function readOption(args: string[], flag: string): string | undefined {
|
||||
const idx = args.indexOf(flag);
|
||||
if (idx < 0) return undefined;
|
||||
return args[idx + 1];
|
||||
}
|
||||
|
||||
function hasFlag(args: string[], flag: string): boolean {
|
||||
return args.includes(flag);
|
||||
}
|
||||
|
||||
function parseIntOption(
|
||||
value: string | undefined,
|
||||
fallback: number,
|
||||
label: string
|
||||
): number {
|
||||
if (!value) {
|
||||
return fallback;
|
||||
}
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
throw new Error(`Invalid ${label}: ${value}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function positionals(args: string[]): string[] {
|
||||
const out: string[] = [];
|
||||
for (let i = 0; i < args.length; i += 1) {
|
||||
const item = args[i];
|
||||
if (!item) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.startsWith("--")) {
|
||||
const next = args[i + 1];
|
||||
if (next && !next.startsWith("--")) {
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
out.push(item);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function printUsage(): void {
|
||||
console.log(`
|
||||
Usage:
|
||||
hf backend start [--host HOST] [--port PORT]
|
||||
hf backend stop [--host HOST] [--port PORT]
|
||||
hf backend status
|
||||
hf backend inspect
|
||||
hf status [--workspace WS] [--json]
|
||||
hf history [--workspace WS] [--limit N] [--branch NAME] [--handoff ID] [--json]
|
||||
hf workspace use <name>
|
||||
hf tui [--workspace WS]
|
||||
|
||||
hf create [task] [--workspace WS] --repo <git-remote> [--name NAME|--branch NAME] [--title TITLE] [--agent claude|codex] [--on BRANCH]
|
||||
hf list [--workspace WS] [--format table|json] [--full]
|
||||
hf switch [handoff-id | -] [--workspace WS]
|
||||
hf attach <handoff-id> [--workspace WS]
|
||||
hf merge <handoff-id> [--workspace WS]
|
||||
hf archive <handoff-id> [--workspace WS]
|
||||
hf push <handoff-id> [--workspace WS]
|
||||
hf sync <handoff-id> [--workspace WS]
|
||||
hf kill <handoff-id> [--workspace WS] [--delete-branch] [--abandon]
|
||||
hf prune [--workspace WS] [--dry-run] [--yes]
|
||||
hf statusline [--workspace WS] [--format table|claude-code]
|
||||
hf db path
|
||||
hf db nuke
|
||||
|
||||
Tips:
|
||||
hf status --help Show status output format and examples
|
||||
hf history --help Show history output format and examples
|
||||
hf switch - Switch to most recently updated handoff
|
||||
`);
|
||||
}
|
||||
|
||||
function printStatusUsage(): void {
|
||||
console.log(`
|
||||
Usage:
|
||||
hf status [--workspace WS] [--json]
|
||||
|
||||
Text Output:
|
||||
workspace=<workspace-id>
|
||||
backend running=<true|false> pid=<pid|unknown> version=<version|unknown>
|
||||
handoffs total=<number>
|
||||
status queued=<n> running=<n> idle=<n> archived=<n> killed=<n> error=<n>
|
||||
providers <provider-id>=<count> ...
|
||||
providers -
|
||||
|
||||
JSON Output:
|
||||
{
|
||||
"workspaceId": "default",
|
||||
"backend": { ...backend status object... },
|
||||
"handoffs": {
|
||||
"total": 4,
|
||||
"byStatus": { "queued": 0, "running": 1, "idle": 2, "archived": 1, "killed": 0, "error": 0 },
|
||||
"byProvider": { "daytona": 4 }
|
||||
}
|
||||
}
|
||||
`);
|
||||
}
|
||||
|
||||
function printHistoryUsage(): void {
|
||||
console.log(`
|
||||
Usage:
|
||||
hf history [--workspace WS] [--limit N] [--branch NAME] [--handoff ID] [--json]
|
||||
|
||||
Text Output:
|
||||
<iso8601>\t<event-kind>\t<branch|handoff|repo|->\t<payload-json>
|
||||
<iso8601>\t<event-kind>\t<branch|handoff|repo|->\t<payload-json...>
|
||||
no events
|
||||
|
||||
Notes:
|
||||
- payload is truncated to 120 characters in text mode.
|
||||
- --limit defaults to 20.
|
||||
|
||||
JSON Output:
|
||||
[
|
||||
{
|
||||
"id": "...",
|
||||
"workspaceId": "default",
|
||||
"kind": "handoff.created",
|
||||
"handoffId": "...",
|
||||
"repoId": "...",
|
||||
"branchName": "feature/foo",
|
||||
"payloadJson": "{\\"providerId\\":\\"daytona\\"}",
|
||||
"createdAt": 1770607522229
|
||||
}
|
||||
]
|
||||
`);
|
||||
}
|
||||
|
||||
async function handleBackend(args: string[]): Promise<void> {
|
||||
const sub = args[0] ?? "start";
|
||||
const config = loadConfig();
|
||||
const host = readOption(args, "--host") ?? config.backend.host;
|
||||
const port = parseBackendPort(readOption(args, "--port"), config.backend.port);
|
||||
const backendConfig = {
|
||||
...config,
|
||||
backend: {
|
||||
...config.backend,
|
||||
host,
|
||||
port
|
||||
}
|
||||
};
|
||||
|
||||
if (sub === "start") {
|
||||
await ensureBackendRunning(backendConfig);
|
||||
const status = await getBackendStatus(host, port);
|
||||
const pid = status.pid ?? "unknown";
|
||||
const version = status.version ?? "unknown";
|
||||
const stale = status.running && !status.versionCurrent ? " [outdated]" : "";
|
||||
console.log(`running=true pid=${pid} version=${version}${stale} log=${status.logPath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === "stop") {
|
||||
await stopBackend(host, port);
|
||||
console.log(`running=false host=${host} port=${port}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === "status") {
|
||||
const status = await getBackendStatus(host, port);
|
||||
const pid = status.pid ?? "unknown";
|
||||
const version = status.version ?? "unknown";
|
||||
const stale = status.running && !status.versionCurrent ? " [outdated]" : "";
|
||||
console.log(
|
||||
`running=${status.running} pid=${pid} version=${version}${stale} host=${host} port=${port} log=${status.logPath}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === "inspect") {
|
||||
await ensureBackendRunning(backendConfig);
|
||||
const metadata = await readBackendMetadata({
|
||||
endpoint: `http://${host}:${port}/api/rivet`,
|
||||
timeoutMs: 4_000
|
||||
});
|
||||
const managerEndpoint = metadata.clientEndpoint ?? `http://${host}:${port}`;
|
||||
const inspectorUrl = `https://inspect.rivet.dev?u=${encodeURIComponent(managerEndpoint)}`;
|
||||
const openCmd = process.platform === "darwin" ? "open" : "xdg-open";
|
||||
spawnSync(openCmd, [inspectorUrl], { stdio: "ignore" });
|
||||
console.log(inspectorUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`Unknown backend subcommand: ${sub}`);
|
||||
}
|
||||
|
||||
async function handleWorkspace(args: string[]): Promise<void> {
|
||||
const sub = args[0];
|
||||
if (sub !== "use") {
|
||||
throw new Error("Usage: hf workspace use <name>");
|
||||
}
|
||||
|
||||
const name = args[1];
|
||||
if (!name) {
|
||||
throw new Error("Missing workspace name");
|
||||
}
|
||||
|
||||
const config = loadConfig();
|
||||
config.workspace.default = name;
|
||||
saveConfig(config);
|
||||
|
||||
const client = createBackendClientFromConfig(config);
|
||||
try {
|
||||
await client.useWorkspace(name);
|
||||
} catch {
|
||||
// Backend may not be running yet. Config is already updated.
|
||||
}
|
||||
|
||||
console.log(`workspace=${name}`);
|
||||
}
|
||||
|
||||
async function handleList(args: string[]): Promise<void> {
|
||||
const config = loadConfig();
|
||||
const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config);
|
||||
const format = readOption(args, "--format") ?? "table";
|
||||
const full = hasFlag(args, "--full");
|
||||
const client = createBackendClientFromConfig(config);
|
||||
const rows = await client.listHandoffs(workspaceId);
|
||||
|
||||
if (format === "json") {
|
||||
console.log(JSON.stringify(rows, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
console.log("no handoffs");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
const age = formatRelativeAge(row.updatedAt);
|
||||
let line = `${row.handoffId}\t${row.branchName}\t${row.status}\t${row.providerId}\t${age}`;
|
||||
if (full) {
|
||||
const task = row.task.length > 60 ? `${row.task.slice(0, 57)}...` : row.task;
|
||||
line += `\t${row.title}\t${task}\t${row.activeSessionId ?? "-"}\t${row.activeSandboxId ?? "-"}`;
|
||||
}
|
||||
console.log(line);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePush(args: string[]): Promise<void> {
|
||||
const handoffId = positionals(args)[0];
|
||||
if (!handoffId) {
|
||||
throw new Error("Missing handoff id for push");
|
||||
}
|
||||
const config = loadConfig();
|
||||
const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config);
|
||||
const client = createBackendClientFromConfig(config);
|
||||
await client.runAction(workspaceId, handoffId, "push");
|
||||
console.log("ok");
|
||||
}
|
||||
|
||||
async function handleSync(args: string[]): Promise<void> {
|
||||
const handoffId = positionals(args)[0];
|
||||
if (!handoffId) {
|
||||
throw new Error("Missing handoff id for sync");
|
||||
}
|
||||
const config = loadConfig();
|
||||
const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config);
|
||||
const client = createBackendClientFromConfig(config);
|
||||
await client.runAction(workspaceId, handoffId, "sync");
|
||||
console.log("ok");
|
||||
}
|
||||
|
||||
async function handleKill(args: string[]): Promise<void> {
|
||||
const handoffId = positionals(args)[0];
|
||||
if (!handoffId) {
|
||||
throw new Error("Missing handoff id for kill");
|
||||
}
|
||||
const config = loadConfig();
|
||||
const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config);
|
||||
const deleteBranch = hasFlag(args, "--delete-branch");
|
||||
const abandon = hasFlag(args, "--abandon");
|
||||
|
||||
if (deleteBranch) {
|
||||
console.log("info: --delete-branch flag set, branch will be deleted after kill");
|
||||
}
|
||||
if (abandon) {
|
||||
console.log("info: --abandon flag set, Graphite abandon will be attempted");
|
||||
}
|
||||
|
||||
const client = createBackendClientFromConfig(config);
|
||||
await client.runAction(workspaceId, handoffId, "kill");
|
||||
console.log("ok");
|
||||
}
|
||||
|
||||
async function handlePrune(args: string[]): Promise<void> {
|
||||
const config = loadConfig();
|
||||
const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config);
|
||||
const dryRun = hasFlag(args, "--dry-run");
|
||||
const yes = hasFlag(args, "--yes");
|
||||
const client = createBackendClientFromConfig(config);
|
||||
const rows = await client.listHandoffs(workspaceId);
|
||||
const prunable = rows.filter((r) => r.status === "archived" || r.status === "killed");
|
||||
|
||||
if (prunable.length === 0) {
|
||||
console.log("nothing to prune");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const row of prunable) {
|
||||
const age = formatRelativeAge(row.updatedAt);
|
||||
console.log(`${dryRun ? "[dry-run] " : ""}${row.handoffId}\t${row.branchName}\t${row.status}\t${age}`);
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
console.log(`\n${prunable.length} handoff(s) would be pruned`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!yes) {
|
||||
console.log("\nnot yet implemented: auto-pruning requires confirmation");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`\n${prunable.length} handoff(s) would be pruned (pruning not yet implemented)`);
|
||||
}
|
||||
|
||||
async function handleStatusline(args: string[]): Promise<void> {
|
||||
const config = loadConfig();
|
||||
const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config);
|
||||
const format = readOption(args, "--format") ?? "table";
|
||||
const client = createBackendClientFromConfig(config);
|
||||
const rows = await client.listHandoffs(workspaceId);
|
||||
const summary = summarizeHandoffs(rows);
|
||||
const running = summary.byStatus.running;
|
||||
const idle = summary.byStatus.idle;
|
||||
const errorCount = summary.byStatus.error;
|
||||
|
||||
if (format === "claude-code") {
|
||||
console.log(`hf:${running}R/${idle}I/${errorCount}E`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`running=${running} idle=${idle} error=${errorCount}`);
|
||||
}
|
||||
|
||||
async function handleDb(args: string[]): Promise<void> {
|
||||
const sub = args[0];
|
||||
if (sub === "path") {
|
||||
const config = loadConfig();
|
||||
const dbPath = config.backend.dbPath.replace(/^~/, homedir());
|
||||
console.log(dbPath);
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === "nuke") {
|
||||
console.log("WARNING: hf db nuke would delete the entire database. This is a placeholder and does not delete anything.");
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error("Usage: hf db path | hf db nuke");
|
||||
}
|
||||
|
||||
async function waitForHandoffReady(
|
||||
client: ReturnType<typeof createBackendClientFromConfig>,
|
||||
workspaceId: string,
|
||||
handoffId: string,
|
||||
timeoutMs: number
|
||||
): Promise<HandoffRecord> {
|
||||
const start = Date.now();
|
||||
let delayMs = 250;
|
||||
|
||||
for (;;) {
|
||||
const record = await client.getHandoff(workspaceId, handoffId);
|
||||
const hasName = Boolean(record.branchName && record.title);
|
||||
const hasSandbox = Boolean(record.activeSandboxId);
|
||||
|
||||
if (record.status === "error") {
|
||||
throw new Error(`handoff entered error state while provisioning: ${handoffId}`);
|
||||
}
|
||||
if (hasName && hasSandbox) {
|
||||
return record;
|
||||
}
|
||||
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
throw new Error(`timed out waiting for handoff provisioning: ${handoffId}`);
|
||||
}
|
||||
|
||||
await new Promise((r) => setTimeout(r, delayMs));
|
||||
delayMs = Math.min(Math.round(delayMs * 1.5), 2_000);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate(args: string[]): Promise<void> {
|
||||
const config = loadConfig();
|
||||
const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config);
|
||||
|
||||
const repoRemote = readOption(args, "--repo");
|
||||
if (!repoRemote) {
|
||||
throw new Error("Missing required --repo <git-remote>");
|
||||
}
|
||||
const explicitBranchName = readOption(args, "--name") ?? readOption(args, "--branch");
|
||||
const explicitTitle = readOption(args, "--title");
|
||||
|
||||
const agentRaw = readOption(args, "--agent");
|
||||
const agentType = agentRaw ? AgentTypeSchema.parse(agentRaw) : undefined;
|
||||
const onBranch = readOption(args, "--on");
|
||||
|
||||
const taskFromArgs = positionals(args).join(" ").trim();
|
||||
const task = taskFromArgs || openEditorForTask();
|
||||
|
||||
const client = createBackendClientFromConfig(config);
|
||||
const repo = await client.addRepo(workspaceId, repoRemote);
|
||||
|
||||
const payload = CreateHandoffInputSchema.parse({
|
||||
workspaceId,
|
||||
repoId: repo.repoId,
|
||||
task,
|
||||
explicitTitle: explicitTitle || undefined,
|
||||
explicitBranchName: explicitBranchName || undefined,
|
||||
agentType,
|
||||
onBranch
|
||||
});
|
||||
|
||||
const created = await client.createHandoff(payload);
|
||||
const handoff = await waitForHandoffReady(client, workspaceId, created.handoffId, 180_000);
|
||||
const switched = await client.switchHandoff(workspaceId, handoff.handoffId);
|
||||
const attached = await client.attachHandoff(workspaceId, handoff.handoffId);
|
||||
|
||||
console.log(`Branch: ${handoff.branchName ?? "-"}`);
|
||||
console.log(`Handoff: ${handoff.handoffId}`);
|
||||
console.log(`Provider: ${handoff.providerId}`);
|
||||
console.log(`Session: ${attached.sessionId ?? "none"}`);
|
||||
console.log(`Target: ${switched.switchTarget || attached.target}`);
|
||||
console.log(`Title: ${handoff.title ?? "-"}`);
|
||||
|
||||
const tmuxResult = spawnCreateTmuxWindow({
|
||||
branchName: handoff.branchName ?? handoff.handoffId,
|
||||
targetPath: switched.switchTarget || attached.target,
|
||||
sessionId: attached.sessionId
|
||||
});
|
||||
|
||||
if (tmuxResult.created) {
|
||||
console.log(`Window: created (${handoff.branchName})`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("");
|
||||
console.log(`Run: hf switch ${handoff.handoffId}`);
|
||||
if ((switched.switchTarget || attached.target).startsWith("/")) {
|
||||
console.log(`cd ${(switched.switchTarget || attached.target)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTui(args: string[]): Promise<void> {
|
||||
const config = loadConfig();
|
||||
const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config);
|
||||
await runTuiCommand(config, workspaceId);
|
||||
}
|
||||
|
||||
async function handleStatus(args: string[]): Promise<void> {
|
||||
if (hasFlag(args, "--help") || hasFlag(args, "-h")) {
|
||||
printStatusUsage();
|
||||
return;
|
||||
}
|
||||
|
||||
const config = loadConfig();
|
||||
const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config);
|
||||
const client = createBackendClientFromConfig(config);
|
||||
const backendStatus = await getBackendStatus(config.backend.host, config.backend.port);
|
||||
const rows = await client.listHandoffs(workspaceId);
|
||||
const summary = summarizeHandoffs(rows);
|
||||
|
||||
if (hasFlag(args, "--json")) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
workspaceId,
|
||||
backend: backendStatus,
|
||||
handoffs: {
|
||||
total: summary.total,
|
||||
byStatus: summary.byStatus,
|
||||
byProvider: summary.byProvider
|
||||
}
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`workspace=${workspaceId}`);
|
||||
console.log(
|
||||
`backend running=${backendStatus.running} pid=${backendStatus.pid ?? "unknown"} version=${backendStatus.version ?? "unknown"}`
|
||||
);
|
||||
console.log(`handoffs total=${summary.total}`);
|
||||
console.log(
|
||||
`status queued=${summary.byStatus.queued} running=${summary.byStatus.running} idle=${summary.byStatus.idle} archived=${summary.byStatus.archived} killed=${summary.byStatus.killed} error=${summary.byStatus.error}`
|
||||
);
|
||||
const providerSummary = Object.entries(summary.byProvider)
|
||||
.map(([provider, count]) => `${provider}=${count}`)
|
||||
.join(" ");
|
||||
console.log(`providers ${providerSummary || "-"}`);
|
||||
}
|
||||
|
||||
async function handleHistory(args: string[]): Promise<void> {
|
||||
if (hasFlag(args, "--help") || hasFlag(args, "-h")) {
|
||||
printHistoryUsage();
|
||||
return;
|
||||
}
|
||||
|
||||
const config = loadConfig();
|
||||
const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config);
|
||||
const limit = parseIntOption(readOption(args, "--limit"), 20, "limit");
|
||||
const branch = readOption(args, "--branch");
|
||||
const handoffId = readOption(args, "--handoff");
|
||||
const client = createBackendClientFromConfig(config);
|
||||
const rows = await client.listHistory({
|
||||
workspaceId,
|
||||
limit,
|
||||
branch: branch || undefined,
|
||||
handoffId: handoffId || undefined
|
||||
});
|
||||
|
||||
if (hasFlag(args, "--json")) {
|
||||
console.log(JSON.stringify(rows, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
console.log("no events");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
const ts = new Date(row.createdAt).toISOString();
|
||||
const target = row.branchName || row.handoffId || row.repoId || "-";
|
||||
let payload = row.payloadJson;
|
||||
if (payload.length > 120) {
|
||||
payload = `${payload.slice(0, 117)}...`;
|
||||
}
|
||||
console.log(`${ts}\t${row.kind}\t${target}\t${payload}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSwitchLike(cmd: string, args: string[]): Promise<void> {
|
||||
let handoffId = positionals(args)[0];
|
||||
if (!handoffId && cmd === "switch") {
|
||||
await handleTui(args);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!handoffId) {
|
||||
throw new Error(`Missing handoff id for ${cmd}`);
|
||||
}
|
||||
|
||||
const config = loadConfig();
|
||||
const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config);
|
||||
const client = createBackendClientFromConfig(config);
|
||||
|
||||
if (cmd === "switch" && handoffId === "-") {
|
||||
const rows = await client.listHandoffs(workspaceId);
|
||||
const active = rows.filter((r) => {
|
||||
const group = groupHandoffStatus(r.status);
|
||||
return group === "running" || group === "idle" || group === "queued";
|
||||
});
|
||||
const sorted = active.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
const target = sorted[0];
|
||||
if (!target) {
|
||||
throw new Error("No active handoffs to switch to");
|
||||
}
|
||||
handoffId = target.handoffId;
|
||||
}
|
||||
|
||||
if (cmd === "switch") {
|
||||
const result = await client.switchHandoff(workspaceId, handoffId);
|
||||
console.log(`cd ${result.switchTarget}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === "attach") {
|
||||
const result = await client.attachHandoff(workspaceId, handoffId);
|
||||
console.log(`target=${result.target} session=${result.sessionId ?? "none"}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === "merge" || cmd === "archive") {
|
||||
await client.runAction(workspaceId, handoffId, cmd);
|
||||
console.log("ok");
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported action: ${cmd}`);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
await ensureBunRuntime();
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const cmd = args[0];
|
||||
const rest = args.slice(1);
|
||||
|
||||
if (cmd === "help" || cmd === "--help" || cmd === "-h") {
|
||||
printUsage();
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === "backend") {
|
||||
await handleBackend(rest);
|
||||
return;
|
||||
}
|
||||
|
||||
const config = loadConfig();
|
||||
await ensureBackendRunning(config);
|
||||
|
||||
if (!cmd || cmd.startsWith("--")) {
|
||||
await handleTui(args);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === "workspace") {
|
||||
await handleWorkspace(rest);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === "create") {
|
||||
await handleCreate(rest);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === "list") {
|
||||
await handleList(rest);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === "tui") {
|
||||
await handleTui(rest);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === "status") {
|
||||
await handleStatus(rest);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === "history") {
|
||||
await handleHistory(rest);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === "push") {
|
||||
await handlePush(rest);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === "sync") {
|
||||
await handleSync(rest);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === "kill") {
|
||||
await handleKill(rest);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === "prune") {
|
||||
await handlePrune(rest);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === "statusline") {
|
||||
await handleStatusline(rest);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === "db") {
|
||||
await handleDb(rest);
|
||||
return;
|
||||
}
|
||||
|
||||
if (["switch", "attach", "merge", "archive"].includes(cmd)) {
|
||||
await handleSwitchLike(cmd, rest);
|
||||
return;
|
||||
}
|
||||
|
||||
printUsage();
|
||||
throw new Error(`Unknown command: ${cmd}`);
|
||||
}
|
||||
|
||||
main().catch((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.stack ?? err.message : String(err);
|
||||
console.error(msg);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const DEFAULT_EDITOR_TEMPLATE = [
|
||||
"# Enter handoff task details below.",
|
||||
"# Lines starting with # are ignored.",
|
||||
""
|
||||
].join("\n");
|
||||
|
||||
export function sanitizeEditorTask(input: string): string {
|
||||
return input
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => !line.trim().startsWith("#"))
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function openEditorForTask(): string {
|
||||
const editor = process.env.VISUAL?.trim() || process.env.EDITOR?.trim() || "vi";
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "hf-task-"));
|
||||
const taskPath = join(tempDir, "task.md");
|
||||
|
||||
try {
|
||||
writeFileSync(taskPath, DEFAULT_EDITOR_TEMPLATE, "utf8");
|
||||
const result = spawnSync(editor, [taskPath], { stdio: "inherit" });
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
if ((result.status ?? 1) !== 0) {
|
||||
throw new Error(`Editor exited with status ${result.status ?? "unknown"}`);
|
||||
}
|
||||
|
||||
const raw = readFileSync(taskPath, "utf8");
|
||||
const task = sanitizeEditorTask(raw);
|
||||
if (!task) {
|
||||
throw new Error("Missing handoff task text");
|
||||
}
|
||||
return task;
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,811 +0,0 @@
|
|||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, isAbsolute, join, resolve } from "node:path";
|
||||
import { cwd } from "node:process";
|
||||
import * as toml from "@iarna/toml";
|
||||
import type { AppConfig } from "@sandbox-agent/factory-shared";
|
||||
import opencodeThemePackJson from "./themes/opencode-pack.json" with { type: "json" };
|
||||
|
||||
export type ThemeMode = "dark" | "light";
|
||||
|
||||
export interface TuiTheme {
|
||||
background: string;
|
||||
text: string;
|
||||
muted: string;
|
||||
header: string;
|
||||
status: string;
|
||||
highlightBg: string;
|
||||
highlightFg: string;
|
||||
selectionBorder: string;
|
||||
success: string;
|
||||
warning: string;
|
||||
error: string;
|
||||
info: string;
|
||||
diffAdd: string;
|
||||
diffDel: string;
|
||||
diffSep: string;
|
||||
agentRunning: string;
|
||||
agentIdle: string;
|
||||
agentNone: string;
|
||||
agentError: string;
|
||||
prUnpushed: string;
|
||||
author: string;
|
||||
ciRunning: string;
|
||||
ciPass: string;
|
||||
ciFail: string;
|
||||
ciNone: string;
|
||||
reviewApproved: string;
|
||||
reviewChanges: string;
|
||||
reviewPending: string;
|
||||
reviewNone: string;
|
||||
}
|
||||
|
||||
export interface TuiThemeResolution {
|
||||
theme: TuiTheme;
|
||||
name: string;
|
||||
source: string;
|
||||
mode: ThemeMode;
|
||||
}
|
||||
|
||||
interface ThemeCandidate {
|
||||
theme: TuiTheme;
|
||||
name: string;
|
||||
}
|
||||
|
||||
type JsonObject = Record<string, unknown>;
|
||||
|
||||
type ConfigLike = AppConfig & { theme?: string };
|
||||
|
||||
const DEFAULT_THEME: TuiTheme = {
|
||||
background: "#282828",
|
||||
text: "#ffffff",
|
||||
muted: "#6b7280",
|
||||
header: "#6b7280",
|
||||
status: "#6b7280",
|
||||
highlightBg: "#282828",
|
||||
highlightFg: "#ffffff",
|
||||
selectionBorder: "#d946ef",
|
||||
success: "#22c55e",
|
||||
warning: "#eab308",
|
||||
error: "#ef4444",
|
||||
info: "#22d3ee",
|
||||
diffAdd: "#22c55e",
|
||||
diffDel: "#ef4444",
|
||||
diffSep: "#6b7280",
|
||||
agentRunning: "#22c55e",
|
||||
agentIdle: "#eab308",
|
||||
agentNone: "#6b7280",
|
||||
agentError: "#ef4444",
|
||||
prUnpushed: "#eab308",
|
||||
author: "#22d3ee",
|
||||
ciRunning: "#eab308",
|
||||
ciPass: "#22c55e",
|
||||
ciFail: "#ef4444",
|
||||
ciNone: "#6b7280",
|
||||
reviewApproved: "#22c55e",
|
||||
reviewChanges: "#ef4444",
|
||||
reviewPending: "#eab308",
|
||||
reviewNone: "#6b7280"
|
||||
};
|
||||
|
||||
const OPENCODE_THEME_PACK = opencodeThemePackJson as Record<string, unknown>;
|
||||
|
||||
export function resolveTuiTheme(config: AppConfig, baseDir = cwd()): TuiThemeResolution {
|
||||
const mode = opencodeStateThemeMode() ?? "dark";
|
||||
const configWithTheme = config as ConfigLike;
|
||||
const override = typeof configWithTheme.theme === "string" ? configWithTheme.theme.trim() : "";
|
||||
|
||||
if (override) {
|
||||
const candidate = loadFromSpec(override, [], mode, baseDir);
|
||||
if (candidate) {
|
||||
return {
|
||||
theme: candidate.theme,
|
||||
name: candidate.name,
|
||||
source: "factory config",
|
||||
mode
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const fromConfig = loadOpencodeThemeFromConfig(mode, baseDir);
|
||||
if (fromConfig) {
|
||||
return fromConfig;
|
||||
}
|
||||
|
||||
const fromState = loadOpencodeThemeFromState(mode, baseDir);
|
||||
if (fromState) {
|
||||
return fromState;
|
||||
}
|
||||
|
||||
return {
|
||||
theme: DEFAULT_THEME,
|
||||
name: "opencode-default",
|
||||
source: "default",
|
||||
mode
|
||||
};
|
||||
}
|
||||
|
||||
function loadOpencodeThemeFromConfig(mode: ThemeMode, baseDir: string): TuiThemeResolution | null {
|
||||
for (const path of opencodeConfigPaths(baseDir)) {
|
||||
if (!existsSync(path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = readJsonWithComments(path);
|
||||
if (!value) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const themeValue = findOpencodeThemeValue(value);
|
||||
if (themeValue === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const candidate = themeFromOpencodeValue(themeValue, opencodeThemeDirs(dirname(path), baseDir), mode, baseDir);
|
||||
if (!candidate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
theme: candidate.theme,
|
||||
name: candidate.name,
|
||||
source: `opencode config (${path})`,
|
||||
mode
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function loadOpencodeThemeFromState(mode: ThemeMode, baseDir: string): TuiThemeResolution | null {
|
||||
const path = opencodeStatePath();
|
||||
if (!path || !existsSync(path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const value = readJsonWithComments(path);
|
||||
if (!isObject(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const spec = value.theme;
|
||||
if (typeof spec !== "string" || !spec.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidate = loadFromSpec(spec.trim(), opencodeThemeDirs(undefined, baseDir), mode, baseDir);
|
||||
if (!candidate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
theme: candidate.theme,
|
||||
name: candidate.name,
|
||||
source: `opencode state (${path})`,
|
||||
mode
|
||||
};
|
||||
}
|
||||
|
||||
function loadFromSpec(
|
||||
spec: string,
|
||||
searchDirs: string[],
|
||||
mode: ThemeMode,
|
||||
baseDir: string
|
||||
): ThemeCandidate | null {
|
||||
if (isDefaultThemeName(spec)) {
|
||||
return {
|
||||
theme: DEFAULT_THEME,
|
||||
name: "opencode-default"
|
||||
};
|
||||
}
|
||||
|
||||
if (isPathLike(spec)) {
|
||||
const resolved = resolvePath(spec, baseDir);
|
||||
if (existsSync(resolved)) {
|
||||
const candidate = loadThemeFromPath(resolved, mode);
|
||||
if (candidate) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const dir of searchDirs) {
|
||||
for (const ext of ["json", "toml"]) {
|
||||
const path = join(dir, `${spec}.${ext}`);
|
||||
if (!existsSync(path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const candidate = loadThemeFromPath(path, mode);
|
||||
if (candidate) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const builtIn = OPENCODE_THEME_PACK[spec];
|
||||
if (builtIn !== undefined) {
|
||||
const theme = themeFromOpencodeJson(builtIn, mode);
|
||||
if (theme) {
|
||||
return {
|
||||
theme,
|
||||
name: spec
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function loadThemeFromPath(path: string, mode: ThemeMode): ThemeCandidate | null {
|
||||
const content = safeReadText(path);
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lower = path.toLowerCase();
|
||||
if (lower.endsWith(".toml")) {
|
||||
try {
|
||||
const parsed = toml.parse(content);
|
||||
const theme = themeFromAny(parsed);
|
||||
if (!theme) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
theme,
|
||||
name: themeNameFromPath(path)
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const value = parseJsonWithComments(content);
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const opencodeTheme = themeFromOpencodeJson(value, mode);
|
||||
if (opencodeTheme) {
|
||||
return {
|
||||
theme: opencodeTheme,
|
||||
name: themeNameFromPath(path)
|
||||
};
|
||||
}
|
||||
|
||||
const paletteTheme = themeFromAny(value);
|
||||
if (!paletteTheme) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
theme: paletteTheme,
|
||||
name: themeNameFromPath(path)
|
||||
};
|
||||
}
|
||||
|
||||
function themeNameFromPath(path: string): string {
|
||||
const base = path.split(/[\\/]/).pop() ?? path;
|
||||
if (base.endsWith(".json") || base.endsWith(".toml")) {
|
||||
return base.replace(/\.(json|toml)$/i, "");
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
function themeFromOpencodeValue(
|
||||
value: unknown,
|
||||
searchDirs: string[],
|
||||
mode: ThemeMode,
|
||||
baseDir: string
|
||||
): ThemeCandidate | null {
|
||||
if (typeof value === "string") {
|
||||
return loadFromSpec(value, searchDirs, mode, baseDir);
|
||||
}
|
||||
|
||||
if (!isObject(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value.theme !== undefined) {
|
||||
const theme = themeFromOpencodeJson(value, mode);
|
||||
if (theme) {
|
||||
return {
|
||||
theme,
|
||||
name: typeof value.name === "string" ? value.name : "inline"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const paletteTheme = themeFromAny(value.colors ?? value.palette ?? value);
|
||||
if (paletteTheme) {
|
||||
return {
|
||||
theme: paletteTheme,
|
||||
name: typeof value.name === "string" ? value.name : "inline"
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof value.name === "string") {
|
||||
const named = loadFromSpec(value.name, searchDirs, mode, baseDir);
|
||||
if (named) {
|
||||
return named;
|
||||
}
|
||||
}
|
||||
|
||||
const pathLike = value.path ?? value.file;
|
||||
if (typeof pathLike === "string") {
|
||||
const resolved = resolvePath(pathLike, baseDir);
|
||||
const candidate = loadThemeFromPath(resolved, mode);
|
||||
if (candidate) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function themeFromOpencodeJson(value: unknown, mode: ThemeMode): TuiTheme | null {
|
||||
if (!isObject(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const themeMap = value.theme;
|
||||
if (!isObject(themeMap)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const defs = isObject(value.defs) ? value.defs : {};
|
||||
|
||||
const background =
|
||||
opencodeColor(themeMap, defs, mode, "background") ??
|
||||
opencodeColor(themeMap, defs, mode, "backgroundPanel") ??
|
||||
opencodeColor(themeMap, defs, mode, "backgroundElement") ??
|
||||
DEFAULT_THEME.background;
|
||||
|
||||
const text = opencodeColor(themeMap, defs, mode, "text") ?? DEFAULT_THEME.text;
|
||||
const muted = opencodeColor(themeMap, defs, mode, "textMuted") ?? DEFAULT_THEME.muted;
|
||||
const highlightBg = opencodeColor(themeMap, defs, mode, "text") ?? text;
|
||||
const highlightFg =
|
||||
opencodeColor(themeMap, defs, mode, "backgroundElement") ??
|
||||
opencodeColor(themeMap, defs, mode, "backgroundPanel") ??
|
||||
opencodeColor(themeMap, defs, mode, "background") ??
|
||||
DEFAULT_THEME.highlightFg;
|
||||
|
||||
const selectionBorder =
|
||||
opencodeColor(themeMap, defs, mode, "secondary") ??
|
||||
opencodeColor(themeMap, defs, mode, "accent") ??
|
||||
opencodeColor(themeMap, defs, mode, "primary") ??
|
||||
DEFAULT_THEME.selectionBorder;
|
||||
|
||||
const success = opencodeColor(themeMap, defs, mode, "success") ?? DEFAULT_THEME.success;
|
||||
const warning = opencodeColor(themeMap, defs, mode, "warning") ?? DEFAULT_THEME.warning;
|
||||
const error = opencodeColor(themeMap, defs, mode, "error") ?? DEFAULT_THEME.error;
|
||||
const info = opencodeColor(themeMap, defs, mode, "info") ?? DEFAULT_THEME.info;
|
||||
const diffAdd = opencodeColor(themeMap, defs, mode, "diffAdded") ?? success;
|
||||
const diffDel = opencodeColor(themeMap, defs, mode, "diffRemoved") ?? error;
|
||||
const diffSep =
|
||||
opencodeColor(themeMap, defs, mode, "diffContext") ??
|
||||
opencodeColor(themeMap, defs, mode, "diffHunkHeader") ??
|
||||
muted;
|
||||
|
||||
return {
|
||||
background,
|
||||
text,
|
||||
muted,
|
||||
header: muted,
|
||||
status: muted,
|
||||
highlightBg,
|
||||
highlightFg,
|
||||
selectionBorder,
|
||||
success,
|
||||
warning,
|
||||
error,
|
||||
info,
|
||||
diffAdd,
|
||||
diffDel,
|
||||
diffSep,
|
||||
agentRunning: success,
|
||||
agentIdle: warning,
|
||||
agentNone: muted,
|
||||
agentError: error,
|
||||
prUnpushed: warning,
|
||||
author: info,
|
||||
ciRunning: warning,
|
||||
ciPass: success,
|
||||
ciFail: error,
|
||||
ciNone: muted,
|
||||
reviewApproved: success,
|
||||
reviewChanges: error,
|
||||
reviewPending: warning,
|
||||
reviewNone: muted
|
||||
};
|
||||
}
|
||||
|
||||
function opencodeColor(themeMap: JsonObject, defs: JsonObject, mode: ThemeMode, key: string): string | null {
|
||||
const raw = themeMap[key];
|
||||
if (raw === undefined) {
|
||||
return null;
|
||||
}
|
||||
return resolveOpencodeColor(raw, themeMap, defs, mode, 0);
|
||||
}
|
||||
|
||||
function resolveOpencodeColor(
|
||||
value: unknown,
|
||||
themeMap: JsonObject,
|
||||
defs: JsonObject,
|
||||
mode: ThemeMode,
|
||||
depth: number
|
||||
): string | null {
|
||||
if (depth > 12) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || trimmed.toLowerCase() === "transparent" || trimmed.toLowerCase() === "none") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fromDefs = defs[trimmed];
|
||||
if (fromDefs !== undefined) {
|
||||
return resolveOpencodeColor(fromDefs, themeMap, defs, mode, depth + 1);
|
||||
}
|
||||
|
||||
const fromTheme = themeMap[trimmed];
|
||||
if (fromTheme !== undefined) {
|
||||
return resolveOpencodeColor(fromTheme, themeMap, defs, mode, depth + 1);
|
||||
}
|
||||
|
||||
if (isColorLike(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isObject(value)) {
|
||||
const nested = value[mode];
|
||||
if (nested !== undefined) {
|
||||
return resolveOpencodeColor(nested, themeMap, defs, mode, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function themeFromAny(value: unknown): TuiTheme | null {
|
||||
const palette = extractPalette(value);
|
||||
if (!palette) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pick = (keys: string[], fallback: string): string => {
|
||||
for (const key of keys) {
|
||||
const v = palette[normalizeKey(key)];
|
||||
if (v && isColorLike(v)) {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const background = pick(["background", "bg", "base", "background_color"], DEFAULT_THEME.background);
|
||||
const text = pick(["text", "foreground", "fg", "primary"], DEFAULT_THEME.text);
|
||||
const muted = pick(["muted", "subtle", "secondary", "dim"], DEFAULT_THEME.muted);
|
||||
const header = pick(["header", "header_text"], muted);
|
||||
const status = pick(["status", "status_text"], muted);
|
||||
const highlightBg = pick(["highlight_bg", "selection", "highlight", "accent_bg"], DEFAULT_THEME.highlightBg);
|
||||
const highlightFg = pick(["highlight_fg", "selection_fg", "accent_fg"], text);
|
||||
const selectionBorder = pick(["selection_border", "highlight_border", "accent", "secondary"], DEFAULT_THEME.selectionBorder);
|
||||
const success = pick(["success", "green"], DEFAULT_THEME.success);
|
||||
const warning = pick(["warning", "yellow"], DEFAULT_THEME.warning);
|
||||
const error = pick(["error", "red"], DEFAULT_THEME.error);
|
||||
const info = pick(["info", "cyan", "blue"], DEFAULT_THEME.info);
|
||||
const diffAdd = pick(["diff_add", "diff_addition", "add"], success);
|
||||
const diffDel = pick(["diff_del", "diff_deletion", "delete"], error);
|
||||
const diffSep = pick(["diff_sep", "diff_separator", "separator"], muted);
|
||||
|
||||
return {
|
||||
background,
|
||||
text,
|
||||
muted,
|
||||
header,
|
||||
status,
|
||||
highlightBg,
|
||||
highlightFg,
|
||||
selectionBorder,
|
||||
success,
|
||||
warning,
|
||||
error,
|
||||
info,
|
||||
diffAdd,
|
||||
diffDel,
|
||||
diffSep,
|
||||
agentRunning: pick(["agent_running", "running"], success),
|
||||
agentIdle: pick(["agent_idle", "idle"], warning),
|
||||
agentNone: pick(["agent_none", "none"], muted),
|
||||
agentError: pick(["agent_error", "agent_failed"], error),
|
||||
prUnpushed: pick(["pr_unpushed", "unpushed"], warning),
|
||||
author: pick(["author"], info),
|
||||
ciRunning: pick(["ci_running"], warning),
|
||||
ciPass: pick(["ci_pass", "ci_success"], success),
|
||||
ciFail: pick(["ci_fail", "ci_error"], error),
|
||||
ciNone: pick(["ci_none", "ci_unknown"], muted),
|
||||
reviewApproved: pick(["review_approved", "approved"], success),
|
||||
reviewChanges: pick(["review_changes", "changes"], error),
|
||||
reviewPending: pick(["review_pending", "pending"], warning),
|
||||
reviewNone: pick(["review_none", "review_unknown"], muted)
|
||||
};
|
||||
}
|
||||
|
||||
function extractPalette(value: unknown): Record<string, string> | null {
|
||||
if (!isObject(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const colors = isObject(value.colors) ? value.colors : undefined;
|
||||
const palette = isObject(value.palette) ? value.palette : undefined;
|
||||
const source = colors ?? palette ?? value;
|
||||
if (!isObject(source)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const out: Record<string, string> = {};
|
||||
for (const [key, raw] of Object.entries(source)) {
|
||||
if (typeof raw !== "string") {
|
||||
continue;
|
||||
}
|
||||
out[normalizeKey(key)] = raw;
|
||||
}
|
||||
|
||||
return Object.keys(out).length > 0 ? out : null;
|
||||
}
|
||||
|
||||
function normalizeKey(key: string): string {
|
||||
return key.toLowerCase().replace(/[\-\s.]/g, "_");
|
||||
}
|
||||
|
||||
function isColorLike(value: string): boolean {
|
||||
const lower = value.trim().toLowerCase();
|
||||
if (!lower) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (/^#[0-9a-f]{3}$/.test(lower) || /^#[0-9a-f]{6}$/.test(lower) || /^#[0-9a-f]{8}$/.test(lower)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (/^rgba?\(\s*\d+\s*,\s*\d+\s*,\s*\d+(\s*,\s*[\d.]+)?\s*\)$/.test(lower)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return /^[a-z_\-]+$/.test(lower);
|
||||
}
|
||||
|
||||
function findOpencodeThemeValue(value: unknown): unknown {
|
||||
if (!isObject(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (value.theme !== undefined) {
|
||||
return value.theme;
|
||||
}
|
||||
|
||||
return pointer(value, ["ui", "theme"]) ?? pointer(value, ["tui", "theme"]) ?? pointer(value, ["options", "theme"]);
|
||||
}
|
||||
|
||||
function pointer(obj: JsonObject, parts: string[]): unknown {
|
||||
let current: unknown = obj;
|
||||
for (const part of parts) {
|
||||
if (!isObject(current)) {
|
||||
return undefined;
|
||||
}
|
||||
current = current[part];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function opencodeConfigPaths(baseDir: string): string[] {
|
||||
const paths: string[] = [];
|
||||
|
||||
const rootish = opencodeProjectConfigPaths(baseDir);
|
||||
paths.push(...rootish);
|
||||
|
||||
const configDir = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
||||
const opencodeDir = join(configDir, "opencode");
|
||||
paths.push(join(opencodeDir, "opencode.json"));
|
||||
paths.push(join(opencodeDir, "opencode.jsonc"));
|
||||
paths.push(join(opencodeDir, "config.json"));
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
function opencodeThemeDirs(configDir: string | undefined, baseDir: string): string[] {
|
||||
const dirs: string[] = [];
|
||||
|
||||
if (configDir) {
|
||||
dirs.push(join(configDir, "themes"));
|
||||
}
|
||||
|
||||
const xdgConfig = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
||||
dirs.push(join(xdgConfig, "opencode", "themes"));
|
||||
dirs.push(join(homedir(), ".opencode", "themes"));
|
||||
|
||||
dirs.push(...opencodeProjectThemeDirs(baseDir));
|
||||
|
||||
return dirs;
|
||||
}
|
||||
|
||||
function opencodeProjectConfigPaths(baseDir: string): string[] {
|
||||
const dirs = ancestorDirs(baseDir);
|
||||
const out: string[] = [];
|
||||
for (const dir of dirs) {
|
||||
out.push(join(dir, "opencode.json"));
|
||||
out.push(join(dir, "opencode.jsonc"));
|
||||
out.push(join(dir, ".opencode", "opencode.json"));
|
||||
out.push(join(dir, ".opencode", "opencode.jsonc"));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function opencodeProjectThemeDirs(baseDir: string): string[] {
|
||||
const dirs = ancestorDirs(baseDir);
|
||||
const out: string[] = [];
|
||||
for (const dir of dirs) {
|
||||
out.push(join(dir, ".opencode", "themes"));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function ancestorDirs(start: string): string[] {
|
||||
const out: string[] = [];
|
||||
let current = resolve(start);
|
||||
|
||||
while (true) {
|
||||
out.push(current);
|
||||
const parent = dirname(current);
|
||||
if (parent === current) {
|
||||
break;
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function opencodeStatePath(): string | null {
|
||||
const stateHome = process.env.XDG_STATE_HOME || join(homedir(), ".local", "state");
|
||||
return join(stateHome, "opencode", "kv.json");
|
||||
}
|
||||
|
||||
function opencodeStateThemeMode(): ThemeMode | null {
|
||||
const path = opencodeStatePath();
|
||||
if (!path || !existsSync(path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const value = readJsonWithComments(path);
|
||||
if (!isObject(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mode = value.theme_mode;
|
||||
if (typeof mode !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lower = mode.toLowerCase();
|
||||
if (lower === "dark" || lower === "light") {
|
||||
return lower;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseJsonWithComments(content: string): unknown {
|
||||
try {
|
||||
return JSON.parse(content);
|
||||
} catch {
|
||||
// Fall through.
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(stripJsoncComments(content));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readJsonWithComments(path: string): unknown {
|
||||
const content = safeReadText(path);
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
return parseJsonWithComments(content);
|
||||
}
|
||||
|
||||
function stripJsoncComments(input: string): string {
|
||||
let output = "";
|
||||
let i = 0;
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
|
||||
while (i < input.length) {
|
||||
const ch = input[i];
|
||||
|
||||
if (inString) {
|
||||
output += ch;
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
} else if (ch === "\\") {
|
||||
escaped = true;
|
||||
} else if (ch === '"') {
|
||||
inString = false;
|
||||
}
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '"') {
|
||||
inString = true;
|
||||
output += ch;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "/" && input[i + 1] === "/") {
|
||||
i += 2;
|
||||
while (i < input.length && input[i] !== "\n") {
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "/" && input[i + 1] === "*") {
|
||||
i += 2;
|
||||
while (i < input.length) {
|
||||
if (input[i] === "*" && input[i + 1] === "/") {
|
||||
i += 2;
|
||||
break;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
output += ch;
|
||||
i += 1;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function safeReadText(path: string): string | null {
|
||||
try {
|
||||
return readFileSync(path, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePath(path: string, baseDir: string): string {
|
||||
if (path.startsWith("~/")) {
|
||||
return join(homedir(), path.slice(2));
|
||||
}
|
||||
if (isAbsolute(path)) {
|
||||
return path;
|
||||
}
|
||||
return resolve(baseDir, path);
|
||||
}
|
||||
|
||||
function isPathLike(spec: string): boolean {
|
||||
return spec.includes("/") || spec.includes("\\") || spec.endsWith(".json") || spec.endsWith(".toml");
|
||||
}
|
||||
|
||||
function isDefaultThemeName(spec: string): boolean {
|
||||
const lower = spec.toLowerCase();
|
||||
return lower === "default" || lower === "opencode" || lower === "opencode-default" || lower === "system";
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is JsonObject {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,220 +0,0 @@
|
|||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
|
||||
const SYMBOL_RUNNING = "▶";
|
||||
const SYMBOL_IDLE = "✓";
|
||||
const DEFAULT_OPENCODE_ENDPOINT = "http://127.0.0.1:4097/opencode";
|
||||
|
||||
export interface TmuxWindowMatch {
|
||||
target: string;
|
||||
windowName: string;
|
||||
}
|
||||
|
||||
export interface SpawnCreateTmuxWindowInput {
|
||||
branchName: string;
|
||||
targetPath: string;
|
||||
sessionId?: string | null;
|
||||
opencodeEndpoint?: string;
|
||||
}
|
||||
|
||||
export interface SpawnCreateTmuxWindowResult {
|
||||
created: boolean;
|
||||
reason:
|
||||
| "created"
|
||||
| "not-in-tmux"
|
||||
| "not-local-path"
|
||||
| "window-exists"
|
||||
| "tmux-new-window-failed";
|
||||
}
|
||||
|
||||
function isTmuxSession(): boolean {
|
||||
return Boolean(process.env.TMUX);
|
||||
}
|
||||
|
||||
function isAbsoluteLocalPath(path: string): boolean {
|
||||
return path.startsWith("/");
|
||||
}
|
||||
|
||||
function runTmux(args: string[]): boolean {
|
||||
const result = spawnSync("tmux", args, { stdio: "ignore" });
|
||||
return !result.error && result.status === 0;
|
||||
}
|
||||
|
||||
function shellEscape(value: string): string {
|
||||
if (value.length === 0) {
|
||||
return "''";
|
||||
}
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
function opencodeExistsOnPath(): boolean {
|
||||
const probe = spawnSync("which", ["opencode"], { stdio: "ignore" });
|
||||
return !probe.error && probe.status === 0;
|
||||
}
|
||||
|
||||
function resolveOpencodeBinary(): string {
|
||||
const envOverride = process.env.HF_OPENCODE_BIN?.trim();
|
||||
if (envOverride) {
|
||||
return envOverride;
|
||||
}
|
||||
|
||||
if (opencodeExistsOnPath()) {
|
||||
return "opencode";
|
||||
}
|
||||
|
||||
const bundledCandidates = [
|
||||
`${homedir()}/.local/share/sandbox-agent/bin/opencode`,
|
||||
`${homedir()}/.opencode/bin/opencode`
|
||||
];
|
||||
|
||||
for (const candidate of bundledCandidates) {
|
||||
if (existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return "opencode";
|
||||
}
|
||||
|
||||
function attachCommand(sessionId: string, targetPath: string, endpoint: string): string {
|
||||
const opencode = resolveOpencodeBinary();
|
||||
return [
|
||||
shellEscape(opencode),
|
||||
"attach",
|
||||
shellEscape(endpoint),
|
||||
"--session",
|
||||
shellEscape(sessionId),
|
||||
"--dir",
|
||||
shellEscape(targetPath)
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
export function stripStatusPrefix(windowName: string): string {
|
||||
return windowName
|
||||
.trimStart()
|
||||
.replace(new RegExp(`^${SYMBOL_RUNNING}\\s+`), "")
|
||||
.replace(new RegExp(`^${SYMBOL_IDLE}\\s+`), "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function findTmuxWindowsByBranch(branchName: string): TmuxWindowMatch[] {
|
||||
const output = spawnSync(
|
||||
"tmux",
|
||||
["list-windows", "-a", "-F", "#{session_name}:#{window_id}:#{window_name}"],
|
||||
{ encoding: "utf8" }
|
||||
);
|
||||
|
||||
if (output.error || output.status !== 0 || !output.stdout) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const lines = output.stdout.split(/\r?\n/).filter((line) => line.trim().length > 0);
|
||||
const matches: TmuxWindowMatch[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const parts = line.split(":", 3);
|
||||
if (parts.length !== 3) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sessionName = parts[0] ?? "";
|
||||
const windowId = parts[1] ?? "";
|
||||
const windowName = parts[2] ?? "";
|
||||
const clean = stripStatusPrefix(windowName);
|
||||
if (clean !== branchName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
matches.push({
|
||||
target: `${sessionName}:${windowId}`,
|
||||
windowName
|
||||
});
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
export function spawnCreateTmuxWindow(
|
||||
input: SpawnCreateTmuxWindowInput
|
||||
): SpawnCreateTmuxWindowResult {
|
||||
if (!isTmuxSession()) {
|
||||
return { created: false, reason: "not-in-tmux" };
|
||||
}
|
||||
|
||||
if (!isAbsoluteLocalPath(input.targetPath)) {
|
||||
return { created: false, reason: "not-local-path" };
|
||||
}
|
||||
|
||||
if (findTmuxWindowsByBranch(input.branchName).length > 0) {
|
||||
return { created: false, reason: "window-exists" };
|
||||
}
|
||||
|
||||
const windowName = input.sessionId ? `${SYMBOL_RUNNING} ${input.branchName}` : input.branchName;
|
||||
const endpoint = input.opencodeEndpoint ?? DEFAULT_OPENCODE_ENDPOINT;
|
||||
let output = "";
|
||||
try {
|
||||
output = execFileSync(
|
||||
"tmux",
|
||||
[
|
||||
"new-window",
|
||||
"-d",
|
||||
"-P",
|
||||
"-F",
|
||||
"#{window_id}",
|
||||
"-n",
|
||||
windowName,
|
||||
"-c",
|
||||
input.targetPath
|
||||
],
|
||||
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }
|
||||
);
|
||||
} catch {
|
||||
return { created: false, reason: "tmux-new-window-failed" };
|
||||
}
|
||||
|
||||
const windowId = output.trim();
|
||||
if (!windowId) {
|
||||
return { created: false, reason: "tmux-new-window-failed" };
|
||||
}
|
||||
|
||||
if (input.sessionId) {
|
||||
const leftPane = `${windowId}.0`;
|
||||
|
||||
// Split left pane horizontally → creates right pane; capture its pane ID
|
||||
let rightPane: string;
|
||||
try {
|
||||
rightPane = execFileSync(
|
||||
"tmux",
|
||||
["split-window", "-h", "-P", "-F", "#{pane_id}", "-t", leftPane, "-c", input.targetPath],
|
||||
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }
|
||||
).trim();
|
||||
} catch {
|
||||
return { created: true, reason: "created" };
|
||||
}
|
||||
|
||||
if (!rightPane) {
|
||||
return { created: true, reason: "created" };
|
||||
}
|
||||
|
||||
// Split right pane vertically → top-right (rightPane) + bottom-right (new)
|
||||
runTmux(["split-window", "-v", "-t", rightPane, "-c", input.targetPath]);
|
||||
|
||||
// Left pane 60% width, top-right pane 70% height
|
||||
runTmux(["resize-pane", "-t", leftPane, "-x", "60%"]);
|
||||
runTmux(["resize-pane", "-t", rightPane, "-y", "70%"]);
|
||||
|
||||
// Editor in left pane, agent attach in top-right pane
|
||||
runTmux(["send-keys", "-t", leftPane, "nvim .", "Enter"]);
|
||||
runTmux([
|
||||
"send-keys",
|
||||
"-t",
|
||||
rightPane,
|
||||
attachCommand(input.sessionId, input.targetPath, endpoint),
|
||||
"Enter"
|
||||
]);
|
||||
runTmux(["select-pane", "-t", rightPane]);
|
||||
}
|
||||
|
||||
return { created: true, reason: "created" };
|
||||
}
|
||||
|
|
@ -1,640 +0,0 @@
|
|||
import type { AppConfig, HandoffRecord } from "@sandbox-agent/factory-shared";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
createBackendClientFromConfig,
|
||||
filterHandoffs,
|
||||
formatRelativeAge,
|
||||
groupHandoffStatus
|
||||
} from "@sandbox-agent/factory-client";
|
||||
import { CLI_BUILD_ID } from "./build-id.js";
|
||||
import { resolveTuiTheme, type TuiTheme } from "./theme.js";
|
||||
|
||||
interface KeyEventLike {
|
||||
name?: string;
|
||||
ctrl?: boolean;
|
||||
meta?: boolean;
|
||||
}
|
||||
|
||||
const HELP_LINES = [
|
||||
"Shortcuts",
|
||||
"Ctrl-H toggle cheatsheet",
|
||||
"Enter switch to branch",
|
||||
"Ctrl-A attach to session",
|
||||
"Ctrl-O open PR in browser",
|
||||
"Ctrl-X archive branch / close PR",
|
||||
"Ctrl-Y merge highlighted PR",
|
||||
"Ctrl-S sync handoff with remote",
|
||||
"Ctrl-N / Down next row",
|
||||
"Ctrl-P / Up previous row",
|
||||
"Backspace delete filter",
|
||||
"Type filter by branch/PR/author",
|
||||
"Esc / Ctrl-C cancel",
|
||||
"",
|
||||
"Legend",
|
||||
"Agent: \u{1F916} running \u{1F4AC} idle \u25CC queued"
|
||||
];
|
||||
|
||||
const COLUMN_WIDTHS = {
|
||||
diff: 10,
|
||||
agent: 5,
|
||||
pr: 6,
|
||||
author: 10,
|
||||
ci: 7,
|
||||
review: 8,
|
||||
age: 5
|
||||
} as const;
|
||||
|
||||
interface DisplayRow {
|
||||
name: string;
|
||||
diff: string;
|
||||
agent: string;
|
||||
pr: string;
|
||||
author: string;
|
||||
ci: string;
|
||||
review: string;
|
||||
age: string;
|
||||
}
|
||||
|
||||
interface RenderOptions {
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
function pad(input: string, width: number): string {
|
||||
if (width <= 0) {
|
||||
return "";
|
||||
}
|
||||
const chars = Array.from(input);
|
||||
const text = chars.length > width ? `${chars.slice(0, Math.max(1, width - 1)).join("")}…` : input;
|
||||
return text.padEnd(width, " ");
|
||||
}
|
||||
|
||||
function truncateToLen(input: string, maxLen: number): string {
|
||||
if (maxLen <= 0) {
|
||||
return "";
|
||||
}
|
||||
return Array.from(input).slice(0, maxLen).join("");
|
||||
}
|
||||
|
||||
function fitLine(input: string, width: number): string {
|
||||
if (width <= 0) {
|
||||
return "";
|
||||
}
|
||||
const clipped = truncateToLen(input, width);
|
||||
const len = Array.from(clipped).length;
|
||||
if (len >= width) {
|
||||
return clipped;
|
||||
}
|
||||
return `${clipped}${" ".repeat(width - len)}`;
|
||||
}
|
||||
|
||||
function overlayLine(base: string, overlay: string, startCol: number, width: number): string {
|
||||
const out = Array.from(fitLine(base, width));
|
||||
const src = Array.from(truncateToLen(overlay, Math.max(0, width - startCol)));
|
||||
for (let i = 0; i < src.length; i += 1) {
|
||||
const col = startCol + i;
|
||||
if (col >= 0 && col < out.length) {
|
||||
out[col] = src[i] ?? " ";
|
||||
}
|
||||
}
|
||||
return out.join("");
|
||||
}
|
||||
|
||||
function buildFooterLine(width: number, segments: string[], right: string): string {
|
||||
if (width <= 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const rightLen = Array.from(right).length;
|
||||
if (width <= rightLen + 1) {
|
||||
return truncateToLen(right, width);
|
||||
}
|
||||
|
||||
const leftMax = width - rightLen - 1;
|
||||
let used = 0;
|
||||
let left = "";
|
||||
let first = true;
|
||||
|
||||
for (const segment of segments) {
|
||||
const chunk = first ? segment : ` | ${segment}`;
|
||||
const clipped = truncateToLen(chunk, leftMax - used);
|
||||
if (!clipped) {
|
||||
break;
|
||||
}
|
||||
left += clipped;
|
||||
used += Array.from(clipped).length;
|
||||
first = false;
|
||||
if (used >= leftMax) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const padding = " ".repeat(Math.max(0, leftMax - used) + 1);
|
||||
return `${left}${padding}${right}`;
|
||||
}
|
||||
|
||||
function agentSymbol(status: HandoffRecord["status"]): string {
|
||||
const group = groupHandoffStatus(status);
|
||||
if (group === "running") return "🤖";
|
||||
if (group === "idle") return "💬";
|
||||
if (group === "error") return "⚠";
|
||||
if (group === "queued") return "◌";
|
||||
return "-";
|
||||
}
|
||||
|
||||
function toDisplayRow(row: HandoffRecord): DisplayRow {
|
||||
const conflictPrefix = row.conflictsWithMain === "true" ? "\u26A0 " : "";
|
||||
|
||||
const prLabel = row.prUrl
|
||||
? `#${row.prUrl.match(/\/pull\/(\d+)/)?.[1] ?? "?"}`
|
||||
: row.prSubmitted ? "sub" : "-";
|
||||
|
||||
const ciLabel = row.ciStatus ?? "-";
|
||||
const reviewLabel = row.reviewStatus
|
||||
? row.reviewStatus === "approved" ? "ok"
|
||||
: row.reviewStatus === "changes_requested" ? "chg"
|
||||
: row.reviewStatus === "pending" ? "..." : row.reviewStatus
|
||||
: "-";
|
||||
|
||||
return {
|
||||
name: `${conflictPrefix}${row.title || row.branchName}`,
|
||||
diff: row.diffStat ?? "-",
|
||||
agent: agentSymbol(row.status),
|
||||
pr: prLabel,
|
||||
author: row.prAuthor ?? "-",
|
||||
ci: ciLabel,
|
||||
review: reviewLabel,
|
||||
age: formatRelativeAge(row.updatedAt)
|
||||
};
|
||||
}
|
||||
|
||||
function helpLines(width: number): string[] {
|
||||
const popupWidth = Math.max(40, Math.min(width - 2, 100));
|
||||
const innerWidth = Math.max(2, popupWidth - 2);
|
||||
const borderTop = `┌${"─".repeat(innerWidth)}┐`;
|
||||
const borderBottom = `└${"─".repeat(innerWidth)}┘`;
|
||||
|
||||
const lines = [borderTop];
|
||||
for (const line of HELP_LINES) {
|
||||
lines.push(`│${pad(line, innerWidth)}│`);
|
||||
}
|
||||
lines.push(borderBottom);
|
||||
return lines;
|
||||
}
|
||||
|
||||
export function formatRows(
|
||||
rows: HandoffRecord[],
|
||||
selected: number,
|
||||
workspaceId: string,
|
||||
status: string,
|
||||
searchQuery = "",
|
||||
showHelp = false,
|
||||
options: RenderOptions = {}
|
||||
): string {
|
||||
const totalWidth = options.width ?? process.stdout.columns ?? 120;
|
||||
const totalHeight = Math.max(6, options.height ?? process.stdout.rows ?? 24);
|
||||
const fixedWidth =
|
||||
COLUMN_WIDTHS.diff +
|
||||
COLUMN_WIDTHS.agent +
|
||||
COLUMN_WIDTHS.pr +
|
||||
COLUMN_WIDTHS.author +
|
||||
COLUMN_WIDTHS.ci +
|
||||
COLUMN_WIDTHS.review +
|
||||
COLUMN_WIDTHS.age;
|
||||
const separators = 7;
|
||||
const prefixWidth = 2;
|
||||
const branchWidth = Math.max(20, totalWidth - (fixedWidth + separators + prefixWidth));
|
||||
|
||||
const branchHeader = searchQuery ? `Branch/PR: ${searchQuery}_` : "Branch/PR (type to filter)";
|
||||
const header = [
|
||||
` ${pad(branchHeader, branchWidth)} ${pad("Diff", COLUMN_WIDTHS.diff)} ${pad("Agent", COLUMN_WIDTHS.agent)} ${pad("PR", COLUMN_WIDTHS.pr)} ${pad("Author", COLUMN_WIDTHS.author)} ${pad("CI", COLUMN_WIDTHS.ci)} ${pad("Review", COLUMN_WIDTHS.review)} ${pad("Age", COLUMN_WIDTHS.age)}`,
|
||||
"-".repeat(Math.max(24, Math.min(totalWidth, 180)))
|
||||
];
|
||||
|
||||
const body =
|
||||
rows.length === 0
|
||||
? ["No branches found."]
|
||||
: rows.map((row, index) => {
|
||||
const marker = index === selected ? "┃ " : " ";
|
||||
const display = toDisplayRow(row);
|
||||
return `${marker}${pad(display.name, branchWidth)} ${pad(display.diff, COLUMN_WIDTHS.diff)} ${pad(display.agent, COLUMN_WIDTHS.agent)} ${pad(display.pr, COLUMN_WIDTHS.pr)} ${pad(display.author, COLUMN_WIDTHS.author)} ${pad(display.ci, COLUMN_WIDTHS.ci)} ${pad(display.review, COLUMN_WIDTHS.review)} ${pad(display.age, COLUMN_WIDTHS.age)}`;
|
||||
});
|
||||
|
||||
const footer = fitLine(
|
||||
buildFooterLine(
|
||||
totalWidth,
|
||||
["Ctrl-H:cheatsheet", `workspace:${workspaceId}`, status],
|
||||
`v${CLI_BUILD_ID}`
|
||||
),
|
||||
totalWidth,
|
||||
);
|
||||
|
||||
const contentHeight = totalHeight - 1;
|
||||
const lines = [...header, ...body].map((line) => fitLine(line, totalWidth));
|
||||
const page = lines.slice(0, contentHeight);
|
||||
while (page.length < contentHeight) {
|
||||
page.push(" ".repeat(totalWidth));
|
||||
}
|
||||
|
||||
if (showHelp) {
|
||||
const popup = helpLines(totalWidth);
|
||||
const startRow = Math.max(0, Math.floor((contentHeight - popup.length) / 2));
|
||||
for (let i = 0; i < popup.length; i += 1) {
|
||||
const target = startRow + i;
|
||||
if (target >= page.length) {
|
||||
break;
|
||||
}
|
||||
const popupLine = popup[i] ?? "";
|
||||
const popupLen = Array.from(popupLine).length;
|
||||
const startCol = Math.max(0, Math.floor((totalWidth - popupLen) / 2));
|
||||
page[target] = overlayLine(page[target] ?? "", popupLine, startCol, totalWidth);
|
||||
}
|
||||
}
|
||||
|
||||
return [...page, footer].join("\n");
|
||||
}
|
||||
|
||||
interface OpenTuiLike {
|
||||
createCliRenderer?: (options?: Record<string, unknown>) => Promise<any>;
|
||||
TextRenderable?: new (ctx: any, options: { id: string; content: string }) => {
|
||||
content: unknown;
|
||||
fg?: string;
|
||||
bg?: string;
|
||||
};
|
||||
fg?: (color: string) => (input: unknown) => unknown;
|
||||
bg?: (color: string) => (input: unknown) => unknown;
|
||||
StyledText?: new (chunks: unknown[]) => unknown;
|
||||
}
|
||||
|
||||
interface StyledTextApi {
|
||||
fg: (color: string) => (input: unknown) => unknown;
|
||||
bg: (color: string) => (input: unknown) => unknown;
|
||||
StyledText: new (chunks: unknown[]) => unknown;
|
||||
}
|
||||
|
||||
function buildStyledContent(content: string, theme: TuiTheme, api: StyledTextApi): unknown {
|
||||
const lines = content.split("\n");
|
||||
const chunks: unknown[] = [];
|
||||
const footerIndex = Math.max(0, lines.length - 1);
|
||||
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
const line = lines[i] ?? "";
|
||||
|
||||
let fgColor = theme.text;
|
||||
let bgColor: string | undefined;
|
||||
|
||||
if (line.startsWith("┃ ")) {
|
||||
const marker = "┃ ";
|
||||
const rest = line.slice(marker.length);
|
||||
bgColor = theme.highlightBg;
|
||||
const markerChunk = api.bg(bgColor)(api.fg(theme.selectionBorder)(marker));
|
||||
const restChunk = api.bg(bgColor)(api.fg(theme.highlightFg)(rest));
|
||||
chunks.push(markerChunk);
|
||||
chunks.push(restChunk);
|
||||
if (i < lines.length - 1) {
|
||||
chunks.push(api.fg(theme.text)("\n"));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (i === 0) {
|
||||
fgColor = theme.header;
|
||||
} else if (i === 1) {
|
||||
fgColor = theme.muted;
|
||||
} else if (i === footerIndex) {
|
||||
fgColor = theme.status;
|
||||
} else if (line.startsWith("┌") || line.startsWith("│") || line.startsWith("└")) {
|
||||
fgColor = theme.info;
|
||||
}
|
||||
|
||||
let chunk: unknown = api.fg(fgColor)(line);
|
||||
if (bgColor) {
|
||||
chunk = api.bg(bgColor)(chunk);
|
||||
}
|
||||
chunks.push(chunk);
|
||||
|
||||
if (i < lines.length - 1) {
|
||||
chunks.push(api.fg(theme.text)("\n"));
|
||||
}
|
||||
}
|
||||
|
||||
return new api.StyledText(chunks);
|
||||
}
|
||||
|
||||
export async function runTui(config: AppConfig, workspaceId: string): Promise<void> {
|
||||
const core = (await import("@opentui/core")) as OpenTuiLike;
|
||||
const createCliRenderer = core.createCliRenderer;
|
||||
const TextRenderable = core.TextRenderable;
|
||||
const styleApi =
|
||||
core.fg && core.bg && core.StyledText
|
||||
? { fg: core.fg, bg: core.bg, StyledText: core.StyledText }
|
||||
: null;
|
||||
|
||||
if (!createCliRenderer || !TextRenderable) {
|
||||
throw new Error("OpenTUI runtime missing createCliRenderer/TextRenderable exports");
|
||||
}
|
||||
|
||||
const themeResolution = resolveTuiTheme(config);
|
||||
const client = createBackendClientFromConfig(config);
|
||||
const renderer = await createCliRenderer({ exitOnCtrlC: false });
|
||||
const text = new TextRenderable(renderer, {
|
||||
id: "factory-switch",
|
||||
content: "Loading..."
|
||||
});
|
||||
text.fg = themeResolution.theme.text;
|
||||
text.bg = themeResolution.theme.background;
|
||||
renderer.root.add(text);
|
||||
renderer.start();
|
||||
|
||||
let allRows: HandoffRecord[] = [];
|
||||
let filteredRows: HandoffRecord[] = [];
|
||||
let selected = 0;
|
||||
let searchQuery = "";
|
||||
let showHelp = false;
|
||||
let status = "loading...";
|
||||
let busy = false;
|
||||
let closed = false;
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const clampSelected = (): void => {
|
||||
if (filteredRows.length === 0) {
|
||||
selected = 0;
|
||||
return;
|
||||
}
|
||||
if (selected < 0) {
|
||||
selected = 0;
|
||||
return;
|
||||
}
|
||||
if (selected >= filteredRows.length) {
|
||||
selected = filteredRows.length - 1;
|
||||
}
|
||||
};
|
||||
|
||||
const render = (): void => {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
const output = formatRows(filteredRows, selected, workspaceId, status, searchQuery, showHelp, {
|
||||
width: renderer.width ?? process.stdout.columns,
|
||||
height: renderer.height ?? process.stdout.rows
|
||||
});
|
||||
text.content = styleApi
|
||||
? buildStyledContent(output, themeResolution.theme, styleApi)
|
||||
: output;
|
||||
renderer.requestRender();
|
||||
};
|
||||
|
||||
const refresh = async (): Promise<void> => {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
allRows = await client.listHandoffs(workspaceId);
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
filteredRows = filterHandoffs(allRows, searchQuery);
|
||||
clampSelected();
|
||||
status = `handoffs=${allRows.length} filtered=${filteredRows.length}`;
|
||||
} catch (err) {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
status = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
render();
|
||||
};
|
||||
|
||||
const selectedRow = (): HandoffRecord | null => {
|
||||
if (filteredRows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return filteredRows[selected] ?? null;
|
||||
};
|
||||
|
||||
let resolveDone: () => void = () => {};
|
||||
const done = new Promise<void>((resolve) => {
|
||||
resolveDone = () => resolve();
|
||||
});
|
||||
|
||||
const close = (output?: string): void => {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
closed = true;
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
process.off("SIGINT", handleSignal);
|
||||
process.off("SIGTERM", handleSignal);
|
||||
renderer.destroy();
|
||||
if (output) {
|
||||
console.log(output);
|
||||
}
|
||||
resolveDone();
|
||||
};
|
||||
|
||||
const handleSignal = (): void => {
|
||||
close();
|
||||
};
|
||||
|
||||
const runActionWithRefresh = async (
|
||||
label: string,
|
||||
fn: () => Promise<void>,
|
||||
success: string
|
||||
): Promise<void> => {
|
||||
if (busy) {
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
status = `${label}...`;
|
||||
render();
|
||||
try {
|
||||
await fn();
|
||||
status = success;
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
status = err instanceof Error ? err.message : String(err);
|
||||
render();
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
};
|
||||
|
||||
await refresh();
|
||||
timer = setInterval(() => {
|
||||
void refresh();
|
||||
}, 10_000);
|
||||
process.once("SIGINT", handleSignal);
|
||||
process.once("SIGTERM", handleSignal);
|
||||
|
||||
const keyInput = (renderer.keyInput ?? renderer.keyHandler) as
|
||||
| { on: (name: string, cb: (event: KeyEventLike) => void) => void }
|
||||
| undefined;
|
||||
|
||||
if (!keyInput) {
|
||||
clearInterval(timer);
|
||||
renderer.destroy();
|
||||
throw new Error("OpenTUI key input handler is unavailable");
|
||||
}
|
||||
|
||||
keyInput.on("keypress", (event: KeyEventLike) => {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const name = event.name ?? "";
|
||||
const ctrl = Boolean(event.ctrl);
|
||||
|
||||
if (ctrl && name === "h") {
|
||||
showHelp = !showHelp;
|
||||
render();
|
||||
return;
|
||||
}
|
||||
|
||||
if (showHelp) {
|
||||
if (name === "escape") {
|
||||
showHelp = false;
|
||||
render();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (name === "q" || name === "escape" || (ctrl && name === "c")) {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
|
||||
if ((ctrl && name === "n") || name === "down") {
|
||||
if (filteredRows.length > 0) {
|
||||
selected = selected >= filteredRows.length - 1 ? 0 : selected + 1;
|
||||
render();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if ((ctrl && name === "p") || name === "up") {
|
||||
if (filteredRows.length > 0) {
|
||||
selected = selected <= 0 ? filteredRows.length - 1 : selected - 1;
|
||||
render();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (name === "backspace") {
|
||||
searchQuery = searchQuery.slice(0, -1);
|
||||
filteredRows = filterHandoffs(allRows, searchQuery);
|
||||
selected = 0;
|
||||
render();
|
||||
return;
|
||||
}
|
||||
|
||||
if (name === "return" || name === "enter") {
|
||||
const row = selectedRow();
|
||||
if (!row || busy) {
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
status = `switching ${row.handoffId}...`;
|
||||
render();
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await client.switchHandoff(workspaceId, row.handoffId);
|
||||
close(`cd ${result.switchTarget}`);
|
||||
} catch (err) {
|
||||
busy = false;
|
||||
status = err instanceof Error ? err.message : String(err);
|
||||
render();
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctrl && name === "a") {
|
||||
const row = selectedRow();
|
||||
if (!row || busy) {
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
status = `attaching ${row.handoffId}...`;
|
||||
render();
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await client.attachHandoff(workspaceId, row.handoffId);
|
||||
close(`target=${result.target} session=${result.sessionId ?? "none"}`);
|
||||
} catch (err) {
|
||||
busy = false;
|
||||
status = err instanceof Error ? err.message : String(err);
|
||||
render();
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctrl && name === "x") {
|
||||
const row = selectedRow();
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
void runActionWithRefresh(
|
||||
`archiving ${row.handoffId}`,
|
||||
async () => client.runAction(workspaceId, row.handoffId, "archive"),
|
||||
`archived ${row.handoffId}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctrl && name === "s") {
|
||||
const row = selectedRow();
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
void runActionWithRefresh(
|
||||
`syncing ${row.handoffId}`,
|
||||
async () => client.runAction(workspaceId, row.handoffId, "sync"),
|
||||
`synced ${row.handoffId}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctrl && name === "y") {
|
||||
const row = selectedRow();
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
void runActionWithRefresh(
|
||||
`merging ${row.handoffId}`,
|
||||
async () => {
|
||||
await client.runAction(workspaceId, row.handoffId, "merge");
|
||||
await client.runAction(workspaceId, row.handoffId, "archive");
|
||||
},
|
||||
`merged+archived ${row.handoffId}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctrl && name === "o") {
|
||||
const row = selectedRow();
|
||||
if (!row?.prUrl) {
|
||||
status = "no PR URL available for this handoff";
|
||||
render();
|
||||
return;
|
||||
}
|
||||
const openCmd = process.platform === "darwin" ? "open" : "xdg-open";
|
||||
spawnSync(openCmd, [row.prUrl], { stdio: "ignore" });
|
||||
status = `opened ${row.prUrl}`;
|
||||
render();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ctrl && !event.meta && name.length === 1) {
|
||||
searchQuery += name;
|
||||
filteredRows = filterHandoffs(allRows, searchQuery);
|
||||
selected = 0;
|
||||
render();
|
||||
}
|
||||
});
|
||||
|
||||
await done;
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import * as toml from "@iarna/toml";
|
||||
import { ConfigSchema, resolveWorkspaceId, type AppConfig } from "@sandbox-agent/factory-shared";
|
||||
|
||||
export const CONFIG_PATH = `${homedir()}/.config/sandbox-agent-factory/config.toml`;
|
||||
|
||||
export function loadConfig(path = CONFIG_PATH): AppConfig {
|
||||
if (!existsSync(path)) {
|
||||
return ConfigSchema.parse({});
|
||||
}
|
||||
|
||||
const raw = readFileSync(path, "utf8");
|
||||
return ConfigSchema.parse(toml.parse(raw));
|
||||
}
|
||||
|
||||
export function saveConfig(config: AppConfig, path = CONFIG_PATH): void {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, toml.stringify(config), "utf8");
|
||||
}
|
||||
|
||||
export function resolveWorkspace(flagWorkspace: string | undefined, config: AppConfig): string {
|
||||
return resolveWorkspaceId(flagWorkspace, config);
|
||||
}
|
||||
|
|
@ -1,167 +0,0 @@
|
|||
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
|
||||
const { spawnMock, execFileSyncMock } = vi.hoisted(() => ({
|
||||
spawnMock: vi.fn(),
|
||||
execFileSyncMock: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock("node:child_process", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
|
||||
return {
|
||||
...actual,
|
||||
spawn: spawnMock,
|
||||
execFileSync: execFileSyncMock
|
||||
};
|
||||
});
|
||||
|
||||
import { ensureBackendRunning, parseBackendPort } from "../src/backend/manager.js";
|
||||
import { ConfigSchema, type AppConfig } from "@sandbox-agent/factory-shared";
|
||||
|
||||
function backendStateFile(baseDir: string, host: string, port: number, suffix: string): string {
|
||||
const sanitized = host
|
||||
.split("")
|
||||
.map((ch) => (/[a-zA-Z0-9]/.test(ch) ? ch : "-"))
|
||||
.join("");
|
||||
|
||||
return join(baseDir, `backend-${sanitized}-${port}.${suffix}`);
|
||||
}
|
||||
|
||||
function healthyMetadataResponse(): { ok: boolean; json: () => Promise<unknown> } {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
runtime: "rivetkit",
|
||||
actorNames: {
|
||||
workspace: {}
|
||||
}
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
function unhealthyMetadataResponse(): { ok: boolean; json: () => Promise<unknown> } {
|
||||
return {
|
||||
ok: false,
|
||||
json: async () => ({})
|
||||
};
|
||||
}
|
||||
|
||||
describe("backend manager", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalStateDir = process.env.HF_BACKEND_STATE_DIR;
|
||||
const originalBuildId = process.env.HF_BUILD_ID;
|
||||
|
||||
const config: AppConfig = ConfigSchema.parse({
|
||||
auto_submit: true,
|
||||
notify: ["terminal"],
|
||||
workspace: { default: "default" },
|
||||
backend: {
|
||||
host: "127.0.0.1",
|
||||
port: 7741,
|
||||
dbPath: "~/.local/share/sandbox-agent-factory/handoff.db",
|
||||
opencode_poll_interval: 2,
|
||||
github_poll_interval: 30,
|
||||
backup_interval_secs: 3600,
|
||||
backup_retention_days: 7
|
||||
},
|
||||
providers: {
|
||||
daytona: { image: "ubuntu:24.04" }
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.HF_BUILD_ID = "test-build";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
spawnMock.mockReset();
|
||||
execFileSyncMock.mockReset();
|
||||
globalThis.fetch = originalFetch;
|
||||
|
||||
if (originalStateDir === undefined) {
|
||||
delete process.env.HF_BACKEND_STATE_DIR;
|
||||
} else {
|
||||
process.env.HF_BACKEND_STATE_DIR = originalStateDir;
|
||||
}
|
||||
|
||||
if (originalBuildId === undefined) {
|
||||
delete process.env.HF_BUILD_ID;
|
||||
} else {
|
||||
process.env.HF_BUILD_ID = originalBuildId;
|
||||
}
|
||||
});
|
||||
|
||||
it("restarts backend when healthy but build is outdated", async () => {
|
||||
const stateDir = mkdtempSync(join(tmpdir(), "hf-backend-test-"));
|
||||
process.env.HF_BACKEND_STATE_DIR = stateDir;
|
||||
|
||||
const pidPath = backendStateFile(stateDir, config.backend.host, config.backend.port, "pid");
|
||||
const versionPath = backendStateFile(stateDir, config.backend.host, config.backend.port, "version");
|
||||
|
||||
mkdirSync(stateDir, { recursive: true });
|
||||
writeFileSync(pidPath, "999999", "utf8");
|
||||
writeFileSync(versionPath, "old-build", "utf8");
|
||||
|
||||
const fetchMock = vi
|
||||
.fn<() => Promise<{ ok: boolean; json: () => Promise<unknown> }>>()
|
||||
.mockResolvedValueOnce(healthyMetadataResponse())
|
||||
.mockResolvedValueOnce(unhealthyMetadataResponse())
|
||||
.mockResolvedValue(healthyMetadataResponse());
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const fakeChild = Object.assign(new EventEmitter(), {
|
||||
pid: process.pid,
|
||||
unref: vi.fn()
|
||||
}) as unknown as ChildProcess;
|
||||
spawnMock.mockReturnValue(fakeChild);
|
||||
|
||||
await ensureBackendRunning(config);
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1);
|
||||
const launchCommand = spawnMock.mock.calls[0]?.[0];
|
||||
const launchArgs = spawnMock.mock.calls[0]?.[1] as string[] | undefined;
|
||||
expect(
|
||||
launchCommand === "pnpm" ||
|
||||
launchCommand === "bun" ||
|
||||
(typeof launchCommand === "string" && launchCommand.endsWith("/bun"))
|
||||
).toBe(true);
|
||||
expect(launchArgs).toEqual(
|
||||
expect.arrayContaining(["start", "--host", config.backend.host, "--port", String(config.backend.port)])
|
||||
);
|
||||
if (launchCommand === "pnpm") {
|
||||
expect(launchArgs).toEqual(expect.arrayContaining(["exec", "bun", "src/index.ts"]));
|
||||
}
|
||||
expect(readFileSync(pidPath, "utf8").trim()).toBe(String(process.pid));
|
||||
expect(readFileSync(versionPath, "utf8").trim()).toBe("test-build");
|
||||
});
|
||||
|
||||
it("does not restart when backend is healthy and build is current", async () => {
|
||||
const stateDir = mkdtempSync(join(tmpdir(), "hf-backend-test-"));
|
||||
process.env.HF_BACKEND_STATE_DIR = stateDir;
|
||||
|
||||
const versionPath = backendStateFile(stateDir, config.backend.host, config.backend.port, "version");
|
||||
mkdirSync(stateDir, { recursive: true });
|
||||
writeFileSync(versionPath, "test-build", "utf8");
|
||||
|
||||
const fetchMock = vi
|
||||
.fn<() => Promise<{ ok: boolean; json: () => Promise<unknown> }>>()
|
||||
.mockResolvedValue(healthyMetadataResponse());
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
await ensureBackendRunning(config);
|
||||
|
||||
expect(spawnMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("validates backend port parsing", () => {
|
||||
expect(parseBackendPort(undefined, 7741)).toBe(7741);
|
||||
expect(parseBackendPort("8080", 7741)).toBe(8080);
|
||||
expect(() => parseBackendPort("0", 7741)).toThrow("Invalid backend port");
|
||||
expect(() => parseBackendPort("abc", 7741)).toThrow("Invalid backend port");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { sanitizeEditorTask } from "../src/task-editor.js";
|
||||
|
||||
describe("task editor helpers", () => {
|
||||
it("strips comment lines and trims whitespace", () => {
|
||||
const value = sanitizeEditorTask(`
|
||||
# comment
|
||||
Implement feature
|
||||
|
||||
# another comment
|
||||
with more detail
|
||||
`);
|
||||
|
||||
expect(value).toBe("Implement feature\n\nwith more detail");
|
||||
});
|
||||
|
||||
it("returns empty string when only comments are present", () => {
|
||||
const value = sanitizeEditorTask(`
|
||||
# hello
|
||||
# world
|
||||
`);
|
||||
|
||||
expect(value).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { ConfigSchema, type AppConfig } from "@sandbox-agent/factory-shared";
|
||||
import { resolveTuiTheme } from "../src/theme.js";
|
||||
|
||||
function withEnv(key: string, value: string | undefined): void {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
return;
|
||||
}
|
||||
process.env[key] = value;
|
||||
}
|
||||
|
||||
describe("resolveTuiTheme", () => {
|
||||
let tempDir: string | null = null;
|
||||
const originalState = process.env.XDG_STATE_HOME;
|
||||
const originalConfig = process.env.XDG_CONFIG_HOME;
|
||||
|
||||
const baseConfig: AppConfig = ConfigSchema.parse({
|
||||
auto_submit: true,
|
||||
notify: ["terminal"],
|
||||
workspace: { default: "default" },
|
||||
backend: {
|
||||
host: "127.0.0.1",
|
||||
port: 7741,
|
||||
dbPath: "~/.local/share/sandbox-agent-factory/handoff.db",
|
||||
opencode_poll_interval: 2,
|
||||
github_poll_interval: 30,
|
||||
backup_interval_secs: 3600,
|
||||
backup_retention_days: 7
|
||||
},
|
||||
providers: {
|
||||
daytona: { image: "ubuntu:24.04" }
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
withEnv("XDG_STATE_HOME", originalState);
|
||||
withEnv("XDG_CONFIG_HOME", originalConfig);
|
||||
if (tempDir) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
tempDir = null;
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to default theme when no theme sources are present", () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "hf-theme-test-"));
|
||||
withEnv("XDG_STATE_HOME", join(tempDir, "state"));
|
||||
withEnv("XDG_CONFIG_HOME", join(tempDir, "config"));
|
||||
|
||||
const resolution = resolveTuiTheme(baseConfig, tempDir);
|
||||
|
||||
expect(resolution.name).toBe("opencode-default");
|
||||
expect(resolution.source).toBe("default");
|
||||
expect(resolution.theme.text).toBe("#ffffff");
|
||||
});
|
||||
|
||||
it("loads theme from opencode state when configured", () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "hf-theme-test-"));
|
||||
const stateHome = join(tempDir, "state");
|
||||
const configHome = join(tempDir, "config");
|
||||
withEnv("XDG_STATE_HOME", stateHome);
|
||||
withEnv("XDG_CONFIG_HOME", configHome);
|
||||
mkdirSync(join(stateHome, "opencode"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(stateHome, "opencode", "kv.json"),
|
||||
JSON.stringify({ theme: "gruvbox", theme_mode: "dark" }),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
const resolution = resolveTuiTheme(baseConfig, tempDir);
|
||||
|
||||
expect(resolution.name).toBe("gruvbox");
|
||||
expect(resolution.source).toContain("opencode state");
|
||||
expect(resolution.mode).toBe("dark");
|
||||
expect(resolution.theme.selectionBorder.toLowerCase()).not.toContain("dark");
|
||||
});
|
||||
|
||||
it("resolves OpenCode token references in theme defs", () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "hf-theme-test-"));
|
||||
const stateHome = join(tempDir, "state");
|
||||
const configHome = join(tempDir, "config");
|
||||
withEnv("XDG_STATE_HOME", stateHome);
|
||||
withEnv("XDG_CONFIG_HOME", configHome);
|
||||
mkdirSync(join(stateHome, "opencode"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(stateHome, "opencode", "kv.json"),
|
||||
JSON.stringify({ theme: "orng", theme_mode: "dark" }),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
const resolution = resolveTuiTheme(baseConfig, tempDir);
|
||||
|
||||
expect(resolution.name).toBe("orng");
|
||||
expect(resolution.theme.selectionBorder).toBe("#EE7948");
|
||||
expect(resolution.theme.background).toBe("#0a0a0a");
|
||||
});
|
||||
|
||||
it("prefers explicit factory theme override from config", () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "hf-theme-test-"));
|
||||
withEnv("XDG_STATE_HOME", join(tempDir, "state"));
|
||||
withEnv("XDG_CONFIG_HOME", join(tempDir, "config"));
|
||||
|
||||
const config = { ...baseConfig, theme: "default" } as AppConfig & { theme: string };
|
||||
const resolution = resolveTuiTheme(config, tempDir);
|
||||
|
||||
expect(resolution.name).toBe("opencode-default");
|
||||
expect(resolution.source).toBe("factory config");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { stripStatusPrefix } from "../src/tmux.js";
|
||||
|
||||
describe("tmux helpers", () => {
|
||||
it("strips running and idle markers from window names", () => {
|
||||
expect(stripStatusPrefix("▶ feature/auth")).toBe("feature/auth");
|
||||
expect(stripStatusPrefix("✓ feature/auth")).toBe("feature/auth");
|
||||
expect(stripStatusPrefix("feature/auth")).toBe("feature/auth");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { HandoffRecord } from "@sandbox-agent/factory-shared";
|
||||
import { filterHandoffs, fuzzyMatch } from "@sandbox-agent/factory-client";
|
||||
import { formatRows } from "../src/tui.js";
|
||||
|
||||
const sample: HandoffRecord = {
|
||||
workspaceId: "default",
|
||||
repoId: "repo-a",
|
||||
repoRemote: "https://example.com/repo-a.git",
|
||||
handoffId: "handoff-1",
|
||||
branchName: "feature/test",
|
||||
title: "Test Title",
|
||||
task: "Do test",
|
||||
providerId: "daytona",
|
||||
status: "running",
|
||||
statusMessage: null,
|
||||
activeSandboxId: "sandbox-1",
|
||||
activeSessionId: "session-1",
|
||||
sandboxes: [
|
||||
{
|
||||
sandboxId: "sandbox-1",
|
||||
providerId: "daytona",
|
||||
switchTarget: "daytona://sandbox-1",
|
||||
cwd: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
],
|
||||
agentType: null,
|
||||
prSubmitted: false,
|
||||
diffStat: null,
|
||||
prUrl: null,
|
||||
prAuthor: null,
|
||||
ciStatus: null,
|
||||
reviewStatus: null,
|
||||
reviewer: null,
|
||||
conflictsWithMain: null,
|
||||
hasUnpushed: null,
|
||||
parentBranch: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
};
|
||||
|
||||
describe("formatRows", () => {
|
||||
it("renders rust-style table header and empty state", () => {
|
||||
const output = formatRows([], 0, "default", "ok");
|
||||
expect(output).toContain("Branch/PR (type to filter)");
|
||||
expect(output).toContain("No branches found.");
|
||||
expect(output).toContain("Ctrl-H:cheatsheet");
|
||||
expect(output).toContain("ok");
|
||||
});
|
||||
|
||||
it("marks selected row with highlight", () => {
|
||||
const output = formatRows([sample], 0, "default", "ready");
|
||||
expect(output).toContain("┃ ");
|
||||
expect(output).toContain("Test Title");
|
||||
expect(output).toContain("Ctrl-H:cheatsheet");
|
||||
});
|
||||
|
||||
it("pins footer to the last terminal row", () => {
|
||||
const output = formatRows([sample], 0, "default", "ready", "", false, {
|
||||
width: 80,
|
||||
height: 12
|
||||
});
|
||||
const lines = output.split("\n");
|
||||
expect(lines).toHaveLength(12);
|
||||
expect(lines[11]).toContain("Ctrl-H:cheatsheet");
|
||||
expect(lines[11]).toContain("v");
|
||||
});
|
||||
});
|
||||
|
||||
describe("search", () => {
|
||||
it("supports ordered fuzzy matching", () => {
|
||||
expect(fuzzyMatch("feature/test-branch", "ftb")).toBe(true);
|
||||
expect(fuzzyMatch("feature/test-branch", "fbt")).toBe(false);
|
||||
});
|
||||
|
||||
it("filters rows across branch and title", () => {
|
||||
const rows: HandoffRecord[] = [
|
||||
sample,
|
||||
{
|
||||
...sample,
|
||||
handoffId: "handoff-2",
|
||||
branchName: "docs/update-intro",
|
||||
title: "Docs Intro Refresh",
|
||||
status: "idle"
|
||||
}
|
||||
];
|
||||
expect(filterHandoffs(rows, "doc")).toHaveLength(1);
|
||||
expect(filterHandoffs(rows, "h2")).toHaveLength(1);
|
||||
expect(filterHandoffs(rows, "test")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { ConfigSchema } from "@sandbox-agent/factory-shared";
|
||||
import { resolveWorkspace } from "../src/workspace/config.js";
|
||||
|
||||
describe("cli workspace resolution", () => {
|
||||
it("uses default workspace when no flag", () => {
|
||||
const config = ConfigSchema.parse({
|
||||
auto_submit: true as const,
|
||||
notify: ["terminal" as const],
|
||||
workspace: { default: "team" },
|
||||
backend: {
|
||||
host: "127.0.0.1",
|
||||
port: 7741,
|
||||
dbPath: "~/.local/share/sandbox-agent-factory/handoff.db",
|
||||
opencode_poll_interval: 2,
|
||||
github_poll_interval: 30,
|
||||
backup_interval_secs: 3600,
|
||||
backup_retention_days: 7
|
||||
},
|
||||
providers: {
|
||||
daytona: { image: "ubuntu:24.04" }
|
||||
}
|
||||
});
|
||||
|
||||
expect(resolveWorkspace(undefined, config)).toBe("team");
|
||||
expect(resolveWorkspace("alpha", config)).toBe("alpha");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src", "test"]
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
import { execSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { defineConfig } from "tsup";
|
||||
|
||||
function packageVersion(): string {
|
||||
try {
|
||||
const packageJsonPath = resolve(process.cwd(), "package.json");
|
||||
const parsed = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { version?: unknown };
|
||||
if (typeof parsed.version === "string" && parsed.version.trim()) {
|
||||
return parsed.version.trim();
|
||||
}
|
||||
} catch {
|
||||
// Fall through.
|
||||
}
|
||||
return "dev";
|
||||
}
|
||||
|
||||
function sourceId(): string {
|
||||
try {
|
||||
const raw = execSync("git rev-parse --short HEAD", {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"]
|
||||
}).trim();
|
||||
if (raw.length > 0) {
|
||||
return raw;
|
||||
}
|
||||
} catch {
|
||||
// Fall through.
|
||||
}
|
||||
return packageVersion();
|
||||
}
|
||||
|
||||
function resolveBuildId(): string {
|
||||
const override = process.env.HF_BUILD_ID?.trim();
|
||||
if (override) {
|
||||
return override;
|
||||
}
|
||||
|
||||
// Match sandbox-agent semantics: source id + unique build timestamp.
|
||||
return `${sourceId()}-${Date.now().toString()}`;
|
||||
}
|
||||
|
||||
const buildId = resolveBuildId();
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/index.ts"],
|
||||
format: ["esm"],
|
||||
dts: true,
|
||||
define: {
|
||||
__HF_BUILD_ID__: JSON.stringify(buildId)
|
||||
}
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue