mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-16 12:03:27 +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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue