mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 09:01:17 +00:00
Configure lefthook formatter checks (#231)
* Add lefthook formatter checks * Fix SDK mode hydration * Stabilize SDK mode integration test
This commit is contained in:
parent
0471214d65
commit
d2346bafb3
282 changed files with 5840 additions and 8399 deletions
|
|
@ -94,7 +94,7 @@ async function generateOne(drizzleDir: string): Promise<void> {
|
|||
})),
|
||||
},
|
||||
null,
|
||||
2
|
||||
2,
|
||||
);
|
||||
|
||||
const outPath = resolve(drizzleDir, "..", "migrations.ts");
|
||||
|
|
@ -128,9 +128,8 @@ async function main(): Promise<void> {
|
|||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.stack ?? error.message : String(error);
|
||||
const message = error instanceof Error ? (error.stack ?? error.message) : String(error);
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(message);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -8,12 +8,7 @@ let providerRegistry: ProviderRegistry | null = null;
|
|||
let notificationService: NotificationService | null = null;
|
||||
let runtimeDriver: BackendDriver | null = null;
|
||||
|
||||
export function initActorRuntimeContext(
|
||||
config: AppConfig,
|
||||
providers: ProviderRegistry,
|
||||
notifications?: NotificationService,
|
||||
driver?: BackendDriver
|
||||
): void {
|
||||
export function initActorRuntimeContext(config: AppConfig, providers: ProviderRegistry, notifications?: NotificationService, driver?: BackendDriver): void {
|
||||
runtimeConfig = config;
|
||||
providerRegistry = providers;
|
||||
notificationService = notifications ?? null;
|
||||
|
|
|
|||
|
|
@ -1,13 +1,4 @@
|
|||
import {
|
||||
handoffKey,
|
||||
handoffStatusSyncKey,
|
||||
historyKey,
|
||||
projectBranchSyncKey,
|
||||
projectKey,
|
||||
projectPrSyncKey,
|
||||
sandboxInstanceKey,
|
||||
workspaceKey
|
||||
} from "./keys.js";
|
||||
import { handoffKey, handoffStatusSyncKey, historyKey, projectBranchSyncKey, projectKey, projectPrSyncKey, sandboxInstanceKey, workspaceKey } from "./keys.js";
|
||||
import type { ProviderId } from "@openhandoff/shared";
|
||||
|
||||
export function actorClient(c: any) {
|
||||
|
|
@ -16,7 +7,7 @@ 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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -25,8 +16,8 @@ export async function getOrCreateProject(c: any, workspaceId: string, repoId: st
|
|||
createWithInput: {
|
||||
workspaceId,
|
||||
repoId,
|
||||
remoteUrl
|
||||
}
|
||||
remoteUrl,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -38,15 +29,9 @@ export function getHandoff(c: any, workspaceId: string, repoId: string, handoffI
|
|||
return actorClient(c).handoff.get(handoffKey(workspaceId, repoId, handoffId));
|
||||
}
|
||||
|
||||
export async function getOrCreateHandoff(
|
||||
c: any,
|
||||
workspaceId: string,
|
||||
repoId: string,
|
||||
handoffId: string,
|
||||
createWithInput: Record<string, unknown>
|
||||
) {
|
||||
export async function getOrCreateHandoff(c: any, workspaceId: string, repoId: string, handoffId: string, createWithInput: Record<string, unknown>) {
|
||||
return await actorClient(c).handoff.getOrCreate(handoffKey(workspaceId, repoId, handoffId), {
|
||||
createWithInput
|
||||
createWithInput,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -54,42 +39,30 @@ 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(
|
||||
c: any,
|
||||
workspaceId: string,
|
||||
repoId: string,
|
||||
repoPath: string,
|
||||
intervalMs: number
|
||||
) {
|
||||
export async function getOrCreateProjectPrSync(c: any, workspaceId: string, repoId: string, repoPath: string, intervalMs: number) {
|
||||
return await actorClient(c).projectPrSync.getOrCreate(projectPrSyncKey(workspaceId, repoId), {
|
||||
createWithInput: {
|
||||
workspaceId,
|
||||
repoId,
|
||||
repoPath,
|
||||
intervalMs
|
||||
}
|
||||
intervalMs,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getOrCreateProjectBranchSync(
|
||||
c: any,
|
||||
workspaceId: string,
|
||||
repoId: string,
|
||||
repoPath: string,
|
||||
intervalMs: number
|
||||
) {
|
||||
export async function getOrCreateProjectBranchSync(c: any, workspaceId: string, repoId: string, repoPath: string, intervalMs: number) {
|
||||
return await actorClient(c).projectBranchSync.getOrCreate(projectBranchSyncKey(workspaceId, repoId), {
|
||||
createWithInput: {
|
||||
workspaceId,
|
||||
repoId,
|
||||
repoPath,
|
||||
intervalMs
|
||||
}
|
||||
intervalMs,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -102,12 +75,9 @@ 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 }
|
||||
);
|
||||
return await actorClient(c).sandboxInstance.getOrCreate(sandboxInstanceKey(workspaceId, providerId, sandboxId), { createWithInput });
|
||||
}
|
||||
|
||||
export async function getOrCreateHandoffStatusSync(
|
||||
|
|
@ -117,14 +87,11 @@ export async function getOrCreateHandoffStatusSync(
|
|||
handoffId: 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),
|
||||
{
|
||||
createWithInput
|
||||
}
|
||||
);
|
||||
return await actorClient(c).handoffStatusSync.getOrCreate(handoffStatusSyncKey(workspaceId, repoId, handoffId, sandboxId, sessionId), {
|
||||
createWithInput,
|
||||
});
|
||||
}
|
||||
|
||||
export function selfProjectPrSync(c: any) {
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ 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"
|
||||
force: "handoff.status_sync.control.force",
|
||||
} as const;
|
||||
|
||||
async function pollSessionStatus(c: { state: HandoffStatusSyncState }): Promise<void> {
|
||||
|
|
@ -43,7 +43,7 @@ async function pollSessionStatus(c: { state: HandoffStatusSyncState }): Promise<
|
|||
await parent.syncWorkbenchSessionStatus({
|
||||
sessionId: c.state.sessionId,
|
||||
status: status.status,
|
||||
at: Date.now()
|
||||
at: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -56,7 +56,7 @@ export const handoffStatusSync = actor({
|
|||
},
|
||||
options: {
|
||||
// Polling actors rely on timer-based wakeups; sleeping would pause the timer and stop polling.
|
||||
noSleep: true
|
||||
noSleep: true,
|
||||
},
|
||||
createState: (_c, input: HandoffStatusSyncInput): HandoffStatusSyncState => ({
|
||||
workspaceId: input.workspaceId,
|
||||
|
|
@ -66,7 +66,7 @@ export const handoffStatusSync = actor({
|
|||
sandboxId: input.sandboxId,
|
||||
sessionId: input.sessionId,
|
||||
intervalMs: input.intervalMs,
|
||||
running: true
|
||||
running: true,
|
||||
}),
|
||||
actions: {
|
||||
async start(c): Promise<void> {
|
||||
|
|
@ -87,7 +87,7 @@ export const handoffStatusSync = actor({
|
|||
async force(c): Promise<void> {
|
||||
const self = selfHandoffStatusSync(c);
|
||||
await self.send(CONTROL.force, {}, { wait: true, timeout: 5 * 60_000 });
|
||||
}
|
||||
},
|
||||
},
|
||||
run: workflow(async (ctx) => {
|
||||
await runWorkflowPollingLoop<HandoffStatusSyncState>(ctx, {
|
||||
|
|
@ -99,10 +99,10 @@ export const handoffStatusSync = actor({
|
|||
} catch (error) {
|
||||
logActorWarning("handoff-status-sync", "poll failed", {
|
||||
error: resolveErrorMessage(error),
|
||||
stack: resolveErrorStack(error)
|
||||
stack: resolveErrorStack(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
})
|
||||
}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,4 +4,3 @@ export default defineConfig({
|
|||
out: "./src/actors/handoff/db/drizzle",
|
||||
schema: "./src/actors/handoff/db/schema.ts",
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -173,4 +173,4 @@
|
|||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -149,4 +149,4 @@
|
|||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -219,4 +219,4 @@
|
|||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,50 +3,50 @@
|
|||
// Do not hand-edit this file.
|
||||
|
||||
const journal = {
|
||||
"entries": [
|
||||
entries: [
|
||||
{
|
||||
"idx": 0,
|
||||
"when": 1770924374665,
|
||||
"tag": "0000_condemned_maria_hill",
|
||||
"breakpoints": true
|
||||
idx: 0,
|
||||
when: 1770924374665,
|
||||
tag: "0000_condemned_maria_hill",
|
||||
breakpoints: true,
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"when": 1770947251055,
|
||||
"tag": "0001_rapid_eddie_brock",
|
||||
"breakpoints": true
|
||||
idx: 1,
|
||||
when: 1770947251055,
|
||||
tag: "0001_rapid_eddie_brock",
|
||||
breakpoints: true,
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"when": 1770948428907,
|
||||
"tag": "0002_lazy_moira_mactaggert",
|
||||
"breakpoints": true
|
||||
idx: 2,
|
||||
when: 1770948428907,
|
||||
tag: "0002_lazy_moira_mactaggert",
|
||||
breakpoints: true,
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"when": 1771027535276,
|
||||
"tag": "0003_plucky_bran",
|
||||
"breakpoints": true
|
||||
idx: 3,
|
||||
when: 1771027535276,
|
||||
tag: "0003_plucky_bran",
|
||||
breakpoints: true,
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"when": 1771097651912,
|
||||
"tag": "0004_focused_shuri",
|
||||
"breakpoints": true
|
||||
idx: 4,
|
||||
when: 1771097651912,
|
||||
tag: "0004_focused_shuri",
|
||||
breakpoints: true,
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"when": 1771370000000,
|
||||
"tag": "0005_sandbox_actor_id",
|
||||
"breakpoints": true
|
||||
idx: 5,
|
||||
when: 1771370000000,
|
||||
tag: "0005_sandbox_actor_id",
|
||||
breakpoints: true,
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"when": 1773020000000,
|
||||
"tag": "0006_workbench_sessions",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
idx: 6,
|
||||
when: 1773020000000,
|
||||
tag: "0006_workbench_sessions",
|
||||
breakpoints: true,
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
|
||||
export default {
|
||||
|
|
@ -241,5 +241,5 @@ PRAGMA foreign_keys=on;
|
|||
\`created_at\` integer NOT NULL,
|
||||
\`updated_at\` integer NOT NULL
|
||||
);`,
|
||||
} as const
|
||||
} as const,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import type {
|
|||
HandoffWorkbenchSetSessionUnreadInput,
|
||||
HandoffWorkbenchSendMessageInput,
|
||||
HandoffWorkbenchUpdateDraftInput,
|
||||
ProviderId
|
||||
ProviderId,
|
||||
} from "@openhandoff/shared";
|
||||
import { expectQueueResponse } from "../../services/queue.js";
|
||||
import { selfHandoff } from "../handles.js";
|
||||
|
|
@ -30,13 +30,9 @@ import {
|
|||
syncWorkbenchSessionStatus,
|
||||
setWorkbenchSessionUnread,
|
||||
stopWorkbenchSession,
|
||||
updateWorkbenchDraft
|
||||
updateWorkbenchDraft,
|
||||
} from "./workbench.js";
|
||||
import {
|
||||
HANDOFF_QUEUE_NAMES,
|
||||
handoffWorkflowQueueName,
|
||||
runHandoffWorkflow
|
||||
} from "./workflow/index.js";
|
||||
import { HANDOFF_QUEUE_NAMES, handoffWorkflowQueueName, runHandoffWorkflow } from "./workflow/index.js";
|
||||
|
||||
export interface HandoffInput {
|
||||
workspaceId: string;
|
||||
|
|
@ -114,7 +110,7 @@ export const handoff = actor({
|
|||
db: handoffDb,
|
||||
queues: Object.fromEntries(HANDOFF_QUEUE_NAMES.map((name) => [name, queue()])),
|
||||
options: {
|
||||
actionTimeout: 5 * 60_000
|
||||
actionTimeout: 5 * 60_000,
|
||||
},
|
||||
createState: (_c, input: HandoffInput) => ({
|
||||
workspaceId: input.workspaceId,
|
||||
|
|
@ -155,17 +151,21 @@ export const handoff = actor({
|
|||
const self = selfHandoff(c);
|
||||
const result = await self.send(handoffWorkflowQueueName("handoff.command.attach"), cmd ?? {}, {
|
||||
wait: true,
|
||||
timeout: 20_000
|
||||
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
|
||||
});
|
||||
const result = await self.send(
|
||||
handoffWorkflowQueueName("handoff.command.switch"),
|
||||
{},
|
||||
{
|
||||
wait: true,
|
||||
timeout: 20_000,
|
||||
},
|
||||
);
|
||||
return expectQueueResponse<{ switchTarget: string }>(result);
|
||||
},
|
||||
|
||||
|
|
@ -173,7 +173,7 @@ export const handoff = actor({
|
|||
const self = selfHandoff(c);
|
||||
await self.send(handoffWorkflowQueueName("handoff.command.push"), cmd ?? {}, {
|
||||
wait: true,
|
||||
timeout: 180_000
|
||||
timeout: 180_000,
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -181,7 +181,7 @@ export const handoff = actor({
|
|||
const self = selfHandoff(c);
|
||||
await self.send(handoffWorkflowQueueName("handoff.command.sync"), cmd ?? {}, {
|
||||
wait: true,
|
||||
timeout: 30_000
|
||||
timeout: 30_000,
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -189,7 +189,7 @@ export const handoff = actor({
|
|||
const self = selfHandoff(c);
|
||||
await self.send(handoffWorkflowQueueName("handoff.command.merge"), cmd ?? {}, {
|
||||
wait: true,
|
||||
timeout: 30_000
|
||||
timeout: 30_000,
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -212,7 +212,7 @@ export const handoff = actor({
|
|||
const self = selfHandoff(c);
|
||||
await self.send(handoffWorkflowQueueName("handoff.command.kill"), cmd ?? {}, {
|
||||
wait: true,
|
||||
timeout: 60_000
|
||||
timeout: 60_000,
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -225,18 +225,10 @@ export const handoff = actor({
|
|||
},
|
||||
|
||||
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,
|
||||
handoffWorkflowQueueName("handoff.command.workbench.mark_unread"),
|
||||
{},
|
||||
{
|
||||
wait: true,
|
||||
timeout: 20_000,
|
||||
|
|
@ -244,16 +236,20 @@ export const handoff = actor({
|
|||
);
|
||||
},
|
||||
|
||||
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,
|
||||
},
|
||||
);
|
||||
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 }> {
|
||||
|
|
@ -339,26 +335,18 @@ export const handoff = actor({
|
|||
|
||||
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,
|
||||
},
|
||||
);
|
||||
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,
|
||||
},
|
||||
);
|
||||
await self.send(handoffWorkflowQueueName("handoff.command.workbench.sync_session_status"), input, {
|
||||
wait: true,
|
||||
timeout: 20_000,
|
||||
});
|
||||
},
|
||||
|
||||
async closeWorkbenchSession(c, input: HandoffTabCommand): Promise<void> {
|
||||
|
|
@ -375,25 +363,25 @@ export const handoff = actor({
|
|||
|
||||
async publishWorkbenchPr(c): Promise<void> {
|
||||
const self = selfHandoff(c);
|
||||
await self.send(handoffWorkflowQueueName("handoff.command.workbench.publish_pr"), {}, {
|
||||
wait: true,
|
||||
timeout: 10 * 60_000,
|
||||
});
|
||||
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,
|
||||
},
|
||||
);
|
||||
}
|
||||
await self.send(handoffWorkflowQueueName("handoff.command.workbench.revert_file"), input, {
|
||||
wait: true,
|
||||
timeout: 5 * 60_000,
|
||||
});
|
||||
},
|
||||
},
|
||||
run: workflow(runHandoffWorkflow)
|
||||
run: workflow(runHandoffWorkflow),
|
||||
});
|
||||
|
||||
export { HANDOFF_QUEUE_NAMES };
|
||||
|
|
|
|||
|
|
@ -2,12 +2,7 @@
|
|||
import { basename } from "node:path";
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import { getActorRuntimeContext } from "../context.js";
|
||||
import {
|
||||
getOrCreateHandoffStatusSync,
|
||||
getOrCreateProject,
|
||||
getOrCreateWorkspace,
|
||||
getSandboxInstance,
|
||||
} from "../handles.js";
|
||||
import { getOrCreateHandoffStatusSync, getOrCreateProject, getOrCreateWorkspace, getSandboxInstance } from "../handles.js";
|
||||
import { handoff as handoffTable, handoffRuntime, handoffWorkbenchSessions } from "./db/schema.js";
|
||||
import { getCurrentRecord } from "./workflow/common.js";
|
||||
|
||||
|
|
@ -90,11 +85,7 @@ export function shouldMarkSessionUnreadForStatus(meta: { thinkingSinceMs?: numbe
|
|||
|
||||
async function listSessionMetaRows(c: any, options?: { includeClosed?: boolean }): Promise<Array<any>> {
|
||||
await ensureWorkbenchSessionTable(c);
|
||||
const rows = await c.db
|
||||
.select()
|
||||
.from(handoffWorkbenchSessions)
|
||||
.orderBy(asc(handoffWorkbenchSessions.createdAt))
|
||||
.all();
|
||||
const rows = await c.db.select().from(handoffWorkbenchSessions).orderBy(asc(handoffWorkbenchSessions.createdAt)).all();
|
||||
const mapped = rows.map((row: any) => ({
|
||||
...row,
|
||||
id: row.sessionId,
|
||||
|
|
@ -120,11 +111,7 @@ async function nextSessionName(c: any): Promise<string> {
|
|||
|
||||
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))
|
||||
.get();
|
||||
const row = await c.db.select().from(handoffWorkbenchSessions).where(eq(handoffWorkbenchSessions.sessionId, sessionId)).get();
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
|
|
@ -142,12 +129,15 @@ async function readSessionMeta(c: any, sessionId: string): Promise<any | null> {
|
|||
};
|
||||
}
|
||||
|
||||
async function ensureSessionMeta(c: any, params: {
|
||||
sessionId: string;
|
||||
model?: string;
|
||||
sessionName?: string;
|
||||
unread?: boolean;
|
||||
}): Promise<any> {
|
||||
async function ensureSessionMeta(
|
||||
c: any,
|
||||
params: {
|
||||
sessionId: string;
|
||||
model?: string;
|
||||
sessionName?: string;
|
||||
unread?: boolean;
|
||||
},
|
||||
): Promise<any> {
|
||||
await ensureWorkbenchSessionTable(c);
|
||||
const existing = await readSessionMeta(c, params.sessionId);
|
||||
if (existing) {
|
||||
|
|
@ -202,12 +192,15 @@ function shellFragment(parts: string[]): string {
|
|||
return parts.join(" && ");
|
||||
}
|
||||
|
||||
async function executeInSandbox(c: any, params: {
|
||||
sandboxId: string;
|
||||
cwd: string;
|
||||
command: string;
|
||||
label: string;
|
||||
}): Promise<{ exitCode: number; result: string }> {
|
||||
async function executeInSandbox(
|
||||
c: any,
|
||||
params: {
|
||||
sandboxId: string;
|
||||
cwd: string;
|
||||
command: string;
|
||||
label: string;
|
||||
},
|
||||
): Promise<{ exitCode: number; result: string }> {
|
||||
const { providers } = getActorRuntimeContext();
|
||||
const provider = providers.get(c.state.providerId);
|
||||
return await provider.executeCommand({
|
||||
|
|
@ -226,13 +219,8 @@ function parseGitStatus(output: string): Array<{ path: string; type: "M" | "A" |
|
|||
.map((line) => {
|
||||
const status = line.slice(0, 2).trim();
|
||||
const rawPath = line.slice(3).trim();
|
||||
const path = rawPath.includes(" -> ") ? rawPath.split(" -> ").pop() ?? rawPath : rawPath;
|
||||
const type =
|
||||
status.includes("D")
|
||||
? "D"
|
||||
: status.includes("A") || status === "??"
|
||||
? "A"
|
||||
: "M";
|
||||
const path = rawPath.includes(" -> ") ? (rawPath.split(" -> ").pop() ?? rawPath) : rawPath;
|
||||
const type = status.includes("D") ? "D" : status.includes("A") || status === "??" ? "A" : "M";
|
||||
return { path, type };
|
||||
});
|
||||
}
|
||||
|
|
@ -312,10 +300,7 @@ function buildFileTree(paths: string[]): Array<any> {
|
|||
|
||||
async function collectWorkbenchGitState(c: any, record: any) {
|
||||
const activeSandboxId = record.activeSandboxId;
|
||||
const activeSandbox =
|
||||
activeSandboxId != null
|
||||
? (record.sandboxes ?? []).find((candidate: any) => candidate.sandboxId === activeSandboxId) ?? null
|
||||
: null;
|
||||
const activeSandbox = activeSandboxId != null ? ((record.sandboxes ?? []).find((candidate: any) => candidate.sandboxId === activeSandboxId) ?? null) : null;
|
||||
const cwd = activeSandbox?.cwd ?? record.sandboxes?.[0]?.cwd ?? null;
|
||||
if (!activeSandboxId || !cwd) {
|
||||
return {
|
||||
|
|
@ -423,12 +408,7 @@ async function readPullRequestSummary(c: any, branchName: string | null) {
|
|||
}
|
||||
|
||||
try {
|
||||
const project = await getOrCreateProject(
|
||||
c,
|
||||
c.state.workspaceId,
|
||||
c.state.repoId,
|
||||
c.state.repoRemote,
|
||||
);
|
||||
const project = await getOrCreateProject(c, c.state.workspaceId, c.state.repoId, c.state.repoRemote);
|
||||
return await project.getPullRequestForBranch({ branchName });
|
||||
} catch {
|
||||
return null;
|
||||
|
|
@ -528,8 +508,7 @@ export async function renameWorkbenchBranch(c: any, value: string): Promise<void
|
|||
if (!record.activeSandboxId) {
|
||||
throw new Error("cannot rename branch without an active sandbox");
|
||||
}
|
||||
const activeSandbox =
|
||||
(record.sandboxes ?? []).find((candidate: any) => candidate.sandboxId === record.activeSandboxId) ?? null;
|
||||
const activeSandbox = (record.sandboxes ?? []).find((candidate: any) => candidate.sandboxId === record.activeSandboxId) ?? null;
|
||||
if (!activeSandbox?.cwd) {
|
||||
throw new Error("cannot rename branch without a sandbox cwd");
|
||||
}
|
||||
|
|
@ -572,8 +551,7 @@ export async function createWorkbenchSession(c: any, model?: string): Promise<{
|
|||
if (!record.activeSandboxId) {
|
||||
throw new Error("cannot create session without an active sandbox");
|
||||
}
|
||||
const activeSandbox =
|
||||
(record.sandboxes ?? []).find((candidate: any) => candidate.sandboxId === record.activeSandboxId) ?? null;
|
||||
const activeSandbox = (record.sandboxes ?? []).find((candidate: any) => candidate.sandboxId === record.activeSandboxId) ?? null;
|
||||
const cwd = activeSandbox?.cwd ?? record.sandboxes?.[0]?.cwd ?? null;
|
||||
if (!cwd) {
|
||||
throw new Error("cannot create session without a sandbox cwd");
|
||||
|
|
@ -639,10 +617,7 @@ export async function sendWorkbenchMessage(c: any, sessionId: string, text: stri
|
|||
|
||||
await ensureSessionMeta(c, { sessionId });
|
||||
const sandbox = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, record.activeSandboxId);
|
||||
const prompt = [
|
||||
text.trim(),
|
||||
...attachments.map((attachment: any) => `@ ${attachment.filePath}:${attachment.lineNumber}\n${attachment.lineContent}`),
|
||||
]
|
||||
const prompt = [text.trim(), ...attachments.map((attachment: any) => `@ ${attachment.filePath}:${attachment.lineNumber}\n${attachment.lineContent}`)]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
if (!prompt) {
|
||||
|
|
@ -673,23 +648,15 @@ export async function sendWorkbenchMessage(c: any, sessionId: string, text: stri
|
|||
.where(eq(handoffRuntime.id, 1))
|
||||
.run();
|
||||
|
||||
const sync = await getOrCreateHandoffStatusSync(
|
||||
c,
|
||||
c.state.workspaceId,
|
||||
c.state.repoId,
|
||||
c.state.handoffId,
|
||||
record.activeSandboxId,
|
||||
const sync = await getOrCreateHandoffStatusSync(c, c.state.workspaceId, c.state.repoId, c.state.handoffId, record.activeSandboxId, sessionId, {
|
||||
workspaceId: c.state.workspaceId,
|
||||
repoId: c.state.repoId,
|
||||
handoffId: c.state.handoffId,
|
||||
providerId: c.state.providerId,
|
||||
sandboxId: record.activeSandboxId,
|
||||
sessionId,
|
||||
{
|
||||
workspaceId: c.state.workspaceId,
|
||||
repoId: c.state.repoId,
|
||||
handoffId: c.state.handoffId,
|
||||
providerId: c.state.providerId,
|
||||
sandboxId: record.activeSandboxId,
|
||||
sessionId,
|
||||
intervalMs: STATUS_SYNC_INTERVAL_MS,
|
||||
},
|
||||
);
|
||||
intervalMs: STATUS_SYNC_INTERVAL_MS,
|
||||
});
|
||||
await sync.setIntervalMs({ intervalMs: STATUS_SYNC_INTERVAL_MS });
|
||||
await sync.start();
|
||||
await sync.force();
|
||||
|
|
@ -709,12 +676,7 @@ export async function stopWorkbenchSession(c: any, sessionId: string): Promise<v
|
|||
await notifyWorkbenchUpdated(c);
|
||||
}
|
||||
|
||||
export async function syncWorkbenchSessionStatus(
|
||||
c: any,
|
||||
sessionId: string,
|
||||
status: "running" | "idle" | "error",
|
||||
at: number,
|
||||
): Promise<void> {
|
||||
export async function syncWorkbenchSessionStatus(c: any, sessionId: string, status: "running" | "idle" | "error", at: number): Promise<void> {
|
||||
const record = await ensureWorkbenchSeeded(c);
|
||||
const meta = await ensureSessionMeta(c, { sessionId });
|
||||
let changed = false;
|
||||
|
|
@ -821,11 +783,7 @@ export async function publishWorkbenchPr(c: any): Promise<void> {
|
|||
throw new Error("cannot publish PR without a branch");
|
||||
}
|
||||
const { driver } = getActorRuntimeContext();
|
||||
const created = await driver.github.createPr(
|
||||
c.state.repoLocalPath,
|
||||
record.branchName,
|
||||
record.title ?? c.state.task,
|
||||
);
|
||||
const created = await driver.github.createPr(c.state.repoLocalPath, record.branchName, record.title ?? c.state.task);
|
||||
await c.db
|
||||
.update(handoffTable)
|
||||
.set({
|
||||
|
|
@ -842,8 +800,7 @@ export async function revertWorkbenchFile(c: any, path: string): Promise<void> {
|
|||
if (!record.activeSandboxId) {
|
||||
throw new Error("cannot revert file without an active sandbox");
|
||||
}
|
||||
const activeSandbox =
|
||||
(record.sandboxes ?? []).find((candidate: any) => candidate.sandboxId === record.activeSandboxId) ?? null;
|
||||
const activeSandbox = (record.sandboxes ?? []).find((candidate: any) => candidate.sandboxId === record.activeSandboxId) ?? null;
|
||||
if (!activeSandbox?.cwd) {
|
||||
throw new Error("cannot revert file without a sandbox cwd");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: str
|
|||
promise,
|
||||
new Promise<T>((_resolve, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs);
|
||||
})
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) {
|
||||
|
|
@ -26,34 +26,27 @@ async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: str
|
|||
export async function handleAttachActivity(loopCtx: any, msg: any): Promise<void> {
|
||||
const record = await getCurrentRecord(loopCtx);
|
||||
const { providers } = getActorRuntimeContext();
|
||||
const activeSandbox =
|
||||
record.activeSandboxId
|
||||
? record.sandboxes.find((sb: any) => sb.sandboxId === record.activeSandboxId) ?? null
|
||||
: null;
|
||||
const activeSandbox = record.activeSandboxId ? (record.sandboxes.find((sb: any) => sb.sandboxId === record.activeSandboxId) ?? null) : null;
|
||||
const provider = providers.get(activeSandbox?.providerId ?? record.providerId);
|
||||
const target = await provider.attachTarget({
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
sandboxId: record.activeSandboxId ?? ""
|
||||
sandboxId: record.activeSandboxId ?? "",
|
||||
});
|
||||
|
||||
await appendHistory(loopCtx, "handoff.attach", {
|
||||
target: target.target,
|
||||
sessionId: record.activeSessionId
|
||||
sessionId: record.activeSessionId,
|
||||
});
|
||||
|
||||
await msg.complete({
|
||||
target: target.target,
|
||||
sessionId: record.activeSessionId
|
||||
sessionId: record.activeSessionId,
|
||||
});
|
||||
}
|
||||
|
||||
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))
|
||||
.get();
|
||||
const runtime = await db.select({ switchTarget: handoffRuntime.activeSwitchTarget }).from(handoffRuntime).where(eq(handoffRuntime.id, HANDOFF_ROW_ID)).get();
|
||||
|
||||
await msg.complete({ switchTarget: runtime?.switchTarget ?? "" });
|
||||
}
|
||||
|
|
@ -61,23 +54,14 @@ 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: "handoff.push",
|
||||
});
|
||||
await msg.complete({ ok: true });
|
||||
}
|
||||
|
||||
export async function handleSimpleCommandActivity(
|
||||
loopCtx: any,
|
||||
msg: any,
|
||||
statusMessage: string,
|
||||
historyKind: string
|
||||
): Promise<void> {
|
||||
export async function handleSimpleCommandActivity(loopCtx: any, msg: any, statusMessage: string, historyKind: string): Promise<void> {
|
||||
const db = loopCtx.db;
|
||||
await db
|
||||
.update(handoffRuntime)
|
||||
.set({ statusMessage, updatedAt: Date.now() })
|
||||
.where(eq(handoffRuntime.id, HANDOFF_ROW_ID))
|
||||
.run();
|
||||
await db.update(handoffRuntime).set({ statusMessage, updatedAt: Date.now() }).where(eq(handoffRuntime.id, HANDOFF_ROW_ID)).run();
|
||||
|
||||
await appendHistory(loopCtx, historyKind, { reason: msg.body?.reason ?? null });
|
||||
await msg.complete({ ok: true });
|
||||
|
|
@ -103,8 +87,8 @@ export async function handleArchiveActivity(loopCtx: any, msg: any): Promise<voi
|
|||
providerId: record.providerId,
|
||||
sandboxId: record.activeSandboxId,
|
||||
sessionId: record.activeSessionId,
|
||||
intervalMs: 2_000
|
||||
}
|
||||
intervalMs: 2_000,
|
||||
},
|
||||
);
|
||||
await withTimeout(sync.stop(), 15_000, "handoff status sync stop");
|
||||
} catch (error) {
|
||||
|
|
@ -114,7 +98,7 @@ export async function handleArchiveActivity(loopCtx: any, msg: any): Promise<voi
|
|||
handoffId: loopCtx.state.handoffId,
|
||||
sandboxId: record.activeSandboxId,
|
||||
sessionId: record.activeSessionId,
|
||||
error: resolveErrorMessage(error)
|
||||
error: resolveErrorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -122,8 +106,7 @@ export async function handleArchiveActivity(loopCtx: any, msg: any): Promise<voi
|
|||
if (record.activeSandboxId) {
|
||||
await setHandoffState(loopCtx, "archive_release_sandbox", "releasing sandbox");
|
||||
const { providers } = getActorRuntimeContext();
|
||||
const activeSandbox =
|
||||
record.sandboxes.find((sb: any) => sb.sandboxId === record.activeSandboxId) ?? null;
|
||||
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;
|
||||
|
|
@ -135,28 +118,24 @@ export async function handleArchiveActivity(loopCtx: any, msg: any): Promise<voi
|
|||
void withTimeout(
|
||||
provider.releaseSandbox({
|
||||
workspaceId,
|
||||
sandboxId
|
||||
sandboxId,
|
||||
}),
|
||||
45_000,
|
||||
"provider releaseSandbox"
|
||||
"provider releaseSandbox",
|
||||
).catch((error) => {
|
||||
logActorWarning("handoff.commands", "failed to release sandbox during archive", {
|
||||
workspaceId,
|
||||
repoId,
|
||||
handoffId,
|
||||
sandboxId,
|
||||
error: resolveErrorMessage(error)
|
||||
error: resolveErrorMessage(error),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const db = loopCtx.db;
|
||||
await setHandoffState(loopCtx, "archive_finalize", "finalizing archive");
|
||||
await db
|
||||
.update(handoffTable)
|
||||
.set({ status: "archived", updatedAt: Date.now() })
|
||||
.where(eq(handoffTable.id, HANDOFF_ROW_ID))
|
||||
.run();
|
||||
await db.update(handoffTable).set({ status: "archived", updatedAt: Date.now() }).where(eq(handoffTable.id, HANDOFF_ROW_ID)).run();
|
||||
|
||||
await db
|
||||
.update(handoffRuntime)
|
||||
|
|
@ -176,29 +155,20 @@ export async function killDestroySandboxActivity(loopCtx: any): Promise<void> {
|
|||
}
|
||||
|
||||
const { providers } = getActorRuntimeContext();
|
||||
const activeSandbox =
|
||||
record.sandboxes.find((sb: any) => sb.sandboxId === record.activeSandboxId) ?? null;
|
||||
const activeSandbox = record.sandboxes.find((sb: any) => sb.sandboxId === record.activeSandboxId) ?? null;
|
||||
const provider = providers.get(activeSandbox?.providerId ?? record.providerId);
|
||||
await provider.destroySandbox({
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
sandboxId: record.activeSandboxId
|
||||
sandboxId: record.activeSandboxId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function killWriteDbActivity(loopCtx: any, msg: any): Promise<void> {
|
||||
await setHandoffState(loopCtx, "kill_finalize", "finalizing kill");
|
||||
const db = loopCtx.db;
|
||||
await db
|
||||
.update(handoffTable)
|
||||
.set({ status: "killed", updatedAt: Date.now() })
|
||||
.where(eq(handoffTable.id, HANDOFF_ROW_ID))
|
||||
.run();
|
||||
await db.update(handoffTable).set({ status: "killed", updatedAt: Date.now() }).where(eq(handoffTable.id, HANDOFF_ROW_ID)).run();
|
||||
|
||||
await db
|
||||
.update(handoffRuntime)
|
||||
.set({ statusMessage: "killed", updatedAt: Date.now() })
|
||||
.where(eq(handoffRuntime.id, HANDOFF_ROW_ID))
|
||||
.run();
|
||||
await db.update(handoffRuntime).set({ statusMessage: "killed", updatedAt: Date.now() }).where(eq(handoffRuntime.id, HANDOFF_ROW_ID)).run();
|
||||
|
||||
await appendHistory(loopCtx, "handoff.kill", { reason: msg.body?.reason ?? null });
|
||||
await msg.complete({ ok: true });
|
||||
|
|
|
|||
|
|
@ -48,9 +48,7 @@ export function resolveErrorDetail(error: unknown): string {
|
|||
return String(error);
|
||||
}
|
||||
|
||||
const nonWorkflowWrapper = messages.find(
|
||||
(msg) => !/^Step\s+"[^"]+"\s+failed\b/i.test(msg)
|
||||
);
|
||||
const nonWorkflowWrapper = messages.find((msg) => !/^Step\s+"[^"]+"\s+failed\b/i.test(msg));
|
||||
return nonWorkflowWrapper ?? messages[0]!;
|
||||
}
|
||||
|
||||
|
|
@ -58,18 +56,10 @@ export function buildAgentPrompt(task: string): string {
|
|||
return task.trim();
|
||||
}
|
||||
|
||||
export async function setHandoffState(
|
||||
ctx: any,
|
||||
status: HandoffStatus,
|
||||
statusMessage?: string
|
||||
): Promise<void> {
|
||||
export async function setHandoffState(ctx: any, status: HandoffStatus, statusMessage?: string): Promise<void> {
|
||||
const now = Date.now();
|
||||
const db = ctx.db;
|
||||
await db
|
||||
.update(handoffTable)
|
||||
.set({ status, updatedAt: now })
|
||||
.where(eq(handoffTable.id, HANDOFF_ROW_ID))
|
||||
.run();
|
||||
await db.update(handoffTable).set({ status, updatedAt: now }).where(eq(handoffTable.id, HANDOFF_ROW_ID)).run();
|
||||
|
||||
if (statusMessage != null) {
|
||||
await db
|
||||
|
|
@ -81,14 +71,14 @@ export async function setHandoffState(
|
|||
activeSwitchTarget: null,
|
||||
activeCwd: null,
|
||||
statusMessage,
|
||||
updatedAt: now
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: handoffRuntime.id,
|
||||
set: {
|
||||
statusMessage,
|
||||
updatedAt: now
|
||||
}
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
|
@ -112,7 +102,7 @@ export async function getCurrentRecord(ctx: any): Promise<HandoffRecord> {
|
|||
agentType: handoffTable.agentType,
|
||||
prSubmitted: handoffTable.prSubmitted,
|
||||
createdAt: handoffTable.createdAt,
|
||||
updatedAt: handoffTable.updatedAt
|
||||
updatedAt: handoffTable.updatedAt,
|
||||
})
|
||||
.from(handoffTable)
|
||||
.leftJoin(handoffRuntime, eq(handoffTable.id, handoffRuntime.id))
|
||||
|
|
@ -176,15 +166,14 @@ export async function getCurrentRecord(ctx: any): Promise<HandoffRecord> {
|
|||
|
||||
export async function appendHistory(ctx: any, kind: string, payload: Record<string, unknown>): Promise<void> {
|
||||
const client = ctx.client();
|
||||
const history = await client.history.getOrCreate(
|
||||
historyKey(ctx.state.workspaceId, ctx.state.repoId),
|
||||
{ createWithInput: { workspaceId: ctx.state.workspaceId, repoId: ctx.state.repoId } }
|
||||
);
|
||||
const history = await client.history.getOrCreate(historyKey(ctx.state.workspaceId, ctx.state.repoId), {
|
||||
createWithInput: { workspaceId: ctx.state.workspaceId, repoId: ctx.state.repoId },
|
||||
});
|
||||
await history.append({
|
||||
kind,
|
||||
handoffId: ctx.state.handoffId,
|
||||
branchName: ctx.state.branchName,
|
||||
payload
|
||||
payload,
|
||||
});
|
||||
|
||||
const workspace = await getOrCreateWorkspace(ctx, ctx.state.workspaceId);
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import {
|
|||
initFailedActivity,
|
||||
initStartSandboxInstanceActivity,
|
||||
initStartStatusSyncActivity,
|
||||
initWriteDbActivity
|
||||
initWriteDbActivity,
|
||||
} from "./init.js";
|
||||
import {
|
||||
handleArchiveActivity,
|
||||
|
|
@ -23,7 +23,7 @@ import {
|
|||
handleSimpleCommandActivity,
|
||||
handleSwitchActivity,
|
||||
killDestroySandboxActivity,
|
||||
killWriteDbActivity
|
||||
killWriteDbActivity,
|
||||
} from "./commands.js";
|
||||
import { idleNotifyActivity, idleSubmitPrActivity, statusUpdateActivity } from "./status-sync.js";
|
||||
import { HANDOFF_QUEUE_NAMES } from "./queue.js";
|
||||
|
|
@ -57,16 +57,13 @@ const commandHandlers: Record<HandoffQueueName, WorkflowHandler> = {
|
|||
await loopCtx.step("init-bootstrap-db", async () => initBootstrapDbActivity(loopCtx, body));
|
||||
await loopCtx.removed("init-enqueue-provision", "step");
|
||||
await loopCtx.removed("init-dispatch-provision-v2", "step");
|
||||
const currentRecord = await loopCtx.step(
|
||||
"init-read-current-record",
|
||||
async () => getCurrentRecord(loopCtx)
|
||||
);
|
||||
const currentRecord = await loopCtx.step("init-read-current-record", async () => getCurrentRecord(loopCtx));
|
||||
|
||||
try {
|
||||
await msg.complete(currentRecord);
|
||||
} catch (error) {
|
||||
logActorWarning("handoff.workflow", "initialize completion failed", {
|
||||
error: resolveErrorMessage(error)
|
||||
error: resolveErrorMessage(error),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
|
@ -99,10 +96,7 @@ const commandHandlers: Record<HandoffQueueName, WorkflowHandler> = {
|
|||
run: async () => initCreateSessionActivity(loopCtx, body, sandbox, sandboxInstanceReady),
|
||||
});
|
||||
|
||||
await loopCtx.step(
|
||||
"init-write-db",
|
||||
async () => initWriteDbActivity(loopCtx, body, sandbox, session, sandboxInstanceReady)
|
||||
);
|
||||
await loopCtx.step("init-write-db", async () => initWriteDbActivity(loopCtx, body, sandbox, session, sandboxInstanceReady));
|
||||
await loopCtx.step("init-start-status-sync", async () => initStartStatusSyncActivity(loopCtx, body, sandbox, session));
|
||||
await loopCtx.step("init-complete", async () => initCompleteActivity(loopCtx, body, sandbox, session));
|
||||
await msg.complete({ ok: true });
|
||||
|
|
@ -125,17 +119,11 @@ const commandHandlers: Record<HandoffQueueName, WorkflowHandler> = {
|
|||
},
|
||||
|
||||
"handoff.command.sync": async (loopCtx, msg) => {
|
||||
await loopCtx.step(
|
||||
"handle-sync",
|
||||
async () => handleSimpleCommandActivity(loopCtx, msg, "sync requested", "handoff.sync")
|
||||
);
|
||||
await loopCtx.step("handle-sync", async () => handleSimpleCommandActivity(loopCtx, msg, "sync requested", "handoff.sync"));
|
||||
},
|
||||
|
||||
"handoff.command.merge": async (loopCtx, msg) => {
|
||||
await loopCtx.step(
|
||||
"handle-merge",
|
||||
async () => handleSimpleCommandActivity(loopCtx, msg, "merge requested", "handoff.merge")
|
||||
);
|
||||
await loopCtx.step("handle-merge", async () => handleSimpleCommandActivity(loopCtx, msg, "merge requested", "handoff.merge"));
|
||||
},
|
||||
|
||||
"handoff.command.archive": async (loopCtx, msg) => {
|
||||
|
|
@ -180,30 +168,22 @@ const commandHandlers: Record<HandoffQueueName, WorkflowHandler> = {
|
|||
},
|
||||
|
||||
"handoff.command.workbench.rename_session": async (loopCtx, msg) => {
|
||||
await loopCtx.step("workbench-rename-session", async () =>
|
||||
renameWorkbenchSession(loopCtx, msg.body.sessionId, msg.body.title),
|
||||
);
|
||||
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) => {
|
||||
await loopCtx.step("workbench-set-session-unread", async () =>
|
||||
setWorkbenchSessionUnread(loopCtx, msg.body.sessionId, msg.body.unread),
|
||||
);
|
||||
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) => {
|
||||
await loopCtx.step("workbench-update-draft", async () =>
|
||||
updateWorkbenchDraft(loopCtx, msg.body.sessionId, msg.body.text, msg.body.attachments),
|
||||
);
|
||||
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) => {
|
||||
await loopCtx.step("workbench-change-model", async () =>
|
||||
changeWorkbenchModel(loopCtx, msg.body.sessionId, msg.body.model),
|
||||
);
|
||||
await loopCtx.step("workbench-change-model", async () => changeWorkbenchModel(loopCtx, msg.body.sessionId, msg.body.model));
|
||||
await msg.complete({ ok: true });
|
||||
},
|
||||
|
||||
|
|
@ -226,9 +206,7 @@ const commandHandlers: Record<HandoffQueueName, WorkflowHandler> = {
|
|||
},
|
||||
|
||||
"handoff.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 loopCtx.step("workbench-sync-session-status", async () => syncWorkbenchSessionStatus(loopCtx, msg.body.sessionId, msg.body.status, msg.body.at));
|
||||
await msg.complete({ ok: true });
|
||||
},
|
||||
|
||||
|
|
@ -269,14 +247,14 @@ const commandHandlers: Record<HandoffQueueName, WorkflowHandler> = {
|
|||
}
|
||||
await loopCtx.step("idle-notify", async () => idleNotifyActivity(loopCtx));
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export async function runHandoffWorkflow(ctx: any): Promise<void> {
|
||||
await ctx.loop("handoff-command-loop", async (loopCtx: any) => {
|
||||
const msg = await loopCtx.queue.next("next-command", {
|
||||
names: [...HANDOFF_QUEUE_NAMES],
|
||||
completable: true
|
||||
completable: true,
|
||||
});
|
||||
if (!msg) {
|
||||
return Loop.continue(undefined);
|
||||
|
|
|
|||
|
|
@ -8,18 +8,11 @@ import {
|
|||
getOrCreateProject,
|
||||
getOrCreateSandboxInstance,
|
||||
getSandboxInstance,
|
||||
selfHandoff
|
||||
selfHandoff,
|
||||
} from "../../handles.js";
|
||||
import { logActorWarning, resolveErrorMessage } from "../../logging.js";
|
||||
import { handoff as handoffTable, handoffRuntime, handoffSandboxes } from "../db/schema.js";
|
||||
import {
|
||||
HANDOFF_ROW_ID,
|
||||
appendHistory,
|
||||
buildAgentPrompt,
|
||||
collectErrorMessages,
|
||||
resolveErrorDetail,
|
||||
setHandoffState
|
||||
} from "./common.js";
|
||||
import { HANDOFF_ROW_ID, appendHistory, buildAgentPrompt, collectErrorMessages, resolveErrorDetail, setHandoffState } from "./common.js";
|
||||
import { handoffWorkflowQueueName } from "./queue.js";
|
||||
|
||||
const DEFAULT_INIT_CREATE_SANDBOX_ACTIVITY_TIMEOUT_MS = 180_000;
|
||||
|
|
@ -43,15 +36,11 @@ function debugInit(loopCtx: any, message: string, context?: Record<string, unkno
|
|||
workspaceId: loopCtx.state.workspaceId,
|
||||
repoId: loopCtx.state.repoId,
|
||||
handoffId: loopCtx.state.handoffId,
|
||||
...(context ?? {})
|
||||
...(context ?? {}),
|
||||
});
|
||||
}
|
||||
|
||||
async function withActivityTimeout<T>(
|
||||
timeoutMs: number,
|
||||
label: string,
|
||||
run: () => Promise<T>
|
||||
): Promise<T> {
|
||||
async function withActivityTimeout<T>(timeoutMs: number, label: string, run: () => Promise<T>): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
try {
|
||||
return await Promise.race([
|
||||
|
|
@ -60,7 +49,7 @@ async function withActivityTimeout<T>(
|
|||
timer = setTimeout(() => {
|
||||
reject(new Error(`${label} timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
})
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) {
|
||||
|
|
@ -88,7 +77,7 @@ export async function initBootstrapDbActivity(loopCtx: any, body: any): Promise<
|
|||
status: "init_bootstrap_db",
|
||||
agentType: loopCtx.state.agentType ?? config.default_agent,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: handoffTable.id,
|
||||
|
|
@ -99,8 +88,8 @@ export async function initBootstrapDbActivity(loopCtx: any, body: any): Promise<
|
|||
providerId,
|
||||
status: "init_bootstrap_db",
|
||||
agentType: loopCtx.state.agentType ?? config.default_agent,
|
||||
updatedAt: now
|
||||
}
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
|
||||
|
|
@ -113,7 +102,7 @@ export async function initBootstrapDbActivity(loopCtx: any, body: any): Promise<
|
|||
activeSwitchTarget: null,
|
||||
activeCwd: null,
|
||||
statusMessage: initialStatusMessage,
|
||||
updatedAt: now
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: handoffRuntime.id,
|
||||
|
|
@ -123,8 +112,8 @@ export async function initBootstrapDbActivity(loopCtx: any, body: any): Promise<
|
|||
activeSwitchTarget: null,
|
||||
activeCwd: null,
|
||||
statusMessage: initialStatusMessage,
|
||||
updatedAt: now
|
||||
}
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
} catch (error) {
|
||||
|
|
@ -155,7 +144,7 @@ export async function initEnsureNameActivity(loopCtx: any): Promise<void> {
|
|||
const existing = await loopCtx.db
|
||||
.select({
|
||||
branchName: handoffTable.branchName,
|
||||
title: handoffTable.title
|
||||
title: handoffTable.title,
|
||||
})
|
||||
.from(handoffTable)
|
||||
.where(eq(handoffTable.id, HANDOFF_ROW_ID))
|
||||
|
|
@ -175,19 +164,12 @@ export async function initEnsureNameActivity(loopCtx: any): Promise<void> {
|
|||
workspaceId: loopCtx.state.workspaceId,
|
||||
repoId: loopCtx.state.repoId,
|
||||
handoffId: loopCtx.state.handoffId,
|
||||
error: resolveErrorMessage(error)
|
||||
error: resolveErrorMessage(error),
|
||||
});
|
||||
}
|
||||
const remoteBranches = (await driver.git.listRemoteBranches(loopCtx.state.repoLocalPath)).map(
|
||||
(branch: any) => branch.branchName
|
||||
);
|
||||
const remoteBranches = (await driver.git.listRemoteBranches(loopCtx.state.repoLocalPath)).map((branch: any) => branch.branchName);
|
||||
|
||||
const project = await getOrCreateProject(
|
||||
loopCtx,
|
||||
loopCtx.state.workspaceId,
|
||||
loopCtx.state.repoId,
|
||||
loopCtx.state.repoRemote
|
||||
);
|
||||
const project = await getOrCreateProject(loopCtx, loopCtx.state.workspaceId, loopCtx.state.repoId, loopCtx.state.repoRemote);
|
||||
const reservedBranches = await project.listReservedBranches({});
|
||||
|
||||
const resolved = resolveCreateFlowDecision({
|
||||
|
|
@ -195,7 +177,7 @@ export async function initEnsureNameActivity(loopCtx: any): Promise<void> {
|
|||
explicitTitle: loopCtx.state.explicitTitle ?? undefined,
|
||||
explicitBranchName: loopCtx.state.explicitBranchName ?? undefined,
|
||||
localBranches: remoteBranches,
|
||||
handoffBranches: reservedBranches
|
||||
handoffBranches: reservedBranches,
|
||||
});
|
||||
|
||||
const now = Date.now();
|
||||
|
|
@ -204,7 +186,7 @@ export async function initEnsureNameActivity(loopCtx: any): Promise<void> {
|
|||
.set({
|
||||
branchName: resolved.branchName,
|
||||
title: resolved.title,
|
||||
updatedAt: now
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(handoffTable.id, HANDOFF_ROW_ID))
|
||||
.run();
|
||||
|
|
@ -218,19 +200,19 @@ export async function initEnsureNameActivity(loopCtx: any): Promise<void> {
|
|||
.update(handoffRuntime)
|
||||
.set({
|
||||
statusMessage: "provisioning",
|
||||
updatedAt: now
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(handoffRuntime.id, HANDOFF_ROW_ID))
|
||||
.run();
|
||||
|
||||
await project.registerHandoffBranch({
|
||||
handoffId: loopCtx.state.handoffId,
|
||||
branchName: resolved.branchName
|
||||
branchName: resolved.branchName,
|
||||
});
|
||||
|
||||
await appendHistory(loopCtx, "handoff.named", {
|
||||
title: resolved.title,
|
||||
branchName: resolved.branchName
|
||||
branchName: resolved.branchName,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -252,7 +234,7 @@ export async function initCreateSandboxActivity(loopCtx: any, body: any): Promis
|
|||
debugInit(loopCtx, "init_create_sandbox started", {
|
||||
providerId,
|
||||
timeoutMs,
|
||||
supportsSessionReuse: provider.capabilities().supportsSessionReuse
|
||||
supportsSessionReuse: provider.capabilities().supportsSessionReuse,
|
||||
});
|
||||
|
||||
if (provider.capabilities().supportsSessionReuse) {
|
||||
|
|
@ -274,18 +256,16 @@ export async function initCreateSandboxActivity(loopCtx: any, body: any): Promis
|
|||
if (sandboxId) {
|
||||
debugInit(loopCtx, "init_create_sandbox attempting resume", { sandboxId });
|
||||
try {
|
||||
const resumed = await withActivityTimeout(
|
||||
timeoutMs,
|
||||
"resumeSandbox",
|
||||
async () => provider.resumeSandbox({
|
||||
const resumed = await withActivityTimeout(timeoutMs, "resumeSandbox", async () =>
|
||||
provider.resumeSandbox({
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
sandboxId
|
||||
})
|
||||
sandboxId,
|
||||
}),
|
||||
);
|
||||
|
||||
debugInit(loopCtx, "init_create_sandbox resume succeeded", {
|
||||
sandboxId: resumed.sandboxId,
|
||||
durationMs: Date.now() - startedAt
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
return resumed;
|
||||
} catch (error) {
|
||||
|
|
@ -294,39 +274,37 @@ export async function initCreateSandboxActivity(loopCtx: any, body: any): Promis
|
|||
repoId: loopCtx.state.repoId,
|
||||
handoffId: loopCtx.state.handoffId,
|
||||
sandboxId,
|
||||
error: resolveErrorMessage(error)
|
||||
error: resolveErrorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debugInit(loopCtx, "init_create_sandbox creating fresh sandbox", {
|
||||
branchName: loopCtx.state.branchName
|
||||
branchName: loopCtx.state.branchName,
|
||||
});
|
||||
|
||||
try {
|
||||
const sandbox = await withActivityTimeout(
|
||||
timeoutMs,
|
||||
"createSandbox",
|
||||
async () => provider.createSandbox({
|
||||
const sandbox = await withActivityTimeout(timeoutMs, "createSandbox", async () =>
|
||||
provider.createSandbox({
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
repoId: loopCtx.state.repoId,
|
||||
repoRemote: loopCtx.state.repoRemote,
|
||||
branchName: loopCtx.state.branchName,
|
||||
handoffId: loopCtx.state.handoffId,
|
||||
debug: (message, context) => debugInit(loopCtx, message, context)
|
||||
})
|
||||
debug: (message, context) => debugInit(loopCtx, message, context),
|
||||
}),
|
||||
);
|
||||
|
||||
debugInit(loopCtx, "init_create_sandbox create succeeded", {
|
||||
sandboxId: sandbox.sandboxId,
|
||||
durationMs: Date.now() - startedAt
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
return sandbox;
|
||||
} catch (error) {
|
||||
debugInit(loopCtx, "init_create_sandbox failed", {
|
||||
durationMs: Date.now() - startedAt,
|
||||
error: resolveErrorMessage(error)
|
||||
error: resolveErrorMessage(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
|
@ -339,67 +317,49 @@ export async function initEnsureAgentActivity(loopCtx: any, body: any, sandbox:
|
|||
const provider = providers.get(providerId);
|
||||
return await provider.ensureSandboxAgent({
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
sandboxId: sandbox.sandboxId
|
||||
sandboxId: sandbox.sandboxId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function initStartSandboxInstanceActivity(
|
||||
loopCtx: any,
|
||||
body: any,
|
||||
sandbox: any,
|
||||
agent: any
|
||||
): Promise<any> {
|
||||
export async function initStartSandboxInstanceActivity(loopCtx: any, body: any, sandbox: any, agent: any): Promise<any> {
|
||||
await setHandoffState(loopCtx, "init_start_sandbox_instance", "starting sandbox runtime");
|
||||
try {
|
||||
const providerId = body?.providerId ?? loopCtx.state.providerId;
|
||||
const sandboxInstance = await getOrCreateSandboxInstance(
|
||||
loopCtx,
|
||||
loopCtx.state.workspaceId,
|
||||
const sandboxInstance = await getOrCreateSandboxInstance(loopCtx, loopCtx.state.workspaceId, providerId, sandbox.sandboxId, {
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
providerId,
|
||||
sandbox.sandboxId,
|
||||
{
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
providerId,
|
||||
sandboxId: sandbox.sandboxId
|
||||
}
|
||||
);
|
||||
sandboxId: sandbox.sandboxId,
|
||||
});
|
||||
|
||||
await sandboxInstance.ensure({
|
||||
metadata: sandbox.metadata,
|
||||
status: "ready",
|
||||
agentEndpoint: agent.endpoint,
|
||||
agentToken: agent.token
|
||||
agentToken: agent.token,
|
||||
});
|
||||
|
||||
const actorId = typeof (sandboxInstance as any).resolve === "function"
|
||||
? await (sandboxInstance as any).resolve()
|
||||
: null;
|
||||
const actorId = typeof (sandboxInstance as any).resolve === "function" ? await (sandboxInstance as any).resolve() : null;
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
actorId: typeof actorId === "string" ? actorId : null
|
||||
actorId: typeof actorId === "string" ? actorId : null,
|
||||
};
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
ok: false as const,
|
||||
error: `sandbox-instance ensure failed: ${detail}`
|
||||
error: `sandbox-instance ensure failed: ${detail}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function initCreateSessionActivity(
|
||||
loopCtx: any,
|
||||
body: any,
|
||||
sandbox: any,
|
||||
sandboxInstanceReady: any
|
||||
): Promise<any> {
|
||||
export async function initCreateSessionActivity(loopCtx: any, body: any, sandbox: any, sandboxInstanceReady: any): Promise<any> {
|
||||
await setHandoffState(loopCtx, "init_create_session", "creating agent session");
|
||||
if (!sandboxInstanceReady.ok) {
|
||||
return {
|
||||
id: null,
|
||||
status: "error",
|
||||
error: sandboxInstanceReady.error ?? "sandbox instance is not ready"
|
||||
error: sandboxInstanceReady.error ?? "sandbox instance is not ready",
|
||||
} as const;
|
||||
}
|
||||
|
||||
|
|
@ -407,15 +367,12 @@ export async function initCreateSessionActivity(
|
|||
const providerId = body?.providerId ?? loopCtx.state.providerId;
|
||||
const sandboxInstance = getSandboxInstance(loopCtx, loopCtx.state.workspaceId, providerId, sandbox.sandboxId);
|
||||
|
||||
const cwd =
|
||||
sandbox.metadata && typeof (sandbox.metadata as any).cwd === "string"
|
||||
? ((sandbox.metadata as any).cwd as string)
|
||||
: undefined;
|
||||
const cwd = sandbox.metadata && typeof (sandbox.metadata as any).cwd === "string" ? ((sandbox.metadata as any).cwd as string) : undefined;
|
||||
|
||||
return await sandboxInstance.createSession({
|
||||
prompt: buildAgentPrompt(loopCtx.state.task),
|
||||
cwd,
|
||||
agent: (loopCtx.state.agentType ?? config.default_agent) as any
|
||||
agent: (loopCtx.state.agentType ?? config.default_agent) as any,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -424,7 +381,7 @@ export async function initWriteDbActivity(
|
|||
body: any,
|
||||
sandbox: any,
|
||||
session: any,
|
||||
sandboxInstanceReady?: { actorId?: string | null }
|
||||
sandboxInstanceReady?: { actorId?: string | null },
|
||||
): Promise<void> {
|
||||
await setHandoffState(loopCtx, "init_write_db", "persisting handoff runtime");
|
||||
const providerId = body?.providerId ?? loopCtx.state.providerId;
|
||||
|
|
@ -434,21 +391,10 @@ export async function initWriteDbActivity(
|
|||
const sessionId = session?.id ?? null;
|
||||
const sessionHealthy = Boolean(sessionId) && session?.status !== "error";
|
||||
const activeSessionId = sessionHealthy ? sessionId : null;
|
||||
const statusMessage =
|
||||
sessionHealthy
|
||||
? "session created"
|
||||
: session?.status === "error"
|
||||
? (session.error ?? "session create failed")
|
||||
: "session unavailable";
|
||||
const statusMessage = sessionHealthy ? "session created" : session?.status === "error" ? (session.error ?? "session create failed") : "session unavailable";
|
||||
|
||||
const activeCwd =
|
||||
sandbox.metadata && typeof (sandbox.metadata as any).cwd === "string"
|
||||
? ((sandbox.metadata as any).cwd as string)
|
||||
: null;
|
||||
const sandboxActorId =
|
||||
typeof sandboxInstanceReady?.actorId === "string" && sandboxInstanceReady.actorId.length > 0
|
||||
? sandboxInstanceReady.actorId
|
||||
: null;
|
||||
const activeCwd = sandbox.metadata && typeof (sandbox.metadata as any).cwd === "string" ? ((sandbox.metadata as any).cwd as string) : null;
|
||||
const sandboxActorId = typeof sandboxInstanceReady?.actorId === "string" && sandboxInstanceReady.actorId.length > 0 ? sandboxInstanceReady.actorId : null;
|
||||
|
||||
await db
|
||||
.update(handoffTable)
|
||||
|
|
@ -456,7 +402,7 @@ export async function initWriteDbActivity(
|
|||
providerId,
|
||||
status: sessionHealthy ? "running" : "error",
|
||||
agentType: loopCtx.state.agentType ?? config.default_agent,
|
||||
updatedAt: now
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(handoffTable.id, HANDOFF_ROW_ID))
|
||||
.run();
|
||||
|
|
@ -471,7 +417,7 @@ export async function initWriteDbActivity(
|
|||
cwd: activeCwd,
|
||||
statusMessage,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: handoffSandboxes.sandboxId,
|
||||
|
|
@ -481,8 +427,8 @@ export async function initWriteDbActivity(
|
|||
switchTarget: sandbox.switchTarget,
|
||||
cwd: activeCwd,
|
||||
statusMessage,
|
||||
updatedAt: now
|
||||
}
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
|
||||
|
|
@ -495,7 +441,7 @@ export async function initWriteDbActivity(
|
|||
activeSwitchTarget: sandbox.switchTarget,
|
||||
activeCwd,
|
||||
statusMessage,
|
||||
updatedAt: now
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: handoffRuntime.id,
|
||||
|
|
@ -505,18 +451,13 @@ export async function initWriteDbActivity(
|
|||
activeSwitchTarget: sandbox.switchTarget,
|
||||
activeCwd,
|
||||
statusMessage,
|
||||
updatedAt: now
|
||||
}
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function initStartStatusSyncActivity(
|
||||
loopCtx: any,
|
||||
body: any,
|
||||
sandbox: any,
|
||||
session: any
|
||||
): Promise<void> {
|
||||
export async function initStartStatusSyncActivity(loopCtx: any, body: any, sandbox: any, session: any): Promise<void> {
|
||||
const sessionId = session?.id ?? null;
|
||||
if (!sessionId || session?.status === "error") {
|
||||
return;
|
||||
|
|
@ -538,8 +479,8 @@ export async function initStartStatusSyncActivity(
|
|||
providerId,
|
||||
sandboxId: sandbox.sandboxId,
|
||||
sessionId,
|
||||
intervalMs: 2_000
|
||||
}
|
||||
intervalMs: 2_000,
|
||||
},
|
||||
);
|
||||
|
||||
await sync.start();
|
||||
|
|
@ -558,21 +499,18 @@ export async function initCompleteActivity(loopCtx: any, body: any, sandbox: any
|
|||
kind: "handoff.initialized",
|
||||
handoffId: loopCtx.state.handoffId,
|
||||
branchName: loopCtx.state.branchName,
|
||||
payload: { providerId, sandboxId: sandbox.sandboxId, sessionId }
|
||||
payload: { providerId, sandboxId: sandbox.sandboxId, sessionId },
|
||||
});
|
||||
|
||||
loopCtx.state.initialized = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const detail =
|
||||
session?.status === "error"
|
||||
? (session.error ?? "session create failed")
|
||||
: "session unavailable";
|
||||
const detail = session?.status === "error" ? (session.error ?? "session create failed") : "session unavailable";
|
||||
await setHandoffState(loopCtx, "error", detail);
|
||||
await appendHistory(loopCtx, "handoff.error", {
|
||||
detail,
|
||||
messages: [detail]
|
||||
messages: [detail],
|
||||
});
|
||||
loopCtx.state.initialized = false;
|
||||
}
|
||||
|
|
@ -596,7 +534,7 @@ export async function initFailedActivity(loopCtx: any, error: unknown): Promise<
|
|||
status: "error",
|
||||
agentType: loopCtx.state.agentType ?? config.default_agent,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: handoffTable.id,
|
||||
|
|
@ -607,8 +545,8 @@ export async function initFailedActivity(loopCtx: any, error: unknown): Promise<
|
|||
providerId,
|
||||
status: "error",
|
||||
agentType: loopCtx.state.agentType ?? config.default_agent,
|
||||
updatedAt: now
|
||||
}
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
|
||||
|
|
@ -621,7 +559,7 @@ export async function initFailedActivity(loopCtx: any, error: unknown): Promise<
|
|||
activeSwitchTarget: null,
|
||||
activeCwd: null,
|
||||
statusMessage: detail,
|
||||
updatedAt: now
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: handoffRuntime.id,
|
||||
|
|
@ -631,13 +569,13 @@ export async function initFailedActivity(loopCtx: any, error: unknown): Promise<
|
|||
activeSwitchTarget: null,
|
||||
activeCwd: null,
|
||||
statusMessage: detail,
|
||||
updatedAt: now
|
||||
}
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
|
||||
await appendHistory(loopCtx, "handoff.error", {
|
||||
detail,
|
||||
messages
|
||||
messages,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,10 +9,7 @@ export interface PushActiveBranchOptions {
|
|||
historyKind?: string;
|
||||
}
|
||||
|
||||
export async function pushActiveBranchActivity(
|
||||
loopCtx: any,
|
||||
options: PushActiveBranchOptions = {}
|
||||
): Promise<void> {
|
||||
export async function pushActiveBranchActivity(loopCtx: any, options: PushActiveBranchOptions = {}): Promise<void> {
|
||||
const record = await getCurrentRecord(loopCtx);
|
||||
const activeSandboxId = record.activeSandboxId;
|
||||
const branchName = loopCtx.state.branchName ?? record.branchName;
|
||||
|
|
@ -24,8 +21,7 @@ export async function pushActiveBranchActivity(
|
|||
throw new Error("cannot push: handoff branch is not set");
|
||||
}
|
||||
|
||||
const activeSandbox =
|
||||
record.sandboxes.find((sandbox: any) => sandbox.sandboxId === activeSandboxId) ?? null;
|
||||
const activeSandbox = record.sandboxes.find((sandbox: any) => sandbox.sandboxId === activeSandboxId) ?? null;
|
||||
const providerId = activeSandbox?.providerId ?? record.providerId;
|
||||
const cwd = activeSandbox?.cwd ?? null;
|
||||
if (!cwd) {
|
||||
|
|
@ -53,14 +49,14 @@ export async function pushActiveBranchActivity(
|
|||
`cd ${JSON.stringify(cwd)}`,
|
||||
"git rev-parse --verify HEAD >/dev/null",
|
||||
"git config credential.helper '!f() { echo username=x-access-token; echo password=${GH_TOKEN:-$GITHUB_TOKEN}; }; f'",
|
||||
`git push -u origin ${JSON.stringify(branchName)}`
|
||||
`git push -u origin ${JSON.stringify(branchName)}`,
|
||||
].join("; ");
|
||||
|
||||
const result = await provider.executeCommand({
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
sandboxId: activeSandboxId,
|
||||
command: ["bash", "-lc", JSON.stringify(script)].join(" "),
|
||||
label: `git push ${branchName}`
|
||||
label: `git push ${branchName}`,
|
||||
});
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
|
|
@ -83,6 +79,6 @@ export async function pushActiveBranchActivity(
|
|||
await appendHistory(loopCtx, options.historyKind ?? "handoff.push", {
|
||||
reason: options.reason ?? null,
|
||||
branchName,
|
||||
sandboxId: activeSandboxId
|
||||
sandboxId: activeSandboxId,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export const HANDOFF_QUEUE_NAMES = [
|
|||
"handoff.command.workbench.close_session",
|
||||
"handoff.command.workbench.publish_pr",
|
||||
"handoff.command.workbench.revert_file",
|
||||
"handoff.status_sync.result"
|
||||
"handoff.status_sync.result",
|
||||
] as const;
|
||||
|
||||
export function handoffWorkflowQueueName(name: string): string {
|
||||
|
|
|
|||
|
|
@ -26,21 +26,16 @@ export async function statusUpdateActivity(loopCtx: any, body: any): Promise<boo
|
|||
const runtime = await db
|
||||
.select({
|
||||
activeSandboxId: handoffRuntime.activeSandboxId,
|
||||
activeSessionId: handoffRuntime.activeSessionId
|
||||
activeSessionId: handoffRuntime.activeSessionId,
|
||||
})
|
||||
.from(handoffRuntime)
|
||||
.where(eq(handoffRuntime.id, HANDOFF_ROW_ID))
|
||||
.get();
|
||||
|
||||
const isActive =
|
||||
runtime?.activeSandboxId === body.sandboxId && runtime?.activeSessionId === body.sessionId;
|
||||
const isActive = runtime?.activeSandboxId === body.sandboxId && runtime?.activeSessionId === body.sessionId;
|
||||
|
||||
if (isActive) {
|
||||
await db
|
||||
.update(handoffTable)
|
||||
.set({ status: newStatus, updatedAt: body.at })
|
||||
.where(eq(handoffTable.id, HANDOFF_ROW_ID))
|
||||
.run();
|
||||
await db.update(handoffTable).set({ status: newStatus, updatedAt: body.at }).where(eq(handoffTable.id, HANDOFF_ROW_ID)).run();
|
||||
|
||||
await db
|
||||
.update(handoffRuntime)
|
||||
|
|
@ -58,7 +53,7 @@ export async function statusUpdateActivity(loopCtx: any, body: any): Promise<boo
|
|||
await appendHistory(loopCtx, "handoff.status", {
|
||||
status: body.status,
|
||||
sessionId: body.sessionId,
|
||||
sandboxId: body.sandboxId
|
||||
sandboxId: body.sandboxId,
|
||||
});
|
||||
|
||||
if (isActive) {
|
||||
|
|
@ -78,11 +73,7 @@ export async function idleSubmitPrActivity(loopCtx: any): Promise<void> {
|
|||
const { driver } = getActorRuntimeContext();
|
||||
const db = loopCtx.db;
|
||||
|
||||
const self = await db
|
||||
.select({ prSubmitted: handoffTable.prSubmitted })
|
||||
.from(handoffTable)
|
||||
.where(eq(handoffTable.id, HANDOFF_ROW_ID))
|
||||
.get();
|
||||
const self = await db.select({ prSubmitted: handoffTable.prSubmitted }).from(handoffTable).where(eq(handoffTable.id, HANDOFF_ROW_ID)).get();
|
||||
|
||||
if (self && self.prSubmitted) return;
|
||||
|
||||
|
|
@ -93,7 +84,7 @@ export async function idleSubmitPrActivity(loopCtx: any): Promise<void> {
|
|||
workspaceId: loopCtx.state.workspaceId,
|
||||
repoId: loopCtx.state.repoId,
|
||||
handoffId: loopCtx.state.handoffId,
|
||||
error: resolveErrorMessage(error)
|
||||
error: resolveErrorMessage(error),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -104,34 +95,26 @@ export async function idleSubmitPrActivity(loopCtx: any): Promise<void> {
|
|||
try {
|
||||
await pushActiveBranchActivity(loopCtx, {
|
||||
reason: "auto_submit_idle",
|
||||
historyKind: "handoff.push.auto"
|
||||
historyKind: "handoff.push.auto",
|
||||
});
|
||||
|
||||
const pr = await driver.github.createPr(
|
||||
loopCtx.state.repoLocalPath,
|
||||
loopCtx.state.branchName,
|
||||
loopCtx.state.title
|
||||
);
|
||||
const pr = await driver.github.createPr(loopCtx.state.repoLocalPath, loopCtx.state.branchName, loopCtx.state.title);
|
||||
|
||||
await db
|
||||
.update(handoffTable)
|
||||
.set({ prSubmitted: 1, updatedAt: Date.now() })
|
||||
.where(eq(handoffTable.id, HANDOFF_ROW_ID))
|
||||
.run();
|
||||
await db.update(handoffTable).set({ prSubmitted: 1, updatedAt: Date.now() }).where(eq(handoffTable.id, HANDOFF_ROW_ID)).run();
|
||||
|
||||
await appendHistory(loopCtx, "handoff.step", {
|
||||
step: "pr_submit",
|
||||
handoffId: loopCtx.state.handoffId,
|
||||
branchName: loopCtx.state.branchName,
|
||||
prUrl: pr.url,
|
||||
prNumber: pr.number
|
||||
prNumber: pr.number,
|
||||
});
|
||||
|
||||
await appendHistory(loopCtx, "handoff.pr_created", {
|
||||
handoffId: loopCtx.state.handoffId,
|
||||
branchName: loopCtx.state.branchName,
|
||||
prUrl: pr.url,
|
||||
prNumber: pr.number
|
||||
prNumber: pr.number,
|
||||
});
|
||||
} catch (error) {
|
||||
const detail = resolveErrorDetail(error);
|
||||
|
|
@ -139,7 +122,7 @@ export async function idleSubmitPrActivity(loopCtx: any): Promise<void> {
|
|||
.update(handoffRuntime)
|
||||
.set({
|
||||
statusMessage: `pr submit failed: ${detail}`,
|
||||
updatedAt: Date.now()
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.where(eq(handoffRuntime.id, HANDOFF_ROW_ID))
|
||||
.run();
|
||||
|
|
@ -147,7 +130,7 @@ export async function idleSubmitPrActivity(loopCtx: any): Promise<void> {
|
|||
await appendHistory(loopCtx, "handoff.pr_create_failed", {
|
||||
handoffId: loopCtx.state.handoffId,
|
||||
branchName: loopCtx.state.branchName,
|
||||
error: detail
|
||||
error: detail,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,4 +4,3 @@ export default defineConfig({
|
|||
out: "./src/actors/history/db/drizzle",
|
||||
schema: "./src/actors/history/db/schema.ts",
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -67,4 +67,4 @@
|
|||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,4 +10,4 @@
|
|||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,14 +3,14 @@
|
|||
// Do not hand-edit this file.
|
||||
|
||||
const journal = {
|
||||
"entries": [
|
||||
entries: [
|
||||
{
|
||||
"idx": 0,
|
||||
"when": 1770924375133,
|
||||
"tag": "0000_watery_bushwacker",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
idx: 0,
|
||||
when: 1770924375133,
|
||||
tag: "0000_watery_bushwacker",
|
||||
breakpoints: true,
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
|
||||
export default {
|
||||
|
|
@ -25,5 +25,5 @@ export default {
|
|||
\`created_at\` integer NOT NULL
|
||||
);
|
||||
`,
|
||||
} as const
|
||||
} as const,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ async function appendHistoryRow(loopCtx: any, body: AppendHistoryCommand): Promi
|
|||
branchName: body.branchName ?? null,
|
||||
kind: body.kind,
|
||||
payloadJson: JSON.stringify(body.payload),
|
||||
createdAt: now
|
||||
createdAt: now,
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
|
@ -45,7 +45,7 @@ async function runHistoryWorkflow(ctx: any): Promise<void> {
|
|||
await ctx.loop("history-command-loop", async (loopCtx: any) => {
|
||||
const msg = await loopCtx.queue.next("next-history-command", {
|
||||
names: [...HISTORY_QUEUE_NAMES],
|
||||
completable: true
|
||||
completable: true,
|
||||
});
|
||||
if (!msg) {
|
||||
return Loop.continue(undefined);
|
||||
|
|
@ -63,11 +63,11 @@ async function runHistoryWorkflow(ctx: any): Promise<void> {
|
|||
export const history = actor({
|
||||
db: historyDb,
|
||||
queues: {
|
||||
"history.command.append": queue()
|
||||
"history.command.append": queue(),
|
||||
},
|
||||
createState: (_c, input: HistoryInput) => ({
|
||||
workspaceId: input.workspaceId,
|
||||
repoId: input.repoId
|
||||
repoId: input.repoId,
|
||||
}),
|
||||
actions: {
|
||||
async append(c, command: AppendHistoryCommand): Promise<void> {
|
||||
|
|
@ -91,7 +91,7 @@ export const history = actor({
|
|||
branchName: events.branchName,
|
||||
kind: events.kind,
|
||||
payloadJson: events.payloadJson,
|
||||
createdAt: events.createdAt
|
||||
createdAt: events.createdAt,
|
||||
})
|
||||
.from(events);
|
||||
|
||||
|
|
@ -103,9 +103,9 @@ export const history = actor({
|
|||
return rows.map((row) => ({
|
||||
...row,
|
||||
workspaceId: c.state.workspaceId,
|
||||
repoId: c.state.repoId
|
||||
repoId: c.state.repoId,
|
||||
}));
|
||||
}
|
||||
},
|
||||
},
|
||||
run: workflow(runHistoryWorkflow)
|
||||
run: workflow(runHistoryWorkflow),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -35,10 +35,10 @@ export const registry = setup({
|
|||
history,
|
||||
projectPrSync,
|
||||
projectBranchSync,
|
||||
handoffStatusSync
|
||||
handoffStatusSync,
|
||||
},
|
||||
managerPort: resolveManagerPort(),
|
||||
managerHost: resolveManagerHost()
|
||||
managerHost: resolveManagerHost(),
|
||||
});
|
||||
|
||||
export * from "./context.js";
|
||||
|
|
|
|||
|
|
@ -12,11 +12,7 @@ export function handoffKey(workspaceId: string, repoId: string, handoffId: strin
|
|||
return ["ws", workspaceId, "project", repoId, "handoff", handoffId];
|
||||
}
|
||||
|
||||
export function sandboxInstanceKey(
|
||||
workspaceId: string,
|
||||
providerId: string,
|
||||
sandboxId: string
|
||||
): ActorKey {
|
||||
export function sandboxInstanceKey(workspaceId: string, providerId: string, sandboxId: string): ActorKey {
|
||||
return ["ws", workspaceId, "provider", providerId, "sandbox", sandboxId];
|
||||
}
|
||||
|
||||
|
|
@ -32,13 +28,7 @@ export function projectBranchSyncKey(workspaceId: string, repoId: string): Actor
|
|||
return ["ws", workspaceId, "project", repoId, "branch-sync"];
|
||||
}
|
||||
|
||||
export function handoffStatusSyncKey(
|
||||
workspaceId: string,
|
||||
repoId: string,
|
||||
handoffId: string,
|
||||
sandboxId: string,
|
||||
sessionId: string
|
||||
): ActorKey {
|
||||
export function handoffStatusSyncKey(workspaceId: string, repoId: string, handoffId: string, sandboxId: 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];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,15 +16,11 @@ export function resolveErrorStack(error: unknown): string | undefined {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
export function logActorWarning(
|
||||
scope: string,
|
||||
message: string,
|
||||
context?: Record<string, unknown>
|
||||
): void {
|
||||
export function logActorWarning(scope: string, message: string, context?: Record<string, unknown>): void {
|
||||
const payload = {
|
||||
scope,
|
||||
message,
|
||||
...(context ?? {})
|
||||
...(context ?? {}),
|
||||
};
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("[openhandoff][actor:warn]", payload);
|
||||
|
|
|
|||
|
|
@ -23,12 +23,7 @@ interface PollingActorContext<TState extends PollingControlState> {
|
|||
state: TState;
|
||||
abortSignal: AbortSignal;
|
||||
queue: {
|
||||
nextBatch(options: {
|
||||
names: readonly string[];
|
||||
timeout: number;
|
||||
count: number;
|
||||
completable: true;
|
||||
}): Promise<PollingQueueMessage[]>;
|
||||
nextBatch(options: { names: readonly string[]; timeout: number; count: number; completable: true }): Promise<PollingQueueMessage[]>;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -39,21 +34,16 @@ interface RunPollingOptions<TState extends PollingControlState> {
|
|||
|
||||
export async function runPollingControlLoop<TState extends PollingControlState>(
|
||||
c: PollingActorContext<TState>,
|
||||
options: RunPollingOptions<TState>
|
||||
options: RunPollingOptions<TState>,
|
||||
): Promise<void> {
|
||||
while (!c.abortSignal.aborted) {
|
||||
const messages = normalizeMessages(
|
||||
await c.queue.nextBatch({
|
||||
names: [
|
||||
options.control.start,
|
||||
options.control.stop,
|
||||
options.control.setInterval,
|
||||
options.control.force
|
||||
],
|
||||
names: [options.control.start, options.control.stop, options.control.setInterval, options.control.force],
|
||||
timeout: Math.max(500, c.state.intervalMs),
|
||||
count: 16,
|
||||
completable: true
|
||||
})
|
||||
completable: true,
|
||||
}),
|
||||
) as PollingQueueMessage[];
|
||||
|
||||
if (messages.length === 0) {
|
||||
|
|
@ -94,12 +84,7 @@ export async function runPollingControlLoop<TState extends PollingControlState>(
|
|||
|
||||
interface WorkflowPollingActorContext<TState extends PollingControlState> {
|
||||
state: TState;
|
||||
loop(config: {
|
||||
name: string;
|
||||
historyEvery: number;
|
||||
historyKeep: number;
|
||||
run(ctx: WorkflowPollingActorContext<TState>): Promise<unknown>;
|
||||
}): Promise<void>;
|
||||
loop(config: { name: string; historyEvery: number; historyKeep: number; run(ctx: WorkflowPollingActorContext<TState>): Promise<unknown> }): Promise<void>;
|
||||
}
|
||||
|
||||
interface WorkflowPollingQueueMessage extends PollingQueueMessage {}
|
||||
|
|
@ -107,12 +92,15 @@ interface WorkflowPollingQueueMessage extends PollingQueueMessage {}
|
|||
interface WorkflowPollingLoopContext<TState extends PollingControlState> {
|
||||
state: TState;
|
||||
queue: {
|
||||
nextBatch(name: string, options: {
|
||||
names: readonly string[];
|
||||
timeout: number;
|
||||
count: number;
|
||||
completable: true;
|
||||
}): Promise<WorkflowPollingQueueMessage[]>;
|
||||
nextBatch(
|
||||
name: string,
|
||||
options: {
|
||||
names: readonly string[];
|
||||
timeout: number;
|
||||
count: number;
|
||||
completable: true;
|
||||
},
|
||||
): Promise<WorkflowPollingQueueMessage[]>;
|
||||
};
|
||||
step<T>(
|
||||
nameOrConfig:
|
||||
|
|
@ -138,12 +126,7 @@ export async function runWorkflowPollingLoop<TState extends PollingControlState>
|
|||
|
||||
const messages = normalizeMessages(
|
||||
await loopCtx.queue.nextBatch("next-polling-control-batch", {
|
||||
names: [
|
||||
options.control.start,
|
||||
options.control.stop,
|
||||
options.control.setInterval,
|
||||
options.control.force,
|
||||
],
|
||||
names: [options.control.start, options.control.stop, options.control.setInterval, options.control.force],
|
||||
timeout: control.running ? control.intervalMs : 60_000,
|
||||
count: 16,
|
||||
completable: true,
|
||||
|
|
@ -172,37 +155,35 @@ export async function runWorkflowPollingLoop<TState extends PollingControlState>
|
|||
continue;
|
||||
}
|
||||
|
||||
if (msg.name === options.control.stop) {
|
||||
await loopCtx.step("control-stop", async () => {
|
||||
loopCtx.state.running = false;
|
||||
});
|
||||
await msg.complete({ ok: true });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (msg.name === options.control.setInterval) {
|
||||
await loopCtx.step("control-set-interval", async () => {
|
||||
const intervalMs = Number((msg.body as { intervalMs?: unknown })?.intervalMs);
|
||||
loopCtx.state.intervalMs = Number.isFinite(intervalMs)
|
||||
? Math.max(500, intervalMs)
|
||||
: loopCtx.state.intervalMs;
|
||||
});
|
||||
await msg.complete({ ok: true });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (msg.name === options.control.force) {
|
||||
await loopCtx.step({
|
||||
name: "control-force",
|
||||
timeout: 5 * 60_000,
|
||||
run: async () => {
|
||||
await options.onPoll(loopCtx as unknown as PollingActorContext<TState>);
|
||||
},
|
||||
});
|
||||
await msg.complete({ ok: true });
|
||||
}
|
||||
if (msg.name === options.control.stop) {
|
||||
await loopCtx.step("control-stop", async () => {
|
||||
loopCtx.state.running = false;
|
||||
});
|
||||
await msg.complete({ ok: true });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (msg.name === options.control.setInterval) {
|
||||
await loopCtx.step("control-set-interval", async () => {
|
||||
const intervalMs = Number((msg.body as { intervalMs?: unknown })?.intervalMs);
|
||||
loopCtx.state.intervalMs = Number.isFinite(intervalMs) ? Math.max(500, intervalMs) : loopCtx.state.intervalMs;
|
||||
});
|
||||
await msg.complete({ ok: true });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (msg.name === options.control.force) {
|
||||
await loopCtx.step({
|
||||
name: "control-force",
|
||||
timeout: 5 * 60_000,
|
||||
run: async () => {
|
||||
await options.onPoll(loopCtx as unknown as PollingActorContext<TState>);
|
||||
},
|
||||
});
|
||||
await msg.complete({ ok: true });
|
||||
}
|
||||
}
|
||||
|
||||
return Loop.continue(undefined);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,15 +39,10 @@ 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"
|
||||
force: "project.branch_sync.control.force",
|
||||
} as const;
|
||||
|
||||
async function enrichBranches(
|
||||
workspaceId: string,
|
||||
repoId: string,
|
||||
repoPath: string,
|
||||
git: GitDriver
|
||||
): Promise<EnrichedBranchSnapshot[]> {
|
||||
async function enrichBranches(workspaceId: string, repoId: string, repoPath: string, git: GitDriver): Promise<EnrichedBranchSnapshot[]> {
|
||||
return await withRepoGitLock(repoPath, async () => {
|
||||
await git.fetch(repoPath);
|
||||
const branches = await git.listRemoteBranches(repoPath);
|
||||
|
|
@ -71,7 +66,7 @@ async function enrichBranches(
|
|||
workspaceId,
|
||||
repoId,
|
||||
branchName: branch.branchName,
|
||||
error: resolveErrorMessage(error)
|
||||
error: resolveErrorMessage(error),
|
||||
});
|
||||
branchDiffStat = null;
|
||||
}
|
||||
|
|
@ -84,7 +79,7 @@ async function enrichBranches(
|
|||
workspaceId,
|
||||
repoId,
|
||||
branchName: branch.branchName,
|
||||
error: resolveErrorMessage(error)
|
||||
error: resolveErrorMessage(error),
|
||||
});
|
||||
branchHasUnpushed = false;
|
||||
}
|
||||
|
|
@ -96,7 +91,7 @@ async function enrichBranches(
|
|||
workspaceId,
|
||||
repoId,
|
||||
branchName: branch.branchName,
|
||||
error: resolveErrorMessage(error)
|
||||
error: resolveErrorMessage(error),
|
||||
});
|
||||
branchConflicts = false;
|
||||
}
|
||||
|
|
@ -108,7 +103,7 @@ async function enrichBranches(
|
|||
trackedInStack: parentByBranch.has(branch.branchName),
|
||||
diffStat: branchDiffStat,
|
||||
hasUnpushed: branchHasUnpushed,
|
||||
conflictsWithMain: branchConflicts
|
||||
conflictsWithMain: branchConflicts,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -132,14 +127,14 @@ export const projectBranchSync = actor({
|
|||
},
|
||||
options: {
|
||||
// Polling actors rely on timer-based wakeups; sleeping would pause the timer and stop polling.
|
||||
noSleep: true
|
||||
noSleep: true,
|
||||
},
|
||||
createState: (_c, input: ProjectBranchSyncInput): ProjectBranchSyncState => ({
|
||||
workspaceId: input.workspaceId,
|
||||
repoId: input.repoId,
|
||||
repoPath: input.repoPath,
|
||||
intervalMs: input.intervalMs,
|
||||
running: true
|
||||
running: true,
|
||||
}),
|
||||
actions: {
|
||||
async start(c): Promise<void> {
|
||||
|
|
@ -160,7 +155,7 @@ export const projectBranchSync = actor({
|
|||
async force(c): Promise<void> {
|
||||
const self = selfProjectBranchSync(c);
|
||||
await self.send(CONTROL.force, {}, { wait: true, timeout: 5 * 60_000 });
|
||||
}
|
||||
},
|
||||
},
|
||||
run: workflow(async (ctx) => {
|
||||
await runWorkflowPollingLoop<ProjectBranchSyncState>(ctx, {
|
||||
|
|
@ -172,10 +167,10 @@ export const projectBranchSync = actor({
|
|||
} catch (error) {
|
||||
logActorWarning("project-branch-sync", "poll failed", {
|
||||
error: resolveErrorMessage(error),
|
||||
stack: resolveErrorStack(error)
|
||||
stack: resolveErrorStack(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
})
|
||||
}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ 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"
|
||||
force: "project.pr_sync.control.force",
|
||||
} as const;
|
||||
|
||||
async function pollPrs(c: { state: ProjectPrSyncState }): Promise<void> {
|
||||
|
|
@ -45,14 +45,14 @@ export const projectPrSync = actor({
|
|||
},
|
||||
options: {
|
||||
// Polling actors rely on timer-based wakeups; sleeping would pause the timer and stop polling.
|
||||
noSleep: true
|
||||
noSleep: true,
|
||||
},
|
||||
createState: (_c, input: ProjectPrSyncInput): ProjectPrSyncState => ({
|
||||
workspaceId: input.workspaceId,
|
||||
repoId: input.repoId,
|
||||
repoPath: input.repoPath,
|
||||
intervalMs: input.intervalMs,
|
||||
running: true
|
||||
running: true,
|
||||
}),
|
||||
actions: {
|
||||
async start(c): Promise<void> {
|
||||
|
|
@ -73,7 +73,7 @@ export const projectPrSync = actor({
|
|||
async force(c): Promise<void> {
|
||||
const self = selfProjectPrSync(c);
|
||||
await self.send(CONTROL.force, {}, { wait: true, timeout: 5 * 60_000 });
|
||||
}
|
||||
},
|
||||
},
|
||||
run: workflow(async (ctx) => {
|
||||
await runWorkflowPollingLoop<ProjectPrSyncState>(ctx, {
|
||||
|
|
@ -85,10 +85,10 @@ export const projectPrSync = actor({
|
|||
} catch (error) {
|
||||
logActorWarning("project-pr-sync", "poll failed", {
|
||||
error: resolveErrorMessage(error),
|
||||
stack: resolveErrorStack(error)
|
||||
stack: resolveErrorStack(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
})
|
||||
}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,24 +2,9 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { and, desc, eq, isNotNull, ne } from "drizzle-orm";
|
||||
import { Loop } from "rivetkit/workflow";
|
||||
import type {
|
||||
AgentType,
|
||||
HandoffRecord,
|
||||
HandoffSummary,
|
||||
ProviderId,
|
||||
RepoOverview,
|
||||
RepoStackAction,
|
||||
RepoStackActionResult
|
||||
} from "@openhandoff/shared";
|
||||
import type { AgentType, HandoffRecord, HandoffSummary, ProviderId, RepoOverview, RepoStackAction, RepoStackActionResult } from "@openhandoff/shared";
|
||||
import { getActorRuntimeContext } from "../context.js";
|
||||
import {
|
||||
getHandoff,
|
||||
getOrCreateHandoff,
|
||||
getOrCreateHistory,
|
||||
getOrCreateProjectBranchSync,
|
||||
getOrCreateProjectPrSync,
|
||||
selfProject
|
||||
} from "../handles.js";
|
||||
import { getHandoff, getOrCreateHandoff, getOrCreateHistory, getOrCreateProjectBranchSync, getOrCreateProjectPrSync, selfProject } from "../handles.js";
|
||||
import { isActorNotFoundError, logActorWarning, resolveErrorMessage } from "../logging.js";
|
||||
import { openhandoffRepoClonePath } from "../../services/openhandoff-paths.js";
|
||||
import { expectQueueResponse } from "../../services/queue.js";
|
||||
|
|
@ -163,11 +148,7 @@ async function ensureHandoffIndexHydrated(c: any): Promise<void> {
|
|||
return;
|
||||
}
|
||||
|
||||
const existing = await c.db
|
||||
.select({ handoffId: handoffIndex.handoffId })
|
||||
.from(handoffIndex)
|
||||
.limit(1)
|
||||
.get();
|
||||
const existing = await c.db.select({ handoffId: handoffIndex.handoffId }).from(handoffIndex).limit(1).get();
|
||||
|
||||
if (existing) {
|
||||
c.state.handoffIndexHydrated = true;
|
||||
|
|
@ -204,7 +185,7 @@ async function ensureHandoffIndexHydrated(c: any): Promise<void> {
|
|||
handoffId: row.handoffId,
|
||||
branchName: row.branchName,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.createdAt
|
||||
updatedAt: row.createdAt,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.run();
|
||||
|
|
@ -214,14 +195,14 @@ async function ensureHandoffIndexHydrated(c: any): Promise<void> {
|
|||
logActorWarning("project", "skipped missing handoffs while hydrating index", {
|
||||
workspaceId: c.state.workspaceId,
|
||||
repoId: c.state.repoId,
|
||||
skippedMissingHandoffActors
|
||||
skippedMissingHandoffActors,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logActorWarning("project", "handoff index hydration from history failed", {
|
||||
workspaceId: c.state.workspaceId,
|
||||
repoId: c.state.repoId,
|
||||
error: resolveErrorMessage(error)
|
||||
error: resolveErrorMessage(error),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -283,7 +264,7 @@ async function enrichHandoffRecord(c: any, record: HandoffRecord): Promise<Hando
|
|||
diffStat: branches.diffStat,
|
||||
hasUnpushed: branches.hasUnpushed,
|
||||
conflictsWithMain: branches.conflictsWithMain,
|
||||
parentBranch: branches.parentBranch
|
||||
parentBranch: branches.parentBranch,
|
||||
})
|
||||
.from(branches)
|
||||
.where(eq(branches.branchName, branchName))
|
||||
|
|
@ -298,7 +279,7 @@ async function enrichHandoffRecord(c: any, record: HandoffRecord): Promise<Hando
|
|||
prAuthor: prCache.prAuthor,
|
||||
ciStatus: prCache.ciStatus,
|
||||
reviewStatus: prCache.reviewStatus,
|
||||
reviewer: prCache.reviewer
|
||||
reviewer: prCache.reviewer,
|
||||
})
|
||||
.from(prCache)
|
||||
.where(eq(prCache.branchName, branchName))
|
||||
|
|
@ -315,7 +296,7 @@ async function enrichHandoffRecord(c: any, record: HandoffRecord): Promise<Hando
|
|||
prAuthor: pr?.prAuthor ?? null,
|
||||
ciStatus: pr?.ciStatus ?? null,
|
||||
reviewStatus: pr?.reviewStatus ?? null,
|
||||
reviewer: pr?.reviewer ?? null
|
||||
reviewer: pr?.reviewer ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -328,14 +309,14 @@ async function ensureProjectMutation(c: any, cmd: EnsureProjectCommand): Promise
|
|||
.values({
|
||||
id: 1,
|
||||
remoteUrl: cmd.remoteUrl,
|
||||
updatedAt: Date.now()
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: repoMeta.id,
|
||||
set: {
|
||||
remoteUrl: cmd.remoteUrl,
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
})
|
||||
.run();
|
||||
|
||||
|
|
@ -357,11 +338,7 @@ async function createHandoffMutation(c: any, cmd: CreateHandoffCommand): Promise
|
|||
if (onBranch) {
|
||||
await forceProjectSync(c, localPath);
|
||||
|
||||
const branchRow = await c.db
|
||||
.select({ branchName: branches.branchName })
|
||||
.from(branches)
|
||||
.where(eq(branches.branchName, onBranch))
|
||||
.get();
|
||||
const branchRow = await c.db.select({ branchName: branches.branchName }).from(branches).where(eq(branches.branchName, onBranch)).get();
|
||||
if (!branchRow) {
|
||||
throw new Error(`Branch not found in repo snapshot: ${onBranch}`);
|
||||
}
|
||||
|
|
@ -369,7 +346,7 @@ async function createHandoffMutation(c: any, cmd: CreateHandoffCommand): Promise
|
|||
await registerHandoffBranchMutation(c, {
|
||||
handoffId,
|
||||
branchName: onBranch,
|
||||
requireExistingRemote: true
|
||||
requireExistingRemote: true,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -387,11 +364,15 @@ async function createHandoffMutation(c: any, cmd: CreateHandoffCommand): Promise
|
|||
providerId: cmd.providerId,
|
||||
agentType: cmd.agentType,
|
||||
explicitTitle: onBranch ? null : cmd.explicitTitle,
|
||||
explicitBranchName: onBranch ? null : cmd.explicitBranchName
|
||||
explicitBranchName: onBranch ? null : cmd.explicitBranchName,
|
||||
});
|
||||
} catch (error) {
|
||||
if (onBranch) {
|
||||
await c.db.delete(handoffIndex).where(eq(handoffIndex.handoffId, handoffId)).run().catch(() => {});
|
||||
await c.db
|
||||
.delete(handoffIndex)
|
||||
.where(eq(handoffIndex.handoffId, handoffId))
|
||||
.run()
|
||||
.catch(() => {});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
|
@ -404,7 +385,7 @@ async function createHandoffMutation(c: any, cmd: CreateHandoffCommand): Promise
|
|||
handoffId,
|
||||
branchName: initialBranchName,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.run();
|
||||
|
|
@ -418,17 +399,14 @@ async function createHandoffMutation(c: any, cmd: CreateHandoffCommand): Promise
|
|||
handoffId,
|
||||
payload: {
|
||||
repoId: c.state.repoId,
|
||||
providerId: cmd.providerId
|
||||
}
|
||||
providerId: cmd.providerId,
|
||||
},
|
||||
});
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
async function registerHandoffBranchMutation(
|
||||
c: any,
|
||||
cmd: RegisterHandoffBranchCommand,
|
||||
): Promise<{ branchName: string; headSha: string }> {
|
||||
async function registerHandoffBranchMutation(c: any, cmd: RegisterHandoffBranchCommand): Promise<{ branchName: string; headSha: string }> {
|
||||
const localPath = await ensureProjectReady(c);
|
||||
|
||||
const branchName = cmd.branchName.trim();
|
||||
|
|
@ -458,7 +436,7 @@ async function registerHandoffBranchMutation(
|
|||
workspaceId: c.state.workspaceId,
|
||||
repoId: c.state.repoId,
|
||||
handoffId: existingOwner.handoffId,
|
||||
branchName
|
||||
branchName,
|
||||
});
|
||||
} else {
|
||||
throw error;
|
||||
|
|
@ -508,7 +486,7 @@ async function registerHandoffBranchMutation(
|
|||
workspaceId: c.state.workspaceId,
|
||||
repoId: c.state.repoId,
|
||||
branchName,
|
||||
error: resolveErrorMessage(error)
|
||||
error: resolveErrorMessage(error),
|
||||
});
|
||||
}
|
||||
stackRows = await driver.stack.listStack(localPath).catch(() => []);
|
||||
|
|
@ -530,7 +508,7 @@ async function registerHandoffBranchMutation(
|
|||
trackedInStack: trackedInStack ? 1 : 0,
|
||||
firstSeenAt: now,
|
||||
lastSeenAt: now,
|
||||
updatedAt: now
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: branches.branchName,
|
||||
|
|
@ -539,8 +517,8 @@ async function registerHandoffBranchMutation(
|
|||
parentBranch,
|
||||
trackedInStack: trackedInStack ? 1 : 0,
|
||||
lastSeenAt: now,
|
||||
updatedAt: now
|
||||
}
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
|
||||
|
|
@ -550,14 +528,14 @@ async function registerHandoffBranchMutation(
|
|||
handoffId: cmd.handoffId,
|
||||
branchName,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: handoffIndex.handoffId,
|
||||
set: {
|
||||
branchName,
|
||||
updatedAt: now
|
||||
}
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
|
||||
|
|
@ -579,7 +557,7 @@ async function runRepoStackActionMutation(c: any, cmd: RunRepoStackActionCommand
|
|||
action,
|
||||
executed: false,
|
||||
message: "git-spice is not available for this repo",
|
||||
at
|
||||
at,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -593,11 +571,7 @@ async function runRepoStackActionMutation(c: any, cmd: RunRepoStackActionCommand
|
|||
await forceProjectSync(c, localPath);
|
||||
|
||||
if (branchName) {
|
||||
const row = await c.db
|
||||
.select({ branchName: branches.branchName })
|
||||
.from(branches)
|
||||
.where(eq(branches.branchName, branchName))
|
||||
.get();
|
||||
const row = await c.db.select({ branchName: branches.branchName }).from(branches).where(eq(branches.branchName, branchName)).get();
|
||||
if (!row) {
|
||||
throw new Error(`Branch not found in repo snapshot: ${branchName}`);
|
||||
}
|
||||
|
|
@ -610,11 +584,7 @@ async function runRepoStackActionMutation(c: any, cmd: RunRepoStackActionCommand
|
|||
if (parentBranch === branchName) {
|
||||
throw new Error("parentBranch must be different from branchName");
|
||||
}
|
||||
const parentRow = await c.db
|
||||
.select({ branchName: branches.branchName })
|
||||
.from(branches)
|
||||
.where(eq(branches.branchName, parentBranch))
|
||||
.get();
|
||||
const parentRow = await c.db.select({ branchName: branches.branchName }).from(branches).where(eq(branches.branchName, parentBranch)).get();
|
||||
if (!parentRow) {
|
||||
throw new Error(`Parent branch not found in repo snapshot: ${parentBranch}`);
|
||||
}
|
||||
|
|
@ -646,15 +616,15 @@ async function runRepoStackActionMutation(c: any, cmd: RunRepoStackActionCommand
|
|||
payload: {
|
||||
action,
|
||||
branchName: branchName ?? null,
|
||||
parentBranch: parentBranch ?? null
|
||||
}
|
||||
parentBranch: parentBranch ?? null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logActorWarning("project", "failed appending repo stack history event", {
|
||||
workspaceId: c.state.workspaceId,
|
||||
repoId: c.state.repoId,
|
||||
action,
|
||||
error: resolveErrorMessage(error)
|
||||
error: resolveErrorMessage(error),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -662,7 +632,7 @@ async function runRepoStackActionMutation(c: any, cmd: RunRepoStackActionCommand
|
|||
action,
|
||||
executed: true,
|
||||
message: `stack action executed: ${action}`,
|
||||
at
|
||||
at,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -684,7 +654,7 @@ async function applyPrSyncResultMutation(c: any, body: PrSyncResult): Promise<vo
|
|||
reviewStatus: item.reviewStatus ?? null,
|
||||
reviewer: item.reviewer ?? null,
|
||||
fetchedAt: body.at,
|
||||
updatedAt: body.at
|
||||
updatedAt: body.at,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: prCache.branchName,
|
||||
|
|
@ -699,8 +669,8 @@ async function applyPrSyncResultMutation(c: any, body: PrSyncResult): Promise<vo
|
|||
reviewStatus: item.reviewStatus ?? null,
|
||||
reviewer: item.reviewer ?? null,
|
||||
fetchedAt: body.at,
|
||||
updatedAt: body.at
|
||||
}
|
||||
updatedAt: body.at,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
|
@ -710,11 +680,7 @@ async function applyPrSyncResultMutation(c: any, body: PrSyncResult): Promise<vo
|
|||
continue;
|
||||
}
|
||||
|
||||
const row = await c.db
|
||||
.select({ handoffId: handoffIndex.handoffId })
|
||||
.from(handoffIndex)
|
||||
.where(eq(handoffIndex.branchName, item.headRefName))
|
||||
.get();
|
||||
const row = await c.db.select({ handoffId: handoffIndex.handoffId }).from(handoffIndex).where(eq(handoffIndex.branchName, item.headRefName)).get();
|
||||
if (!row) {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -730,7 +696,7 @@ async function applyPrSyncResultMutation(c: any, body: PrSyncResult): Promise<vo
|
|||
repoId: c.state.repoId,
|
||||
handoffId: row.handoffId,
|
||||
branchName: item.headRefName,
|
||||
prState: item.state
|
||||
prState: item.state,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
|
@ -740,7 +706,7 @@ async function applyPrSyncResultMutation(c: any, body: PrSyncResult): Promise<vo
|
|||
handoffId: row.handoffId,
|
||||
branchName: item.headRefName,
|
||||
prState: item.state,
|
||||
error: resolveErrorMessage(error)
|
||||
error: resolveErrorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -752,7 +718,7 @@ async function applyBranchSyncResultMutation(c: any, body: BranchSyncResult): Pr
|
|||
for (const item of body.items) {
|
||||
const existing = await c.db
|
||||
.select({
|
||||
firstSeenAt: branches.firstSeenAt
|
||||
firstSeenAt: branches.firstSeenAt,
|
||||
})
|
||||
.from(branches)
|
||||
.where(eq(branches.branchName, item.branchName))
|
||||
|
|
@ -770,7 +736,7 @@ async function applyBranchSyncResultMutation(c: any, body: BranchSyncResult): Pr
|
|||
conflictsWithMain: item.conflictsWithMain ? 1 : 0,
|
||||
firstSeenAt: existing?.firstSeenAt ?? body.at,
|
||||
lastSeenAt: body.at,
|
||||
updatedAt: body.at
|
||||
updatedAt: body.at,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: branches.branchName,
|
||||
|
|
@ -783,16 +749,13 @@ async function applyBranchSyncResultMutation(c: any, body: BranchSyncResult): Pr
|
|||
conflictsWithMain: item.conflictsWithMain ? 1 : 0,
|
||||
firstSeenAt: existing?.firstSeenAt ?? body.at,
|
||||
lastSeenAt: body.at,
|
||||
updatedAt: body.at
|
||||
}
|
||||
updatedAt: body.at,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
const existingRows = await c.db
|
||||
.select({ branchName: branches.branchName })
|
||||
.from(branches)
|
||||
.all();
|
||||
const existingRows = await c.db.select({ branchName: branches.branchName }).from(branches).all();
|
||||
|
||||
for (const row of existingRows) {
|
||||
if (incoming.has(row.branchName)) {
|
||||
|
|
@ -822,62 +785,60 @@ export async function runProjectWorkflow(ctx: any): Promise<void> {
|
|||
return Loop.continue(undefined);
|
||||
}
|
||||
|
||||
if (msg.name === "project.command.hydrateHandoffIndex") {
|
||||
await loopCtx.step("project-hydrate-handoff-index", async () =>
|
||||
hydrateHandoffIndexMutation(loopCtx, msg.body as HydrateHandoffIndexCommand),
|
||||
);
|
||||
await msg.complete({ ok: true });
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
if (msg.name === "project.command.hydrateHandoffIndex") {
|
||||
await loopCtx.step("project-hydrate-handoff-index", async () => hydrateHandoffIndexMutation(loopCtx, msg.body as HydrateHandoffIndexCommand));
|
||||
await msg.complete({ ok: true });
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
|
||||
if (msg.name === "project.command.createHandoff") {
|
||||
const result = await loopCtx.step({
|
||||
name: "project-create-handoff",
|
||||
timeout: 12 * 60_000,
|
||||
run: async () => createHandoffMutation(loopCtx, msg.body as CreateHandoffCommand),
|
||||
});
|
||||
await msg.complete(result);
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
if (msg.name === "project.command.createHandoff") {
|
||||
const result = await loopCtx.step({
|
||||
name: "project-create-handoff",
|
||||
timeout: 12 * 60_000,
|
||||
run: async () => createHandoffMutation(loopCtx, msg.body as CreateHandoffCommand),
|
||||
});
|
||||
await msg.complete(result);
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
|
||||
if (msg.name === "project.command.registerHandoffBranch") {
|
||||
const result = await loopCtx.step({
|
||||
name: "project-register-handoff-branch",
|
||||
timeout: 5 * 60_000,
|
||||
run: async () => registerHandoffBranchMutation(loopCtx, msg.body as RegisterHandoffBranchCommand),
|
||||
});
|
||||
await msg.complete(result);
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
if (msg.name === "project.command.registerHandoffBranch") {
|
||||
const result = await loopCtx.step({
|
||||
name: "project-register-handoff-branch",
|
||||
timeout: 5 * 60_000,
|
||||
run: async () => registerHandoffBranchMutation(loopCtx, msg.body as RegisterHandoffBranchCommand),
|
||||
});
|
||||
await msg.complete(result);
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
|
||||
if (msg.name === "project.command.runRepoStackAction") {
|
||||
const result = await loopCtx.step({
|
||||
name: "project-run-repo-stack-action",
|
||||
timeout: 12 * 60_000,
|
||||
run: async () => runRepoStackActionMutation(loopCtx, msg.body as RunRepoStackActionCommand),
|
||||
});
|
||||
await msg.complete(result);
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
if (msg.name === "project.command.runRepoStackAction") {
|
||||
const result = await loopCtx.step({
|
||||
name: "project-run-repo-stack-action",
|
||||
timeout: 12 * 60_000,
|
||||
run: async () => runRepoStackActionMutation(loopCtx, msg.body as RunRepoStackActionCommand),
|
||||
});
|
||||
await msg.complete(result);
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
|
||||
if (msg.name === "project.command.applyPrSyncResult") {
|
||||
await loopCtx.step({
|
||||
name: "project-apply-pr-sync-result",
|
||||
timeout: 60_000,
|
||||
run: async () => applyPrSyncResultMutation(loopCtx, msg.body as PrSyncResult),
|
||||
});
|
||||
await msg.complete({ ok: true });
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
if (msg.name === "project.command.applyPrSyncResult") {
|
||||
await loopCtx.step({
|
||||
name: "project-apply-pr-sync-result",
|
||||
timeout: 60_000,
|
||||
run: async () => applyPrSyncResultMutation(loopCtx, msg.body as PrSyncResult),
|
||||
});
|
||||
await msg.complete({ ok: true });
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
|
||||
if (msg.name === "project.command.applyBranchSyncResult") {
|
||||
await loopCtx.step({
|
||||
name: "project-apply-branch-sync-result",
|
||||
timeout: 60_000,
|
||||
run: async () => applyBranchSyncResultMutation(loopCtx, msg.body as BranchSyncResult),
|
||||
});
|
||||
await msg.complete({ ok: true });
|
||||
}
|
||||
if (msg.name === "project.command.applyBranchSyncResult") {
|
||||
await loopCtx.step({
|
||||
name: "project-apply-branch-sync-result",
|
||||
timeout: 60_000,
|
||||
run: async () => applyBranchSyncResultMutation(loopCtx, msg.body as BranchSyncResult),
|
||||
});
|
||||
await msg.complete({ ok: true });
|
||||
}
|
||||
|
||||
return Loop.continue(undefined);
|
||||
});
|
||||
|
|
@ -907,15 +868,9 @@ export const projectActions = {
|
|||
async listReservedBranches(c: any, _cmd?: ListReservedBranchesCommand): Promise<string[]> {
|
||||
await ensureHandoffIndexHydratedForRead(c);
|
||||
|
||||
const rows = await c.db
|
||||
.select({ branchName: handoffIndex.branchName })
|
||||
.from(handoffIndex)
|
||||
.where(isNotNull(handoffIndex.branchName))
|
||||
.all();
|
||||
const rows = await c.db.select({ branchName: handoffIndex.branchName }).from(handoffIndex).where(isNotNull(handoffIndex.branchName)).all();
|
||||
|
||||
return rows
|
||||
.map((row) => row.branchName)
|
||||
.filter((name): name is string => typeof name === "string" && name.trim().length > 0);
|
||||
return rows.map((row) => row.branchName).filter((name): name is string => typeof name === "string" && name.trim().length > 0);
|
||||
},
|
||||
|
||||
async registerHandoffBranch(c: any, cmd: RegisterHandoffBranchCommand): Promise<{ branchName: string; headSha: string }> {
|
||||
|
|
@ -942,11 +897,7 @@ export const projectActions = {
|
|||
|
||||
await ensureHandoffIndexHydratedForRead(c);
|
||||
|
||||
const handoffRows = await c.db
|
||||
.select({ handoffId: handoffIndex.handoffId })
|
||||
.from(handoffIndex)
|
||||
.orderBy(desc(handoffIndex.updatedAt))
|
||||
.all();
|
||||
const handoffRows = await c.db.select({ handoffId: handoffIndex.handoffId }).from(handoffIndex).orderBy(desc(handoffIndex.updatedAt)).all();
|
||||
|
||||
for (const row of handoffRows) {
|
||||
try {
|
||||
|
|
@ -964,7 +915,7 @@ export const projectActions = {
|
|||
branchName: record.branchName,
|
||||
title: record.title,
|
||||
status: record.status,
|
||||
updatedAt: record.updatedAt
|
||||
updatedAt: record.updatedAt,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isStaleHandoffReferenceError(error)) {
|
||||
|
|
@ -972,7 +923,7 @@ export const projectActions = {
|
|||
logActorWarning("project", "pruned stale handoff index row during summary listing", {
|
||||
workspaceId: c.state.workspaceId,
|
||||
repoId: c.state.repoId,
|
||||
handoffId: row.handoffId
|
||||
handoffId: row.handoffId,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
|
@ -980,7 +931,7 @@ export const projectActions = {
|
|||
workspaceId: c.state.workspaceId,
|
||||
repoId: c.state.repoId,
|
||||
handoffId: row.handoffId,
|
||||
error: resolveErrorMessage(error)
|
||||
error: resolveErrorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -992,11 +943,7 @@ export const projectActions = {
|
|||
async getHandoffEnriched(c: any, cmd: GetHandoffEnrichedCommand): Promise<HandoffRecord> {
|
||||
await ensureHandoffIndexHydratedForRead(c);
|
||||
|
||||
const row = await c.db
|
||||
.select({ handoffId: handoffIndex.handoffId })
|
||||
.from(handoffIndex)
|
||||
.where(eq(handoffIndex.handoffId, cmd.handoffId))
|
||||
.get();
|
||||
const row = await c.db.select({ handoffId: handoffIndex.handoffId }).from(handoffIndex).where(eq(handoffIndex.handoffId, cmd.handoffId)).get();
|
||||
if (!row) {
|
||||
throw new Error(`Unknown handoff in repo ${c.state.repoId}: ${cmd.handoffId}`);
|
||||
}
|
||||
|
|
@ -1035,7 +982,7 @@ export const projectActions = {
|
|||
conflictsWithMain: branches.conflictsWithMain,
|
||||
firstSeenAt: branches.firstSeenAt,
|
||||
lastSeenAt: branches.lastSeenAt,
|
||||
updatedAt: branches.updatedAt
|
||||
updatedAt: branches.updatedAt,
|
||||
})
|
||||
.from(branches)
|
||||
.all();
|
||||
|
|
@ -1044,15 +991,12 @@ export const projectActions = {
|
|||
.select({
|
||||
handoffId: handoffIndex.handoffId,
|
||||
branchName: handoffIndex.branchName,
|
||||
updatedAt: handoffIndex.updatedAt
|
||||
updatedAt: handoffIndex.updatedAt,
|
||||
})
|
||||
.from(handoffIndex)
|
||||
.all();
|
||||
|
||||
const handoffMetaByBranch = new Map<
|
||||
string,
|
||||
{ handoffId: string; title: string | null; status: HandoffRecord["status"] | null; updatedAt: number }
|
||||
>();
|
||||
const handoffMetaByBranch = new Map<string, { handoffId: string; title: string | null; status: HandoffRecord["status"] | null; updatedAt: number }>();
|
||||
|
||||
for (const row of handoffRows) {
|
||||
if (!row.branchName) {
|
||||
|
|
@ -1065,7 +1009,7 @@ export const projectActions = {
|
|||
handoffId: row.handoffId,
|
||||
title: record.title ?? null,
|
||||
status: record.status,
|
||||
updatedAt: record.updatedAt
|
||||
updatedAt: record.updatedAt,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isStaleHandoffReferenceError(error)) {
|
||||
|
|
@ -1074,7 +1018,7 @@ export const projectActions = {
|
|||
workspaceId: c.state.workspaceId,
|
||||
repoId: c.state.repoId,
|
||||
handoffId: row.handoffId,
|
||||
branchName: row.branchName
|
||||
branchName: row.branchName,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
|
@ -1083,7 +1027,7 @@ export const projectActions = {
|
|||
repoId: c.state.repoId,
|
||||
handoffId: row.handoffId,
|
||||
branchName: row.branchName,
|
||||
error: resolveErrorMessage(error)
|
||||
error: resolveErrorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1096,7 +1040,7 @@ export const projectActions = {
|
|||
prUrl: prCache.prUrl,
|
||||
ciStatus: prCache.ciStatus,
|
||||
reviewStatus: prCache.reviewStatus,
|
||||
reviewer: prCache.reviewer
|
||||
reviewer: prCache.reviewer,
|
||||
})
|
||||
.from(prCache)
|
||||
.all();
|
||||
|
|
@ -1106,8 +1050,8 @@ export const projectActions = {
|
|||
branchRowsRaw.map((row) => ({
|
||||
branchName: row.branchName,
|
||||
parentBranch: row.parentBranch ?? null,
|
||||
updatedAt: row.updatedAt
|
||||
}))
|
||||
updatedAt: row.updatedAt,
|
||||
})),
|
||||
);
|
||||
|
||||
const detailByBranch = new Map(branchRowsRaw.map((row) => [row.branchName, row]));
|
||||
|
|
@ -1135,7 +1079,7 @@ export const projectActions = {
|
|||
reviewer: pr?.reviewer ?? null,
|
||||
firstSeenAt: row.firstSeenAt ?? null,
|
||||
lastSeenAt: row.lastSeenAt ?? null,
|
||||
updatedAt: Math.max(row.updatedAt, handoffMeta?.updatedAt ?? 0)
|
||||
updatedAt: Math.max(row.updatedAt, handoffMeta?.updatedAt ?? 0),
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -1146,14 +1090,11 @@ export const projectActions = {
|
|||
baseRef,
|
||||
stackAvailable,
|
||||
fetchedAt: now,
|
||||
branches: branchRows
|
||||
branches: branchRows,
|
||||
};
|
||||
},
|
||||
|
||||
async getPullRequestForBranch(
|
||||
c: any,
|
||||
cmd: GetPullRequestForBranchCommand,
|
||||
): Promise<{ number: number; status: "draft" | "ready" } | null> {
|
||||
async getPullRequestForBranch(c: any, cmd: GetPullRequestForBranchCommand): Promise<{ number: number; status: "draft" | "ready" } | null> {
|
||||
const branchName = cmd.branchName?.trim();
|
||||
if (!branchName) {
|
||||
return null;
|
||||
|
|
@ -1202,5 +1143,5 @@ export const projectActions = {
|
|||
wait: true,
|
||||
timeout: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,4 +4,3 @@ export default defineConfig({
|
|||
out: "./src/actors/project/db/drizzle",
|
||||
schema: "./src/actors/project/db/schema.ts",
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -189,4 +189,4 @@
|
|||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -213,4 +213,4 @@
|
|||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -251,4 +251,4 @@
|
|||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,32 +3,32 @@
|
|||
// Do not hand-edit this file.
|
||||
|
||||
const journal = {
|
||||
"entries": [
|
||||
entries: [
|
||||
{
|
||||
"idx": 0,
|
||||
"when": 1770924376062,
|
||||
"tag": "0000_stormy_the_hunter",
|
||||
"breakpoints": true
|
||||
idx: 0,
|
||||
when: 1770924376062,
|
||||
tag: "0000_stormy_the_hunter",
|
||||
breakpoints: true,
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"when": 1770947252449,
|
||||
"tag": "0001_wild_carlie_cooper",
|
||||
"breakpoints": true
|
||||
idx: 1,
|
||||
when: 1770947252449,
|
||||
tag: "0001_wild_carlie_cooper",
|
||||
breakpoints: true,
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"when": 1771276338465,
|
||||
"tag": "0002_far_war_machine",
|
||||
"breakpoints": true
|
||||
idx: 2,
|
||||
when: 1771276338465,
|
||||
tag: "0002_far_war_machine",
|
||||
breakpoints: true,
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"when": 1771369000000,
|
||||
"tag": "0003_busy_legacy",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
idx: 3,
|
||||
when: 1771369000000,
|
||||
tag: "0003_busy_legacy",
|
||||
breakpoints: true,
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
|
||||
export default {
|
||||
|
|
@ -77,5 +77,5 @@ ALTER TABLE \`branches\` DROP COLUMN \`worktree_path\`;`,
|
|||
);
|
||||
`,
|
||||
m0003: `ALTER TABLE \`branches\` ADD \`tracked_in_stack\` integer;`,
|
||||
} as const
|
||||
} as const,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -40,5 +40,5 @@ export const handoffIndex = sqliteTable("handoff_index", {
|
|||
handoffId: text("handoff_id").notNull().primaryKey(),
|
||||
branchName: text("branch_name"),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
updatedAt: integer("updated_at").notNull()
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export const project = actor({
|
|||
remoteUrl: input.remoteUrl,
|
||||
localPath: null as string | null,
|
||||
syncActorsStarted: false,
|
||||
handoffIndexHydrated: false
|
||||
handoffIndexHydrated: false,
|
||||
}),
|
||||
actions: projectActions,
|
||||
run: workflow(runProjectWorkflow),
|
||||
|
|
|
|||
|
|
@ -4,4 +4,3 @@ export default defineConfig({
|
|||
out: "./src/actors/sandbox-instance/db/drizzle",
|
||||
schema: "./src/actors/sandbox-instance/db/schema.ts",
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -53,4 +53,4 @@
|
|||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,20 +3,20 @@
|
|||
// Do not hand-edit this file.
|
||||
|
||||
const journal = {
|
||||
"entries": [
|
||||
entries: [
|
||||
{
|
||||
"idx": 0,
|
||||
"when": 1770924375604,
|
||||
"tag": "0000_broad_tyrannus",
|
||||
"breakpoints": true
|
||||
idx: 0,
|
||||
when: 1770924375604,
|
||||
tag: "0000_broad_tyrannus",
|
||||
breakpoints: true,
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"when": 1776482400000,
|
||||
"tag": "0001_sandbox_sessions",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
idx: 1,
|
||||
when: 1776482400000,
|
||||
tag: "0001_sandbox_sessions",
|
||||
breakpoints: true,
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
|
||||
export default {
|
||||
|
|
@ -57,5 +57,5 @@ CREATE INDEX \`sandbox_session_events_session_id_event_index_idx\` ON \`sandbox_
|
|||
--> statement-breakpoint
|
||||
CREATE INDEX \`sandbox_session_events_session_id_created_at_idx\` ON \`sandbox_session_events\` (\`session_id\`,\`created_at\`);
|
||||
`,
|
||||
} as const
|
||||
} as const,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -23,9 +23,7 @@ const CREATE_SESSION_MAX_ATTEMPTS = 3;
|
|||
const CREATE_SESSION_RETRY_BASE_MS = 1_000;
|
||||
const CREATE_SESSION_STEP_TIMEOUT_MS = 10 * 60_000;
|
||||
|
||||
function normalizeStatusFromEventPayload(
|
||||
payload: unknown,
|
||||
): "running" | "idle" | "error" | null {
|
||||
function normalizeStatusFromEventPayload(payload: unknown): "running" | "idle" | "error" | null {
|
||||
if (payload && typeof payload === "object") {
|
||||
const envelope = payload as {
|
||||
error?: unknown;
|
||||
|
|
@ -49,11 +47,7 @@ function normalizeStatusFromEventPayload(
|
|||
if (lowered.includes("error") || lowered.includes("failed")) {
|
||||
return "error";
|
||||
}
|
||||
if (
|
||||
lowered.includes("ended") ||
|
||||
lowered.includes("complete") ||
|
||||
lowered.includes("stopped")
|
||||
) {
|
||||
if (lowered.includes("ended") || lowered.includes("complete") || lowered.includes("stopped")) {
|
||||
return "idle";
|
||||
}
|
||||
}
|
||||
|
|
@ -183,12 +177,7 @@ async function derivePersistedSessionStatus(
|
|||
|
||||
function isTransientSessionCreateError(detail: string): boolean {
|
||||
const lowered = detail.toLowerCase();
|
||||
if (
|
||||
lowered.includes("timed out") ||
|
||||
lowered.includes("timeout") ||
|
||||
lowered.includes("504") ||
|
||||
lowered.includes("gateway timeout")
|
||||
) {
|
||||
if (lowered.includes("timed out") || lowered.includes("timeout") || lowered.includes("504") || lowered.includes("gateway timeout")) {
|
||||
// ACP timeout errors are expensive and usually deterministic for the same
|
||||
// request; immediate retries spawn additional sessions/processes and make
|
||||
// recovery harder.
|
||||
|
|
@ -196,11 +185,7 @@ function isTransientSessionCreateError(detail: string): boolean {
|
|||
}
|
||||
|
||||
return (
|
||||
lowered.includes("502") ||
|
||||
lowered.includes("503") ||
|
||||
lowered.includes("bad gateway") ||
|
||||
lowered.includes("econnreset") ||
|
||||
lowered.includes("econnrefused")
|
||||
lowered.includes("502") || lowered.includes("503") || lowered.includes("bad gateway") || lowered.includes("econnreset") || lowered.includes("econnrefused")
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -265,9 +250,7 @@ const SANDBOX_INSTANCE_QUEUE_NAMES = [
|
|||
|
||||
type SandboxInstanceQueueName = (typeof SANDBOX_INSTANCE_QUEUE_NAMES)[number];
|
||||
|
||||
function sandboxInstanceWorkflowQueueName(
|
||||
name: SandboxInstanceQueueName,
|
||||
): SandboxInstanceQueueName {
|
||||
function sandboxInstanceWorkflowQueueName(name: SandboxInstanceQueueName): SandboxInstanceQueueName {
|
||||
return name;
|
||||
}
|
||||
|
||||
|
|
@ -297,15 +280,15 @@ async function ensureSandboxMutation(c: any, command: EnsureSandboxCommand): Pro
|
|||
id: SANDBOX_ROW_ID,
|
||||
metadataJson,
|
||||
status: command.status,
|
||||
updatedAt: now
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: sandboxInstanceTable.id,
|
||||
set: {
|
||||
metadataJson,
|
||||
status: command.status,
|
||||
updatedAt: now
|
||||
}
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
|
@ -315,17 +298,14 @@ async function updateHealthMutation(c: any, command: HealthSandboxCommand): Prom
|
|||
.update(sandboxInstanceTable)
|
||||
.set({
|
||||
status: `${command.status}:${command.message}`,
|
||||
updatedAt: Date.now()
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.where(eq(sandboxInstanceTable.id, SANDBOX_ROW_ID))
|
||||
.run();
|
||||
}
|
||||
|
||||
async function destroySandboxMutation(c: any): Promise<void> {
|
||||
await c.db
|
||||
.delete(sandboxInstanceTable)
|
||||
.where(eq(sandboxInstanceTable.id, SANDBOX_ROW_ID))
|
||||
.run();
|
||||
await c.db.delete(sandboxInstanceTable).where(eq(sandboxInstanceTable.id, SANDBOX_ROW_ID)).run();
|
||||
}
|
||||
|
||||
async function createSessionMutation(c: any, command: CreateSessionCommand): Promise<CreateSessionResult> {
|
||||
|
|
@ -362,7 +342,7 @@ async function createSessionMutation(c: any, command: CreateSessionCommand): Pro
|
|||
attempt,
|
||||
maxAttempts: CREATE_SESSION_MAX_ATTEMPTS,
|
||||
waitMs,
|
||||
error: detail
|
||||
error: detail,
|
||||
});
|
||||
await delay(waitMs);
|
||||
}
|
||||
|
|
@ -372,7 +352,7 @@ async function createSessionMutation(c: any, command: CreateSessionCommand): Pro
|
|||
return {
|
||||
id: null,
|
||||
status: "error",
|
||||
error: `sandbox-agent createSession failed after ${attemptsMade} ${attemptLabel}: ${lastDetail}`
|
||||
error: `sandbox-agent createSession failed after ${attemptsMade} ${attemptLabel}: ${lastDetail}`,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -405,62 +385,50 @@ async function runSandboxInstanceWorkflow(ctx: any): Promise<void> {
|
|||
return Loop.continue(undefined);
|
||||
}
|
||||
|
||||
if (msg.name === "sandboxInstance.command.ensure") {
|
||||
await loopCtx.step("sandbox-instance-ensure", async () =>
|
||||
ensureSandboxMutation(loopCtx, msg.body as EnsureSandboxCommand),
|
||||
);
|
||||
await msg.complete({ ok: true });
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
if (msg.name === "sandboxInstance.command.ensure") {
|
||||
await loopCtx.step("sandbox-instance-ensure", async () => ensureSandboxMutation(loopCtx, msg.body as EnsureSandboxCommand));
|
||||
await msg.complete({ ok: true });
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
|
||||
if (msg.name === "sandboxInstance.command.updateHealth") {
|
||||
await loopCtx.step("sandbox-instance-update-health", async () =>
|
||||
updateHealthMutation(loopCtx, msg.body as HealthSandboxCommand),
|
||||
);
|
||||
await msg.complete({ ok: true });
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
if (msg.name === "sandboxInstance.command.updateHealth") {
|
||||
await loopCtx.step("sandbox-instance-update-health", async () => updateHealthMutation(loopCtx, msg.body as HealthSandboxCommand));
|
||||
await msg.complete({ ok: true });
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
|
||||
if (msg.name === "sandboxInstance.command.destroy") {
|
||||
await loopCtx.step("sandbox-instance-destroy", async () =>
|
||||
destroySandboxMutation(loopCtx),
|
||||
);
|
||||
await msg.complete({ ok: true });
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
if (msg.name === "sandboxInstance.command.destroy") {
|
||||
await loopCtx.step("sandbox-instance-destroy", async () => destroySandboxMutation(loopCtx));
|
||||
await msg.complete({ ok: true });
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
|
||||
if (msg.name === "sandboxInstance.command.createSession") {
|
||||
const result = await loopCtx.step({
|
||||
name: "sandbox-instance-create-session",
|
||||
timeout: CREATE_SESSION_STEP_TIMEOUT_MS,
|
||||
run: async () => createSessionMutation(loopCtx, msg.body as CreateSessionCommand),
|
||||
});
|
||||
await msg.complete(result);
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
if (msg.name === "sandboxInstance.command.createSession") {
|
||||
const result = await loopCtx.step({
|
||||
name: "sandbox-instance-create-session",
|
||||
timeout: CREATE_SESSION_STEP_TIMEOUT_MS,
|
||||
run: async () => createSessionMutation(loopCtx, msg.body as CreateSessionCommand),
|
||||
});
|
||||
await msg.complete(result);
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
|
||||
if (msg.name === "sandboxInstance.command.sendPrompt") {
|
||||
await loopCtx.step("sandbox-instance-send-prompt", async () =>
|
||||
sendPromptMutation(loopCtx, msg.body as SendPromptCommand),
|
||||
);
|
||||
await msg.complete({ ok: true });
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
if (msg.name === "sandboxInstance.command.sendPrompt") {
|
||||
await loopCtx.step("sandbox-instance-send-prompt", async () => sendPromptMutation(loopCtx, msg.body as SendPromptCommand));
|
||||
await msg.complete({ ok: true });
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
|
||||
if (msg.name === "sandboxInstance.command.cancelSession") {
|
||||
await loopCtx.step("sandbox-instance-cancel-session", async () =>
|
||||
cancelSessionMutation(loopCtx, msg.body as SessionControlCommand),
|
||||
);
|
||||
await msg.complete({ ok: true });
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
if (msg.name === "sandboxInstance.command.cancelSession") {
|
||||
await loopCtx.step("sandbox-instance-cancel-session", async () => cancelSessionMutation(loopCtx, msg.body as SessionControlCommand));
|
||||
await msg.complete({ ok: true });
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
|
||||
if (msg.name === "sandboxInstance.command.destroySession") {
|
||||
await loopCtx.step("sandbox-instance-destroy-session", async () =>
|
||||
destroySessionMutation(loopCtx, msg.body as SessionControlCommand),
|
||||
);
|
||||
await msg.complete({ ok: true });
|
||||
}
|
||||
if (msg.name === "sandboxInstance.command.destroySession") {
|
||||
await loopCtx.step("sandbox-instance-destroy-session", async () => destroySessionMutation(loopCtx, msg.body as SessionControlCommand));
|
||||
await msg.complete({ ok: true });
|
||||
}
|
||||
|
||||
return Loop.continue(undefined);
|
||||
});
|
||||
|
|
@ -518,10 +486,14 @@ export const sandboxInstance = actor({
|
|||
|
||||
async destroy(c): Promise<void> {
|
||||
const self = selfSandboxInstance(c);
|
||||
await self.send(sandboxInstanceWorkflowQueueName("sandboxInstance.command.destroy"), {}, {
|
||||
wait: true,
|
||||
timeout: 60_000,
|
||||
});
|
||||
await self.send(
|
||||
sandboxInstanceWorkflowQueueName("sandboxInstance.command.destroy"),
|
||||
{},
|
||||
{
|
||||
wait: true,
|
||||
timeout: 60_000,
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
async createSession(c: any, command: CreateSessionCommand): Promise<CreateSessionResult> {
|
||||
|
|
@ -534,10 +506,7 @@ export const sandboxInstance = actor({
|
|||
);
|
||||
},
|
||||
|
||||
async listSessions(
|
||||
c: any,
|
||||
command?: ListSessionsCommand
|
||||
): Promise<{ items: SessionRecord[]; nextCursor?: string }> {
|
||||
async listSessions(c: any, command?: ListSessionsCommand): Promise<{ items: SessionRecord[]; nextCursor?: string }> {
|
||||
const persist = new SandboxInstancePersistDriver(c.db);
|
||||
try {
|
||||
const client = await getSandboxAgentClient(c);
|
||||
|
|
@ -556,7 +525,7 @@ export const sandboxInstance = actor({
|
|||
workspaceId: c.state.workspaceId,
|
||||
providerId: c.state.providerId,
|
||||
sandboxId: c.state.sandboxId,
|
||||
error: resolveErrorMessage(error)
|
||||
error: resolveErrorMessage(error),
|
||||
});
|
||||
return await persist.listSessions({
|
||||
cursor: command?.cursor,
|
||||
|
|
@ -565,10 +534,7 @@ export const sandboxInstance = actor({
|
|||
}
|
||||
},
|
||||
|
||||
async listSessionEvents(
|
||||
c: any,
|
||||
command: ListSessionEventsCommand
|
||||
): Promise<{ items: SessionEvent[]; nextCursor?: string }> {
|
||||
async listSessionEvents(c: any, command: ListSessionEventsCommand): Promise<{ items: SessionEvent[]; nextCursor?: string }> {
|
||||
const persist = new SandboxInstancePersistDriver(c.db);
|
||||
return await persist.listEvents({
|
||||
sessionId: command.sessionId,
|
||||
|
|
@ -601,15 +567,9 @@ export const sandboxInstance = actor({
|
|||
});
|
||||
},
|
||||
|
||||
async sessionStatus(
|
||||
c,
|
||||
command: SessionStatusCommand
|
||||
): Promise<{ id: string; status: "running" | "idle" | "error" }> {
|
||||
return await derivePersistedSessionStatus(
|
||||
new SandboxInstancePersistDriver(c.db),
|
||||
command.sessionId,
|
||||
);
|
||||
}
|
||||
async sessionStatus(c, command: SessionStatusCommand): Promise<{ id: string; status: "running" | "idle" | "error" }> {
|
||||
return await derivePersistedSessionStatus(new SandboxInstancePersistDriver(c.db), command.sessionId);
|
||||
},
|
||||
},
|
||||
run: workflow(runSandboxInstanceWorkflow),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,12 +1,5 @@
|
|||
import { and, asc, count, eq } from "drizzle-orm";
|
||||
import type {
|
||||
ListEventsRequest,
|
||||
ListPage,
|
||||
ListPageRequest,
|
||||
SessionEvent,
|
||||
SessionPersistDriver,
|
||||
SessionRecord
|
||||
} from "sandbox-agent";
|
||||
import type { ListEventsRequest, ListPage, ListPageRequest, SessionEvent, SessionPersistDriver, SessionRecord } from "sandbox-agent";
|
||||
import { sandboxSessionEvents, sandboxSessions } from "./db/schema.js";
|
||||
|
||||
const DEFAULT_MAX_SESSIONS = 1024;
|
||||
|
|
@ -27,11 +20,7 @@ function parseCursor(cursor: string | undefined): number {
|
|||
return parsed;
|
||||
}
|
||||
|
||||
export function resolveEventListOffset(params: {
|
||||
cursor?: string;
|
||||
total: number;
|
||||
limit: number;
|
||||
}): number {
|
||||
export function resolveEventListOffset(params: { cursor?: string; total: number; limit: number }): number {
|
||||
if (params.cursor != null) {
|
||||
return parseCursor(params.cursor);
|
||||
}
|
||||
|
|
@ -65,13 +54,10 @@ export class SandboxInstancePersistDriver implements SessionPersistDriver {
|
|||
|
||||
constructor(
|
||||
private readonly db: any,
|
||||
options: SandboxInstancePersistDriverOptions = {}
|
||||
options: SandboxInstancePersistDriverOptions = {},
|
||||
) {
|
||||
this.maxSessions = normalizeCap(options.maxSessions, DEFAULT_MAX_SESSIONS);
|
||||
this.maxEventsPerSession = normalizeCap(
|
||||
options.maxEventsPerSession,
|
||||
DEFAULT_MAX_EVENTS_PER_SESSION
|
||||
);
|
||||
this.maxEventsPerSession = normalizeCap(options.maxEventsPerSession, DEFAULT_MAX_EVENTS_PER_SESSION);
|
||||
}
|
||||
|
||||
async getSession(id: string): Promise<SessionRecord | null> {
|
||||
|
|
@ -132,10 +118,7 @@ export class SandboxInstancePersistDriver implements SessionPersistDriver {
|
|||
sessionInit: safeParseJson(row.sessionInitJson, undefined),
|
||||
}));
|
||||
|
||||
const totalRow = await this.db
|
||||
.select({ c: count() })
|
||||
.from(sandboxSessions)
|
||||
.get();
|
||||
const totalRow = await this.db.select({ c: count() }).from(sandboxSessions).get();
|
||||
const total = Number(totalRow?.c ?? 0);
|
||||
|
||||
const nextOffset = offset + items.length;
|
||||
|
|
@ -172,10 +155,7 @@ export class SandboxInstancePersistDriver implements SessionPersistDriver {
|
|||
.run();
|
||||
|
||||
// Evict oldest sessions beyond cap.
|
||||
const totalRow = await this.db
|
||||
.select({ c: count() })
|
||||
.from(sandboxSessions)
|
||||
.get();
|
||||
const totalRow = await this.db.select({ c: count() }).from(sandboxSessions).get();
|
||||
const total = Number(totalRow?.c ?? 0);
|
||||
const overflow = total - this.maxSessions;
|
||||
if (overflow <= 0) return;
|
||||
|
|
@ -195,11 +175,7 @@ export class SandboxInstancePersistDriver implements SessionPersistDriver {
|
|||
|
||||
async listEvents(request: ListEventsRequest): Promise<ListPage<SessionEvent>> {
|
||||
const limit = normalizeCap(request.limit, DEFAULT_LIST_LIMIT);
|
||||
const totalRow = await this.db
|
||||
.select({ c: count() })
|
||||
.from(sandboxSessionEvents)
|
||||
.where(eq(sandboxSessionEvents.sessionId, request.sessionId))
|
||||
.get();
|
||||
const totalRow = await this.db.select({ c: count() }).from(sandboxSessionEvents).where(eq(sandboxSessionEvents.sessionId, request.sessionId)).get();
|
||||
const total = Number(totalRow?.c ?? 0);
|
||||
const offset = resolveEventListOffset({
|
||||
cursor: request.cursor,
|
||||
|
|
@ -267,11 +243,7 @@ export class SandboxInstancePersistDriver implements SessionPersistDriver {
|
|||
.run();
|
||||
|
||||
// Trim oldest events beyond cap.
|
||||
const totalRow = await this.db
|
||||
.select({ c: count() })
|
||||
.from(sandboxSessionEvents)
|
||||
.where(eq(sandboxSessionEvents.sessionId, event.sessionId))
|
||||
.get();
|
||||
const totalRow = await this.db.select({ c: count() }).from(sandboxSessionEvents).where(eq(sandboxSessionEvents.sessionId, event.sessionId)).get();
|
||||
const total = Number(totalRow?.c ?? 0);
|
||||
const overflow = total - this.maxEventsPerSession;
|
||||
if (overflow <= 0) return;
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ import type {
|
|||
RepoStackActionResult,
|
||||
RepoRecord,
|
||||
SwitchResult,
|
||||
WorkspaceUseInput
|
||||
WorkspaceUseInput,
|
||||
} from "@openhandoff/shared";
|
||||
import { getActorRuntimeContext } from "../context.js";
|
||||
import { getHandoff, getOrCreateHistory, getOrCreateProject, selfWorkspace } from "../handles.js";
|
||||
|
|
@ -58,11 +58,7 @@ interface RepoOverviewInput {
|
|||
repoId: string;
|
||||
}
|
||||
|
||||
const WORKSPACE_QUEUE_NAMES = [
|
||||
"workspace.command.addRepo",
|
||||
"workspace.command.createHandoff",
|
||||
"workspace.command.refreshProviderProfiles",
|
||||
] as const;
|
||||
const WORKSPACE_QUEUE_NAMES = ["workspace.command.addRepo", "workspace.command.createHandoff", "workspace.command.refreshProviderProfiles"] as const;
|
||||
|
||||
type WorkspaceQueueName = (typeof WORKSPACE_QUEUE_NAMES)[number];
|
||||
|
||||
|
|
@ -79,11 +75,7 @@ function assertWorkspace(c: { state: WorkspaceState }, workspaceId: string): voi
|
|||
}
|
||||
|
||||
async function resolveRepoId(c: any, handoffId: string): Promise<string> {
|
||||
const row = await c.db
|
||||
.select({ repoId: handoffLookup.repoId })
|
||||
.from(handoffLookup)
|
||||
.where(eq(handoffLookup.handoffId, handoffId))
|
||||
.get();
|
||||
const row = await c.db.select({ repoId: handoffLookup.repoId }).from(handoffLookup).where(eq(handoffLookup.handoffId, handoffId)).get();
|
||||
|
||||
if (!row) {
|
||||
throw new Error(`Unknown handoff: ${handoffId} (not in lookup)`);
|
||||
|
|
@ -107,11 +99,7 @@ async function upsertHandoffLookupRow(c: any, handoffId: string, repoId: string)
|
|||
}
|
||||
|
||||
async function collectAllHandoffSummaries(c: any): Promise<HandoffSummary[]> {
|
||||
const repoRows = await c.db
|
||||
.select({ repoId: repos.repoId, remoteUrl: repos.remoteUrl })
|
||||
.from(repos)
|
||||
.orderBy(desc(repos.updatedAt))
|
||||
.all();
|
||||
const repoRows = await c.db.select({ repoId: repos.repoId, remoteUrl: repos.remoteUrl }).from(repos).orderBy(desc(repos.updatedAt)).all();
|
||||
|
||||
const all: HandoffSummary[] = [];
|
||||
for (const row of repoRows) {
|
||||
|
|
@ -123,7 +111,7 @@ async function collectAllHandoffSummaries(c: any): Promise<HandoffSummary[]> {
|
|||
logActorWarning("workspace", "failed collecting handoffs for repo", {
|
||||
workspaceId: c.state.workspaceId,
|
||||
repoId: row.repoId,
|
||||
error: resolveErrorMessage(error)
|
||||
error: resolveErrorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -172,7 +160,7 @@ async function buildWorkbenchSnapshot(c: any): Promise<HandoffWorkbenchSnapshot>
|
|||
workspaceId: c.state.workspaceId,
|
||||
repoId: row.repoId,
|
||||
handoffId: summary.handoffId,
|
||||
error: resolveErrorMessage(error)
|
||||
error: resolveErrorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -189,7 +177,7 @@ async function buildWorkbenchSnapshot(c: any): Promise<HandoffWorkbenchSnapshot>
|
|||
logActorWarning("workspace", "failed collecting workbench repo snapshot", {
|
||||
workspaceId: c.state.workspaceId,
|
||||
repoId: row.repoId,
|
||||
error: resolveErrorMessage(error)
|
||||
error: resolveErrorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -200,7 +188,7 @@ async function buildWorkbenchSnapshot(c: any): Promise<HandoffWorkbenchSnapshot>
|
|||
workspaceId: c.state.workspaceId,
|
||||
repos: repoRows.map((row) => ({
|
||||
id: row.repoId,
|
||||
label: repoLabelFromRemote(row.remoteUrl)
|
||||
label: repoLabelFromRemote(row.remoteUrl),
|
||||
})),
|
||||
projects,
|
||||
handoffs,
|
||||
|
|
@ -232,14 +220,14 @@ async function addRepoMutation(c: any, input: AddRepoInput): Promise<RepoRecord>
|
|||
repoId,
|
||||
remoteUrl,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: repos.repoId,
|
||||
set: {
|
||||
remoteUrl,
|
||||
updatedAt: now
|
||||
}
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
|
||||
|
|
@ -249,7 +237,7 @@ async function addRepoMutation(c: any, input: AddRepoInput): Promise<RepoRecord>
|
|||
repoId,
|
||||
remoteUrl,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -260,11 +248,7 @@ async function createHandoffMutation(c: any, input: CreateHandoffInput): Promise
|
|||
const providerId = input.providerId ?? providers.defaultProviderId();
|
||||
|
||||
const repoId = input.repoId;
|
||||
const repoRow = await c.db
|
||||
.select({ remoteUrl: repos.remoteUrl })
|
||||
.from(repos)
|
||||
.where(eq(repos.repoId, repoId))
|
||||
.get();
|
||||
const repoRow = await c.db.select({ remoteUrl: repos.remoteUrl }).from(repos).where(eq(repos.repoId, repoId)).get();
|
||||
if (!repoRow) {
|
||||
throw new Error(`Unknown repo: ${repoId}`);
|
||||
}
|
||||
|
|
@ -275,14 +259,14 @@ async function createHandoffMutation(c: any, input: CreateHandoffInput): Promise
|
|||
.values({
|
||||
providerId,
|
||||
profileJson: JSON.stringify({ providerId }),
|
||||
updatedAt: Date.now()
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: providerProfiles.providerId,
|
||||
set: {
|
||||
profileJson: JSON.stringify({ providerId }),
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
})
|
||||
.run();
|
||||
|
||||
|
|
@ -295,18 +279,18 @@ async function createHandoffMutation(c: any, input: CreateHandoffInput): Promise
|
|||
agentType: input.agentType ?? null,
|
||||
explicitTitle: input.explicitTitle ?? null,
|
||||
explicitBranchName: input.explicitBranchName ?? null,
|
||||
onBranch: input.onBranch ?? null
|
||||
onBranch: input.onBranch ?? null,
|
||||
});
|
||||
|
||||
await c.db
|
||||
.insert(handoffLookup)
|
||||
.values({
|
||||
handoffId: created.handoffId,
|
||||
repoId
|
||||
repoId,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: handoffLookup.handoffId,
|
||||
set: { repoId }
|
||||
set: { repoId },
|
||||
})
|
||||
.run();
|
||||
|
||||
|
|
@ -328,14 +312,14 @@ async function refreshProviderProfilesMutation(c: any, command?: RefreshProvider
|
|||
.values({
|
||||
providerId,
|
||||
profileJson: JSON.stringify({ providerId }),
|
||||
updatedAt: Date.now()
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: providerProfiles.providerId,
|
||||
set: {
|
||||
profileJson: JSON.stringify({ providerId }),
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
|
@ -406,7 +390,7 @@ export const workspaceActions = {
|
|||
repoId: repos.repoId,
|
||||
remoteUrl: repos.remoteUrl,
|
||||
createdAt: repos.createdAt,
|
||||
updatedAt: repos.updatedAt
|
||||
updatedAt: repos.updatedAt,
|
||||
})
|
||||
.from(repos)
|
||||
.orderBy(desc(repos.updatedAt))
|
||||
|
|
@ -417,7 +401,7 @@ export const workspaceActions = {
|
|||
repoId: row.repoId,
|
||||
remoteUrl: row.remoteUrl,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt
|
||||
updatedAt: row.updatedAt,
|
||||
}));
|
||||
},
|
||||
|
||||
|
|
@ -447,7 +431,7 @@ export const workspaceActions = {
|
|||
task: input.task,
|
||||
...(input.title ? { explicitTitle: input.title } : {}),
|
||||
...(input.branch ? { explicitBranchName: input.branch } : {}),
|
||||
...(input.model ? { agentType: agentTypeForModel(input.model) } : {})
|
||||
...(input.model ? { agentType: agentTypeForModel(input.model) } : {}),
|
||||
});
|
||||
return { handoffId: created.handoffId };
|
||||
},
|
||||
|
|
@ -521,11 +505,7 @@ export const workspaceActions = {
|
|||
assertWorkspace(c, input.workspaceId);
|
||||
|
||||
if (input.repoId) {
|
||||
const repoRow = await c.db
|
||||
.select({ remoteUrl: repos.remoteUrl })
|
||||
.from(repos)
|
||||
.where(eq(repos.repoId, input.repoId))
|
||||
.get();
|
||||
const repoRow = await c.db.select({ remoteUrl: repos.remoteUrl }).from(repos).where(eq(repos.repoId, input.repoId)).get();
|
||||
if (!repoRow) {
|
||||
throw new Error(`Unknown repo: ${input.repoId}`);
|
||||
}
|
||||
|
|
@ -540,11 +520,7 @@ export const workspaceActions = {
|
|||
async getRepoOverview(c: any, input: RepoOverviewInput): Promise<RepoOverview> {
|
||||
assertWorkspace(c, input.workspaceId);
|
||||
|
||||
const repoRow = await c.db
|
||||
.select({ remoteUrl: repos.remoteUrl })
|
||||
.from(repos)
|
||||
.where(eq(repos.repoId, input.repoId))
|
||||
.get();
|
||||
const repoRow = await c.db.select({ remoteUrl: repos.remoteUrl }).from(repos).where(eq(repos.repoId, input.repoId)).get();
|
||||
if (!repoRow) {
|
||||
throw new Error(`Unknown repo: ${input.repoId}`);
|
||||
}
|
||||
|
|
@ -557,11 +533,7 @@ export const workspaceActions = {
|
|||
async runRepoStackAction(c: any, input: RepoStackActionInput): Promise<RepoStackActionResult> {
|
||||
assertWorkspace(c, input.workspaceId);
|
||||
|
||||
const repoRow = await c.db
|
||||
.select({ remoteUrl: repos.remoteUrl })
|
||||
.from(repos)
|
||||
.where(eq(repos.repoId, input.repoId))
|
||||
.get();
|
||||
const repoRow = await c.db.select({ remoteUrl: repos.remoteUrl }).from(repos).where(eq(repos.repoId, input.repoId)).get();
|
||||
if (!repoRow) {
|
||||
throw new Error(`Unknown repo: ${input.repoId}`);
|
||||
}
|
||||
|
|
@ -571,7 +543,7 @@ export const workspaceActions = {
|
|||
return await project.runRepoStackAction({
|
||||
action: input.action,
|
||||
branchName: input.branchName,
|
||||
parentBranch: input.parentBranch
|
||||
parentBranch: input.parentBranch,
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -585,7 +557,7 @@ export const workspaceActions = {
|
|||
workspaceId: c.state.workspaceId,
|
||||
handoffId,
|
||||
providerId: record.providerId,
|
||||
switchTarget: switched.switchTarget
|
||||
switchTarget: switched.switchTarget,
|
||||
};
|
||||
},
|
||||
|
||||
|
|
@ -611,14 +583,14 @@ export const workspaceActions = {
|
|||
const items = await hist.list({
|
||||
branch: input.branch,
|
||||
handoffId: input.handoffId,
|
||||
limit
|
||||
limit,
|
||||
});
|
||||
allEvents.push(...items);
|
||||
} catch (error) {
|
||||
logActorWarning("workspace", "history lookup failed for repo", {
|
||||
workspaceId: c.state.workspaceId,
|
||||
repoId: row.repoId,
|
||||
error: resolveErrorMessage(error)
|
||||
error: resolveErrorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -632,11 +604,7 @@ export const workspaceActions = {
|
|||
|
||||
const repoId = await resolveRepoId(c, input.handoffId);
|
||||
|
||||
const repoRow = await c.db
|
||||
.select({ remoteUrl: repos.remoteUrl })
|
||||
.from(repos)
|
||||
.where(eq(repos.repoId, repoId))
|
||||
.get();
|
||||
const repoRow = await c.db.select({ remoteUrl: repos.remoteUrl }).from(repos).where(eq(repos.repoId, repoId)).get();
|
||||
if (!repoRow) {
|
||||
throw new Error(`Unknown repo: ${repoId}`);
|
||||
}
|
||||
|
|
@ -685,5 +653,5 @@ export const workspaceActions = {
|
|||
const repoId = await resolveRepoId(c, input.handoffId);
|
||||
const h = getHandoff(c, c.state.workspaceId, repoId, input.handoffId);
|
||||
await h.kill({ reason: input.reason });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,4 +4,3 @@ export default defineConfig({
|
|||
out: "./src/actors/workspace/db/drizzle",
|
||||
schema: "./src/actors/workspace/db/schema.ts",
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -46,4 +46,4 @@
|
|||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,4 +84,4 @@
|
|||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,26 +3,26 @@
|
|||
// Do not hand-edit this file.
|
||||
|
||||
const journal = {
|
||||
"entries": [
|
||||
entries: [
|
||||
{
|
||||
"idx": 0,
|
||||
"when": 1770924376525,
|
||||
"tag": "0000_rare_iron_man",
|
||||
"breakpoints": true
|
||||
idx: 0,
|
||||
when: 1770924376525,
|
||||
tag: "0000_rare_iron_man",
|
||||
breakpoints: true,
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"when": 1770947252912,
|
||||
"tag": "0001_sleepy_lady_deathstrike",
|
||||
"breakpoints": true
|
||||
idx: 1,
|
||||
when: 1770947252912,
|
||||
tag: "0001_sleepy_lady_deathstrike",
|
||||
breakpoints: true,
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"when": 1772668800000,
|
||||
"tag": "0002_tiny_silver_surfer",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
idx: 2,
|
||||
when: 1772668800000,
|
||||
tag: "0002_tiny_silver_surfer",
|
||||
breakpoints: true,
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
|
||||
export default {
|
||||
|
|
@ -46,5 +46,5 @@ export default {
|
|||
\`repo_id\` text NOT NULL
|
||||
);
|
||||
`,
|
||||
} as const
|
||||
} as const,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export const workspace = actor({
|
|||
actionTimeout: 5 * 60_000,
|
||||
},
|
||||
createState: (_c, workspaceId: string) => ({
|
||||
workspaceId
|
||||
workspaceId,
|
||||
}),
|
||||
actions: workspaceActions,
|
||||
run: workflow(runWorkspaceWorkflow),
|
||||
|
|
|
|||
|
|
@ -34,11 +34,8 @@ export interface ActorSqliteDbOptions<TSchema extends Record<string, unknown>> {
|
|||
baseDir?: string;
|
||||
}
|
||||
|
||||
export function actorSqliteDb<TSchema extends Record<string, unknown>>(
|
||||
options: ActorSqliteDbOptions<TSchema>
|
||||
): DatabaseProvider<any & RawAccess> {
|
||||
const isBunRuntime =
|
||||
typeof (globalThis as any).Bun !== "undefined" && typeof (process as any)?.versions?.bun === "string";
|
||||
export function actorSqliteDb<TSchema extends Record<string, unknown>>(options: ActorSqliteDbOptions<TSchema>): DatabaseProvider<any & RawAccess> {
|
||||
const isBunRuntime = typeof (globalThis as any).Bun !== "undefined" && typeof (process as any)?.versions?.bun === "string";
|
||||
|
||||
// Backend tests run in a Node-ish Vitest environment where `bun:sqlite` and
|
||||
// Bun's sqlite-backed Drizzle driver are not supported.
|
||||
|
|
|
|||
|
|
@ -1,17 +1,8 @@
|
|||
import type { BranchSnapshot } from "./integrations/git/index.js";
|
||||
import type { PullRequestSnapshot } from "./integrations/github/index.js";
|
||||
import type {
|
||||
SandboxSession,
|
||||
SandboxAgentClientOptions,
|
||||
SandboxSessionCreateRequest
|
||||
} from "./integrations/sandbox-agent/client.js";
|
||||
import type { SandboxSession, SandboxAgentClientOptions, SandboxSessionCreateRequest } from "./integrations/sandbox-agent/client.js";
|
||||
import type { ListEventsRequest, ListPage, ListPageRequest, SessionEvent, SessionRecord } from "sandbox-agent";
|
||||
import type {
|
||||
DaytonaClientOptions,
|
||||
DaytonaCreateSandboxOptions,
|
||||
DaytonaPreviewEndpoint,
|
||||
DaytonaSandbox,
|
||||
} from "./integrations/daytona/client.js";
|
||||
import type { DaytonaClientOptions, DaytonaCreateSandboxOptions, DaytonaPreviewEndpoint, DaytonaSandbox } from "./integrations/daytona/client.js";
|
||||
import {
|
||||
validateRemote,
|
||||
ensureCloned,
|
||||
|
|
@ -67,12 +58,7 @@ export interface StackDriver {
|
|||
|
||||
export interface GithubDriver {
|
||||
listPullRequests(repoPath: string): Promise<PullRequestSnapshot[]>;
|
||||
createPr(
|
||||
repoPath: string,
|
||||
headBranch: string,
|
||||
title: string,
|
||||
body?: string
|
||||
): Promise<{ number: number; url: string }>;
|
||||
createPr(repoPath: string, headBranch: string, title: string, body?: string): Promise<{ number: number; url: string }>;
|
||||
}
|
||||
|
||||
export interface SandboxAgentClientLike {
|
||||
|
|
|
|||
|
|
@ -33,10 +33,8 @@ export async function startBackend(options: BackendStartOptions = {}): Promise<v
|
|||
return undefined;
|
||||
};
|
||||
|
||||
config.providers.daytona.endpoint =
|
||||
envFirst("HF_DAYTONA_ENDPOINT", "DAYTONA_ENDPOINT") ?? config.providers.daytona.endpoint;
|
||||
config.providers.daytona.apiKey =
|
||||
envFirst("HF_DAYTONA_API_KEY", "DAYTONA_API_KEY") ?? config.providers.daytona.apiKey;
|
||||
config.providers.daytona.endpoint = envFirst("HF_DAYTONA_ENDPOINT", "DAYTONA_ENDPOINT") ?? config.providers.daytona.endpoint;
|
||||
config.providers.daytona.apiKey = envFirst("HF_DAYTONA_API_KEY", "DAYTONA_API_KEY") ?? config.providers.daytona.apiKey;
|
||||
|
||||
const driver = createDefaultDriver();
|
||||
const providers = createProviderRegistry(config, driver);
|
||||
|
|
@ -58,7 +56,7 @@ export async function startBackend(options: BackendStartOptions = {}): Promise<v
|
|||
allowHeaders: ["Content-Type", "Authorization", "x-rivet-token"],
|
||||
allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
exposeHeaders: ["Content-Type"],
|
||||
})
|
||||
}),
|
||||
);
|
||||
app.use(
|
||||
"/api/rivet",
|
||||
|
|
@ -67,7 +65,7 @@ export async function startBackend(options: BackendStartOptions = {}): Promise<v
|
|||
allowHeaders: ["Content-Type", "Authorization", "x-rivet-token"],
|
||||
allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
exposeHeaders: ["Content-Type"],
|
||||
})
|
||||
}),
|
||||
);
|
||||
const forward = async (c: any) => {
|
||||
try {
|
||||
|
|
@ -86,7 +84,7 @@ export async function startBackend(options: BackendStartOptions = {}): Promise<v
|
|||
const server = Bun.serve({
|
||||
fetch: app.fetch,
|
||||
hostname: config.backend.host,
|
||||
port: config.backend.port
|
||||
port: config.backend.port,
|
||||
});
|
||||
|
||||
process.on("SIGINT", async () => {
|
||||
|
|
@ -130,13 +128,13 @@ async function main(): Promise<void> {
|
|||
const port = parseArg("--port") ?? process.env.PORT ?? process.env.HF_BACKEND_PORT;
|
||||
await startBackend({
|
||||
host,
|
||||
port: parseEnvPort(port)
|
||||
port: parseEnvPort(port),
|
||||
});
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
main().catch((err: unknown) => {
|
||||
const message = err instanceof Error ? err.stack ?? err.message : String(err);
|
||||
const message = err instanceof Error ? (err.stack ?? err.message) : String(err);
|
||||
console.error(message);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -52,9 +52,7 @@ export class DaytonaClient {
|
|||
image: options.image,
|
||||
envVars: options.envVars,
|
||||
labels: options.labels,
|
||||
...(options.autoStopInterval !== undefined
|
||||
? { autoStopInterval: options.autoStopInterval }
|
||||
: {}),
|
||||
...(options.autoStopInterval !== undefined ? { autoStopInterval: options.autoStopInterval } : {}),
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -32,18 +32,10 @@ function commandLabel(cmd: SpiceCommand): string {
|
|||
|
||||
function looksMissing(error: unknown): boolean {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
return (
|
||||
detail.includes("ENOENT") ||
|
||||
detail.includes("not a git command") ||
|
||||
detail.includes("command not found")
|
||||
);
|
||||
return detail.includes("ENOENT") || detail.includes("not a git command") || detail.includes("command not found");
|
||||
}
|
||||
|
||||
async function tryRun(
|
||||
repoPath: string,
|
||||
cmd: SpiceCommand,
|
||||
args: string[]
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
async function tryRun(repoPath: string, cmd: SpiceCommand, args: string[]): Promise<{ stdout: string; stderr: string }> {
|
||||
return await execFileAsync(cmd.command, [...cmd.prefix, ...args], {
|
||||
cwd: repoPath,
|
||||
timeout: DEFAULT_TIMEOUT_MS,
|
||||
|
|
@ -51,8 +43,8 @@ async function tryRun(
|
|||
env: {
|
||||
...process.env,
|
||||
NO_COLOR: "1",
|
||||
FORCE_COLOR: "0"
|
||||
}
|
||||
FORCE_COLOR: "0",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -140,14 +132,7 @@ export async function gitSpiceAvailable(repoPath: string): Promise<boolean> {
|
|||
|
||||
export async function gitSpiceListStack(repoPath: string): Promise<SpiceStackEntry[]> {
|
||||
try {
|
||||
const { stdout } = await runSpice(repoPath, [
|
||||
"log",
|
||||
"short",
|
||||
"--all",
|
||||
"--json",
|
||||
"--no-cr-status",
|
||||
"--no-prompt"
|
||||
]);
|
||||
const { stdout } = await runSpice(repoPath, ["log", "short", "--all", "--json", "--no-cr-status", "--no-prompt"]);
|
||||
return parseLogJson(stdout);
|
||||
} catch {
|
||||
return [];
|
||||
|
|
@ -160,9 +145,9 @@ export async function gitSpiceSyncRepo(repoPath: string): Promise<void> {
|
|||
[
|
||||
["repo", "sync", "--restack", "--no-prompt"],
|
||||
["repo", "sync", "--restack"],
|
||||
["repo", "sync"]
|
||||
["repo", "sync"],
|
||||
],
|
||||
"git-spice repo sync failed"
|
||||
"git-spice repo sync failed",
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -171,9 +156,9 @@ export async function gitSpiceRestackRepo(repoPath: string): Promise<void> {
|
|||
repoPath,
|
||||
[
|
||||
["repo", "restack", "--no-prompt"],
|
||||
["repo", "restack"]
|
||||
["repo", "restack"],
|
||||
],
|
||||
"git-spice repo restack failed"
|
||||
"git-spice repo restack failed",
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -184,9 +169,9 @@ export async function gitSpiceRestackSubtree(repoPath: string, branchName: strin
|
|||
["upstack", "restack", "--branch", branchName, "--no-prompt"],
|
||||
["upstack", "restack", "--branch", branchName],
|
||||
["branch", "restack", "--branch", branchName, "--no-prompt"],
|
||||
["branch", "restack", "--branch", branchName]
|
||||
["branch", "restack", "--branch", branchName],
|
||||
],
|
||||
`git-spice restack subtree failed for ${branchName}`
|
||||
`git-spice restack subtree failed for ${branchName}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -195,41 +180,33 @@ export async function gitSpiceRebaseBranch(repoPath: string, branchName: string)
|
|||
repoPath,
|
||||
[
|
||||
["branch", "restack", "--branch", branchName, "--no-prompt"],
|
||||
["branch", "restack", "--branch", branchName]
|
||||
["branch", "restack", "--branch", branchName],
|
||||
],
|
||||
`git-spice branch restack failed for ${branchName}`
|
||||
`git-spice branch restack failed for ${branchName}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function gitSpiceReparentBranch(
|
||||
repoPath: string,
|
||||
branchName: string,
|
||||
parentBranch: string
|
||||
): Promise<void> {
|
||||
export async function gitSpiceReparentBranch(repoPath: string, branchName: string, parentBranch: string): Promise<void> {
|
||||
await runFallbacks(
|
||||
repoPath,
|
||||
[
|
||||
["upstack", "onto", "--branch", branchName, parentBranch, "--no-prompt"],
|
||||
["upstack", "onto", "--branch", branchName, parentBranch],
|
||||
["branch", "onto", "--branch", branchName, parentBranch, "--no-prompt"],
|
||||
["branch", "onto", "--branch", branchName, parentBranch]
|
||||
["branch", "onto", "--branch", branchName, parentBranch],
|
||||
],
|
||||
`git-spice reparent failed for ${branchName} -> ${parentBranch}`
|
||||
`git-spice reparent failed for ${branchName} -> ${parentBranch}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function gitSpiceTrackBranch(
|
||||
repoPath: string,
|
||||
branchName: string,
|
||||
parentBranch: string
|
||||
): Promise<void> {
|
||||
export async function gitSpiceTrackBranch(repoPath: string, branchName: string, parentBranch: string): Promise<void> {
|
||||
await runFallbacks(
|
||||
repoPath,
|
||||
[
|
||||
["branch", "track", branchName, "--base", parentBranch, "--no-prompt"],
|
||||
["branch", "track", branchName, "--base", parentBranch]
|
||||
["branch", "track", branchName, "--base", parentBranch],
|
||||
],
|
||||
`git-spice track failed for ${branchName}`
|
||||
`git-spice track failed for ${branchName}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,12 +11,7 @@ const DEFAULT_GIT_FETCH_TIMEOUT_MS = 2 * 60_000;
|
|||
const DEFAULT_GIT_CLONE_TIMEOUT_MS = 5 * 60_000;
|
||||
|
||||
function resolveGithubToken(): string | null {
|
||||
const token =
|
||||
process.env.GH_TOKEN ??
|
||||
process.env.GITHUB_TOKEN ??
|
||||
process.env.HF_GITHUB_TOKEN ??
|
||||
process.env.HF_GH_TOKEN ??
|
||||
null;
|
||||
const token = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN ?? process.env.HF_GITHUB_TOKEN ?? process.env.HF_GH_TOKEN ?? null;
|
||||
if (!token) return null;
|
||||
const trimmed = token.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
|
|
@ -33,19 +28,18 @@ function ensureAskpassScript(): string {
|
|||
|
||||
// Git invokes $GIT_ASKPASS with the prompt string as argv[1]. Provide both username and password.
|
||||
// We avoid embedding the token in this file; it is read from env at runtime.
|
||||
const content =
|
||||
[
|
||||
"#!/bin/sh",
|
||||
'prompt="$1"',
|
||||
// Prefer GH_TOKEN/GITHUB_TOKEN but support HF_* aliases too.
|
||||
'token="${GH_TOKEN:-${GITHUB_TOKEN:-${HF_GITHUB_TOKEN:-${HF_GH_TOKEN:-}}}}"',
|
||||
'case "$prompt" in',
|
||||
' *Username*) echo "x-access-token" ;;',
|
||||
' *Password*) echo "$token" ;;',
|
||||
' *) echo "" ;;',
|
||||
"esac",
|
||||
"",
|
||||
].join("\n");
|
||||
const content = [
|
||||
"#!/bin/sh",
|
||||
'prompt="$1"',
|
||||
// Prefer GH_TOKEN/GITHUB_TOKEN but support HF_* aliases too.
|
||||
'token="${GH_TOKEN:-${GITHUB_TOKEN:-${HF_GITHUB_TOKEN:-${HF_GH_TOKEN:-}}}}"',
|
||||
'case "$prompt" in',
|
||||
' *Username*) echo "x-access-token" ;;',
|
||||
' *Password*) echo "$token" ;;',
|
||||
' *) echo "" ;;',
|
||||
"esac",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
writeFileSync(path, content, "utf8");
|
||||
chmodSync(path, 0o700);
|
||||
|
|
@ -141,12 +135,7 @@ export async function ensureCloned(remoteUrl: string, targetPath: string): Promi
|
|||
|
||||
export async function remoteDefaultBaseRef(repoPath: string): Promise<string> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync("git", [
|
||||
"-C",
|
||||
repoPath,
|
||||
"symbolic-ref",
|
||||
"refs/remotes/origin/HEAD",
|
||||
], { env: gitEnv() });
|
||||
const { stdout } = await execFileAsync("git", ["-C", repoPath, "symbolic-ref", "refs/remotes/origin/HEAD"], { env: gitEnv() });
|
||||
const ref = stdout.trim(); // refs/remotes/origin/main
|
||||
const match = ref.match(/^refs\/remotes\/(.+)$/);
|
||||
if (match?.[1]) {
|
||||
|
|
@ -169,17 +158,10 @@ export async function remoteDefaultBaseRef(repoPath: string): Promise<string> {
|
|||
}
|
||||
|
||||
export async function listRemoteBranches(repoPath: string): Promise<BranchSnapshot[]> {
|
||||
const { stdout } = await execFileAsync(
|
||||
"git",
|
||||
[
|
||||
"-C",
|
||||
repoPath,
|
||||
"for-each-ref",
|
||||
"--format=%(refname:short) %(objectname)",
|
||||
"refs/remotes/origin",
|
||||
],
|
||||
{ maxBuffer: 1024 * 1024, env: gitEnv() }
|
||||
);
|
||||
const { stdout } = await execFileAsync("git", ["-C", repoPath, "for-each-ref", "--format=%(refname:short) %(objectname)", "refs/remotes/origin"], {
|
||||
maxBuffer: 1024 * 1024,
|
||||
env: gitEnv(),
|
||||
});
|
||||
|
||||
return stdout
|
||||
.trim()
|
||||
|
|
@ -191,24 +173,12 @@ export async function listRemoteBranches(repoPath: string): Promise<BranchSnapsh
|
|||
const branchName = short.replace(/^origin\//, "");
|
||||
return { branchName, commitSha: commitSha ?? "" };
|
||||
})
|
||||
.filter(
|
||||
(row) =>
|
||||
row.branchName.length > 0 &&
|
||||
row.branchName !== "HEAD" &&
|
||||
row.branchName !== "origin" &&
|
||||
row.commitSha.length > 0,
|
||||
);
|
||||
.filter((row) => row.branchName.length > 0 && row.branchName !== "HEAD" && row.branchName !== "origin" && row.commitSha.length > 0);
|
||||
}
|
||||
|
||||
async function remoteBranchExists(repoPath: string, branchName: string): Promise<boolean> {
|
||||
try {
|
||||
await execFileAsync("git", [
|
||||
"-C",
|
||||
repoPath,
|
||||
"show-ref",
|
||||
"--verify",
|
||||
`refs/remotes/origin/${branchName}`,
|
||||
], { env: gitEnv() });
|
||||
await execFileAsync("git", ["-C", repoPath, "show-ref", "--verify", `refs/remotes/origin/${branchName}`], { env: gitEnv() });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
|
|
@ -233,11 +203,10 @@ export async function diffStatForBranch(repoPath: string, branchName: string): P
|
|||
try {
|
||||
const baseRef = await remoteDefaultBaseRef(repoPath);
|
||||
const headRef = `origin/${branchName}`;
|
||||
const { stdout } = await execFileAsync(
|
||||
"git",
|
||||
["-C", repoPath, "diff", "--shortstat", `${baseRef}...${headRef}`],
|
||||
{ maxBuffer: 1024 * 1024, env: gitEnv() }
|
||||
);
|
||||
const { stdout } = await execFileAsync("git", ["-C", repoPath, "diff", "--shortstat", `${baseRef}...${headRef}`], {
|
||||
maxBuffer: 1024 * 1024,
|
||||
env: gitEnv(),
|
||||
});
|
||||
const trimmed = stdout.trim();
|
||||
if (!trimmed) {
|
||||
return "+0/-0";
|
||||
|
|
@ -252,20 +221,13 @@ export async function diffStatForBranch(repoPath: string, branchName: string): P
|
|||
}
|
||||
}
|
||||
|
||||
export async function conflictsWithMain(
|
||||
repoPath: string,
|
||||
branchName: string
|
||||
): Promise<boolean> {
|
||||
export async function conflictsWithMain(repoPath: string, branchName: string): Promise<boolean> {
|
||||
try {
|
||||
const baseRef = await remoteDefaultBaseRef(repoPath);
|
||||
const headRef = `origin/${branchName}`;
|
||||
// Use merge-tree (git 2.38+) for a clean conflict check.
|
||||
try {
|
||||
await execFileAsync(
|
||||
"git",
|
||||
["-C", repoPath, "merge-tree", "--write-tree", "--no-messages", baseRef, headRef],
|
||||
{ env: gitEnv() }
|
||||
);
|
||||
await execFileAsync("git", ["-C", repoPath, "merge-tree", "--write-tree", "--no-messages", baseRef, headRef], { env: gitEnv() });
|
||||
// If merge-tree exits 0, no conflicts. Non-zero exit means conflicts.
|
||||
return false;
|
||||
} catch {
|
||||
|
|
@ -279,11 +241,7 @@ export async function conflictsWithMain(
|
|||
|
||||
export async function getOriginOwner(repoPath: string): Promise<string> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
"git",
|
||||
["-C", repoPath, "remote", "get-url", "origin"],
|
||||
{ env: gitEnv() }
|
||||
);
|
||||
const { stdout } = await execFileAsync("git", ["-C", repoPath, "remote", "get-url", "origin"], { env: gitEnv() });
|
||||
const url = stdout.trim();
|
||||
// Handle SSH: git@github.com:owner/repo.git
|
||||
const sshMatch = url.match(/[:\/]([^\/]+)\/[^\/]+(?:\.git)?$/);
|
||||
|
|
|
|||
|
|
@ -36,9 +36,7 @@ interface GhPrListItem {
|
|||
}>;
|
||||
}
|
||||
|
||||
function parseCiStatus(
|
||||
checks: GhPrListItem["statusCheckRollup"]
|
||||
): string | null {
|
||||
function parseCiStatus(checks: GhPrListItem["statusCheckRollup"]): string | null {
|
||||
if (!checks || checks.length === 0) return null;
|
||||
|
||||
let total = 0;
|
||||
|
|
@ -53,12 +51,7 @@ function parseCiStatus(
|
|||
|
||||
if (conclusion === "SUCCESS" || state === "SUCCESS") {
|
||||
successes++;
|
||||
} else if (
|
||||
status === "IN_PROGRESS" ||
|
||||
status === "QUEUED" ||
|
||||
status === "PENDING" ||
|
||||
state === "PENDING"
|
||||
) {
|
||||
} else if (status === "IN_PROGRESS" || status === "QUEUED" || status === "PENDING" || state === "PENDING") {
|
||||
hasRunning = true;
|
||||
}
|
||||
}
|
||||
|
|
@ -70,9 +63,7 @@ function parseCiStatus(
|
|||
return `${successes}/${total}`;
|
||||
}
|
||||
|
||||
function parseReviewStatus(
|
||||
reviews: GhPrListItem["reviews"]
|
||||
): { status: string | null; reviewer: string | null } {
|
||||
function parseReviewStatus(reviews: GhPrListItem["reviews"]): { status: string | null; reviewer: string | null } {
|
||||
if (!reviews || reviews.length === 0) {
|
||||
return { status: null, reviewer: null };
|
||||
}
|
||||
|
|
@ -120,35 +111,21 @@ function snapshotFromGhItem(item: GhPrListItem): PullRequestSnapshot {
|
|||
isDraft: item.isDraft ?? false,
|
||||
ciStatus: parseCiStatus(item.statusCheckRollup),
|
||||
reviewStatus,
|
||||
reviewer
|
||||
reviewer,
|
||||
};
|
||||
}
|
||||
|
||||
const PR_JSON_FIELDS =
|
||||
"number,headRefName,state,title,url,author,isDraft,statusCheckRollup,reviews";
|
||||
const PR_JSON_FIELDS = "number,headRefName,state,title,url,author,isDraft,statusCheckRollup,reviews";
|
||||
|
||||
export async function listPullRequests(repoPath: string): Promise<PullRequestSnapshot[]> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
"gh",
|
||||
[
|
||||
"pr",
|
||||
"list",
|
||||
"--json",
|
||||
PR_JSON_FIELDS,
|
||||
"--limit",
|
||||
"200"
|
||||
],
|
||||
{ maxBuffer: 1024 * 1024 * 4, cwd: repoPath }
|
||||
);
|
||||
const { stdout } = await execFileAsync("gh", ["pr", "list", "--json", PR_JSON_FIELDS, "--limit", "200"], { maxBuffer: 1024 * 1024 * 4, cwd: repoPath });
|
||||
|
||||
const parsed = JSON.parse(stdout) as GhPrListItem[];
|
||||
|
||||
return parsed.map((item) => {
|
||||
// Handle fork PRs where headRefName may contain "owner:branch"
|
||||
const headRefName = item.headRefName.includes(":")
|
||||
? item.headRefName.split(":").pop() ?? item.headRefName
|
||||
: item.headRefName;
|
||||
const headRefName = item.headRefName.includes(":") ? (item.headRefName.split(":").pop() ?? item.headRefName) : item.headRefName;
|
||||
|
||||
return snapshotFromGhItem({ ...item, headRefName });
|
||||
});
|
||||
|
|
@ -157,22 +134,9 @@ export async function listPullRequests(repoPath: string): Promise<PullRequestSna
|
|||
}
|
||||
}
|
||||
|
||||
export async function getPrInfo(
|
||||
repoPath: string,
|
||||
branchName: string
|
||||
): Promise<PullRequestSnapshot | null> {
|
||||
export async function getPrInfo(repoPath: string, branchName: string): Promise<PullRequestSnapshot | null> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
"gh",
|
||||
[
|
||||
"pr",
|
||||
"view",
|
||||
branchName,
|
||||
"--json",
|
||||
PR_JSON_FIELDS
|
||||
],
|
||||
{ maxBuffer: 1024 * 1024 * 4, cwd: repoPath }
|
||||
);
|
||||
const { stdout } = await execFileAsync("gh", ["pr", "view", branchName, "--json", PR_JSON_FIELDS], { maxBuffer: 1024 * 1024 * 4, cwd: repoPath });
|
||||
|
||||
const item = JSON.parse(stdout) as GhPrListItem;
|
||||
return snapshotFromGhItem(item);
|
||||
|
|
@ -181,12 +145,7 @@ export async function getPrInfo(
|
|||
}
|
||||
}
|
||||
|
||||
export async function createPr(
|
||||
repoPath: string,
|
||||
headBranch: string,
|
||||
title: string,
|
||||
body?: string
|
||||
): Promise<{ number: number; url: string }> {
|
||||
export async function createPr(repoPath: string, headBranch: string, title: string, body?: string): Promise<{ number: number; url: string }> {
|
||||
const args = ["pr", "create", "--title", title, "--head", headBranch];
|
||||
if (body) {
|
||||
args.push("--body", body);
|
||||
|
|
@ -196,7 +155,7 @@ export async function createPr(
|
|||
|
||||
const { stdout } = await execFileAsync("gh", args, {
|
||||
maxBuffer: 1024 * 1024,
|
||||
cwd: repoPath
|
||||
cwd: repoPath,
|
||||
});
|
||||
|
||||
// gh pr create outputs the PR URL on success
|
||||
|
|
@ -208,29 +167,17 @@ export async function createPr(
|
|||
return { number, url };
|
||||
}
|
||||
|
||||
export async function getAllowedMergeMethod(
|
||||
repoPath: string
|
||||
): Promise<"squash" | "rebase" | "merge"> {
|
||||
export async function getAllowedMergeMethod(repoPath: string): Promise<"squash" | "rebase" | "merge"> {
|
||||
try {
|
||||
// Get the repo owner/name from gh
|
||||
const { stdout: repoJson } = await execFileAsync(
|
||||
"gh",
|
||||
["repo", "view", "--json", "owner,name"],
|
||||
{ cwd: repoPath }
|
||||
);
|
||||
const { stdout: repoJson } = await execFileAsync("gh", ["repo", "view", "--json", "owner,name"], { cwd: repoPath });
|
||||
const repo = JSON.parse(repoJson) as { owner: { login: string }; name: string };
|
||||
const repoFullName = `${repo.owner.login}/${repo.name}`;
|
||||
|
||||
const { stdout } = await execFileAsync(
|
||||
"gh",
|
||||
[
|
||||
"api",
|
||||
`repos/${repoFullName}`,
|
||||
"--jq",
|
||||
".allow_squash_merge, .allow_rebase_merge, .allow_merge_commit"
|
||||
],
|
||||
{ maxBuffer: 1024 * 1024, cwd: repoPath }
|
||||
);
|
||||
const { stdout } = await execFileAsync("gh", ["api", `repos/${repoFullName}`, "--jq", ".allow_squash_merge, .allow_rebase_merge, .allow_merge_commit"], {
|
||||
maxBuffer: 1024 * 1024,
|
||||
cwd: repoPath,
|
||||
});
|
||||
|
||||
const lines = stdout.trim().split("\n");
|
||||
const allowSquash = lines[0]?.trim() === "true";
|
||||
|
|
@ -248,23 +195,12 @@ export async function getAllowedMergeMethod(
|
|||
|
||||
export async function mergePr(repoPath: string, prNumber: number): Promise<void> {
|
||||
const method = await getAllowedMergeMethod(repoPath);
|
||||
await execFileAsync(
|
||||
"gh",
|
||||
["pr", "merge", String(prNumber), `--${method}`, "--delete-branch"],
|
||||
{ cwd: repoPath }
|
||||
);
|
||||
await execFileAsync("gh", ["pr", "merge", String(prNumber), `--${method}`, "--delete-branch"], { cwd: repoPath });
|
||||
}
|
||||
|
||||
export async function isPrMerged(
|
||||
repoPath: string,
|
||||
branchName: string
|
||||
): Promise<boolean> {
|
||||
export async function isPrMerged(repoPath: string, branchName: string): Promise<boolean> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
"gh",
|
||||
["pr", "view", branchName, "--json", "state"],
|
||||
{ cwd: repoPath }
|
||||
);
|
||||
const { stdout } = await execFileAsync("gh", ["pr", "view", branchName, "--json", "state"], { cwd: repoPath });
|
||||
const parsed = JSON.parse(stdout) as { state: string };
|
||||
return parsed.state.toUpperCase() === "MERGED";
|
||||
} catch {
|
||||
|
|
@ -272,16 +208,9 @@ export async function isPrMerged(
|
|||
}
|
||||
}
|
||||
|
||||
export async function getPrTitle(
|
||||
repoPath: string,
|
||||
branchName: string
|
||||
): Promise<string | null> {
|
||||
export async function getPrTitle(repoPath: string, branchName: string): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
"gh",
|
||||
["pr", "view", branchName, "--json", "title"],
|
||||
{ cwd: repoPath }
|
||||
);
|
||||
const { stdout } = await execFileAsync("gh", ["pr", "view", branchName, "--json", "title"], { cwd: repoPath });
|
||||
const parsed = JSON.parse(stdout) as { title: string };
|
||||
return parsed.title;
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -21,17 +21,11 @@ export async function graphiteGet(repoPath: string, branchName: string): Promise
|
|||
}
|
||||
}
|
||||
|
||||
export async function graphiteCreateBranch(
|
||||
repoPath: string,
|
||||
branchName: string
|
||||
): Promise<void> {
|
||||
export async function graphiteCreateBranch(repoPath: string, branchName: string): Promise<void> {
|
||||
await execFileAsync("gt", ["create", branchName], { cwd: repoPath });
|
||||
}
|
||||
|
||||
export async function graphiteCheckout(
|
||||
repoPath: string,
|
||||
branchName: string
|
||||
): Promise<void> {
|
||||
export async function graphiteCheckout(repoPath: string, branchName: string): Promise<void> {
|
||||
await execFileAsync("gt", ["checkout", branchName], { cwd: repoPath });
|
||||
}
|
||||
|
||||
|
|
@ -39,17 +33,11 @@ export async function graphiteSubmit(repoPath: string): Promise<void> {
|
|||
await execFileAsync("gt", ["submit", "--no-edit"], { cwd: repoPath });
|
||||
}
|
||||
|
||||
export async function graphiteMergeBranch(
|
||||
repoPath: string,
|
||||
branchName: string
|
||||
): Promise<void> {
|
||||
export async function graphiteMergeBranch(repoPath: string, branchName: string): Promise<void> {
|
||||
await execFileAsync("gt", ["merge", branchName], { cwd: repoPath });
|
||||
}
|
||||
|
||||
export async function graphiteAbandon(
|
||||
repoPath: string,
|
||||
branchName: string
|
||||
): Promise<void> {
|
||||
export async function graphiteAbandon(repoPath: string, branchName: string): Promise<void> {
|
||||
await execFileAsync("gt", ["abandon", branchName], { cwd: repoPath });
|
||||
}
|
||||
|
||||
|
|
@ -58,14 +46,12 @@ export interface GraphiteStackEntry {
|
|||
parentBranch: string | null;
|
||||
}
|
||||
|
||||
export async function graphiteGetStack(
|
||||
repoPath: string
|
||||
): Promise<GraphiteStackEntry[]> {
|
||||
export async function graphiteGetStack(repoPath: string): Promise<GraphiteStackEntry[]> {
|
||||
try {
|
||||
// Try JSON output first
|
||||
const { stdout } = await execFileAsync("gt", ["log", "--json"], {
|
||||
cwd: repoPath,
|
||||
maxBuffer: 1024 * 1024
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(stdout) as Array<{
|
||||
|
|
@ -77,14 +63,14 @@ export async function graphiteGetStack(
|
|||
|
||||
return parsed.map((entry) => ({
|
||||
branchName: entry.branch ?? entry.name ?? "",
|
||||
parentBranch: entry.parent ?? entry.parentBranch ?? null
|
||||
parentBranch: entry.parent ?? entry.parentBranch ?? null,
|
||||
}));
|
||||
} catch {
|
||||
// Fall back to text parsing of `gt log`
|
||||
try {
|
||||
const { stdout } = await execFileAsync("gt", ["log"], {
|
||||
cwd: repoPath,
|
||||
maxBuffer: 1024 * 1024
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
|
||||
const entries: GraphiteStackEntry[] = [];
|
||||
|
|
@ -113,9 +99,7 @@ export async function graphiteGetStack(
|
|||
branchStack.pop();
|
||||
}
|
||||
|
||||
const parentBranch = branchStack.length > 0
|
||||
? branchStack[branchStack.length - 1] ?? null
|
||||
: null;
|
||||
const parentBranch = branchStack.length > 0 ? (branchStack[branchStack.length - 1] ?? null) : null;
|
||||
|
||||
entries.push({ branchName, parentBranch });
|
||||
branchStack.push(branchName);
|
||||
|
|
@ -128,15 +112,12 @@ export async function graphiteGetStack(
|
|||
}
|
||||
}
|
||||
|
||||
export async function graphiteGetParent(
|
||||
repoPath: string,
|
||||
branchName: string
|
||||
): Promise<string | null> {
|
||||
export async function graphiteGetParent(repoPath: string, branchName: string): Promise<string | null> {
|
||||
try {
|
||||
// Try `gt get <branchName>` to see parent info
|
||||
const { stdout } = await execFileAsync("gt", ["get", branchName], {
|
||||
cwd: repoPath,
|
||||
maxBuffer: 1024 * 1024
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
|
||||
// Parse output for parent branch reference
|
||||
|
|
|
|||
|
|
@ -1,12 +1,5 @@
|
|||
import type { AgentType } from "@openhandoff/shared";
|
||||
import type {
|
||||
ListEventsRequest,
|
||||
ListPage,
|
||||
ListPageRequest,
|
||||
SessionEvent,
|
||||
SessionPersistDriver,
|
||||
SessionRecord
|
||||
} from "sandbox-agent";
|
||||
import type { ListEventsRequest, ListPage, ListPageRequest, SessionEvent, SessionPersistDriver, SessionRecord } from "sandbox-agent";
|
||||
import { SandboxAgent } from "sandbox-agent";
|
||||
|
||||
export type AgentId = AgentType | "opencode";
|
||||
|
|
@ -118,18 +111,11 @@ export class SandboxAgentClient {
|
|||
const message = err instanceof Error ? err.message : String(err);
|
||||
const lowered = message.toLowerCase();
|
||||
// sandbox-agent server times out long-running ACP prompts and returns a 504-like error.
|
||||
return (
|
||||
lowered.includes("timeout waiting for agent response") ||
|
||||
lowered.includes("timed out waiting for agent response") ||
|
||||
lowered.includes("504")
|
||||
);
|
||||
return lowered.includes("timeout waiting for agent response") || lowered.includes("timed out waiting for agent response") || lowered.includes("504");
|
||||
}
|
||||
|
||||
async createSession(request: string | SandboxSessionCreateRequest): Promise<SandboxSession> {
|
||||
const normalized: SandboxSessionCreateRequest =
|
||||
typeof request === "string"
|
||||
? { prompt: request }
|
||||
: request;
|
||||
const normalized: SandboxSessionCreateRequest = typeof request === "string" ? { prompt: request } : request;
|
||||
const sdk = await this.sdk();
|
||||
// Do not wrap createSession in a local Promise.race timeout. The underlying SDK
|
||||
// call is not abortable, so local timeout races create overlapping ACP requests and
|
||||
|
|
@ -343,18 +329,14 @@ export class SandboxAgentClient {
|
|||
} while (cursor);
|
||||
}
|
||||
|
||||
async generateCommitMessage(
|
||||
dir: string,
|
||||
spec: string,
|
||||
task: string
|
||||
): Promise<string> {
|
||||
async generateCommitMessage(dir: string, spec: string, task: string): Promise<string> {
|
||||
const prompt = [
|
||||
"Generate a conventional commit message for the following changes.",
|
||||
"Return ONLY the commit message, no explanation or markdown formatting.",
|
||||
"",
|
||||
`Task: ${task}`,
|
||||
"",
|
||||
`Spec/diff:\n${spec}`
|
||||
`Spec/diff:\n${spec}`,
|
||||
].join("\n");
|
||||
|
||||
const sdk = await this.sdk();
|
||||
|
|
|
|||
|
|
@ -99,10 +99,10 @@ export class TerminalBellBackend implements NotifyBackend {
|
|||
}
|
||||
|
||||
const backendFactories: Record<string, () => NotifyBackend> = {
|
||||
"openclaw": () => new OpenclawBackend(),
|
||||
openclaw: () => new OpenclawBackend(),
|
||||
"macos-osascript": () => new MacOsNotifyBackend(),
|
||||
"linux-notify-send": () => new LinuxNotifySendBackend(),
|
||||
"terminal": () => new TerminalBellBackend(),
|
||||
terminal: () => new TerminalBellBackend(),
|
||||
};
|
||||
|
||||
export async function createBackends(configOrder: string[]): Promise<NotifyBackend[]> {
|
||||
|
|
|
|||
|
|
@ -49,11 +49,7 @@ export function createNotificationService(backends: NotifyBackend[]): Notificati
|
|||
},
|
||||
|
||||
async changesRequested(branchName: string, prNumber: number, reviewer: string): Promise<void> {
|
||||
await notify(
|
||||
"Changes Requested",
|
||||
`Changes requested on PR #${prNumber} (${branchName}) by ${reviewer}`,
|
||||
"high",
|
||||
);
|
||||
await notify("Changes Requested", `Changes requested on PR #${prNumber} (${branchName}) by ${reviewer}`, "high");
|
||||
},
|
||||
|
||||
async prMerged(branchName: string, prNumber: number): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -15,14 +15,7 @@ export class PrStateTracker {
|
|||
this.states = new Map();
|
||||
}
|
||||
|
||||
update(
|
||||
repoId: string,
|
||||
branchName: string,
|
||||
prNumber: number,
|
||||
ci: CiState,
|
||||
review: ReviewState,
|
||||
reviewer?: string,
|
||||
): PrStateTransition[] {
|
||||
update(repoId: string, branchName: string, prNumber: number, ci: CiState, review: ReviewState, reviewer?: string): PrStateTransition[] {
|
||||
const key = `${repoId}:${branchName}`;
|
||||
const prev = this.states.get(key);
|
||||
const transitions: PrStateTransition[] = [];
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import type {
|
|||
SandboxHandle,
|
||||
SandboxHealth,
|
||||
SandboxHealthRequest,
|
||||
SandboxProvider
|
||||
SandboxProvider,
|
||||
} from "../provider-api/index.js";
|
||||
import type { DaytonaDriver } from "../../driver.js";
|
||||
import { Image } from "@daytonaio/sdk";
|
||||
|
|
@ -33,7 +33,7 @@ export interface DaytonaProviderConfig {
|
|||
export class DaytonaProvider implements SandboxProvider {
|
||||
constructor(
|
||||
private readonly config: DaytonaProviderConfig,
|
||||
private readonly daytona?: DaytonaDriver
|
||||
private readonly daytona?: DaytonaDriver,
|
||||
) {}
|
||||
|
||||
private static readonly SANDBOX_AGENT_PORT = 2468;
|
||||
|
|
@ -60,10 +60,7 @@ export class DaytonaProvider implements SandboxProvider {
|
|||
}
|
||||
|
||||
private getAcpRequestTimeoutMs(): number {
|
||||
const parsed = Number(
|
||||
process.env.HF_SANDBOX_AGENT_ACP_REQUEST_TIMEOUT_MS
|
||||
?? DaytonaProvider.DEFAULT_ACP_REQUEST_TIMEOUT_MS.toString()
|
||||
);
|
||||
const parsed = Number(process.env.HF_SANDBOX_AGENT_ACP_REQUEST_TIMEOUT_MS ?? DaytonaProvider.DEFAULT_ACP_REQUEST_TIMEOUT_MS.toString());
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return DaytonaProvider.DEFAULT_ACP_REQUEST_TIMEOUT_MS;
|
||||
}
|
||||
|
|
@ -117,7 +114,7 @@ export class DaytonaProvider implements SandboxProvider {
|
|||
throw new Error(
|
||||
"daytona provider is not configured: missing apiKey. " +
|
||||
"Set HF_DAYTONA_API_KEY (or DAYTONA_API_KEY). " +
|
||||
"Optionally set HF_DAYTONA_ENDPOINT (or DAYTONA_ENDPOINT)."
|
||||
"Optionally set HF_DAYTONA_ENDPOINT (or DAYTONA_ENDPOINT).",
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -154,20 +151,14 @@ export class DaytonaProvider implements SandboxProvider {
|
|||
return Image.base(this.config.image).runCommands(
|
||||
"apt-get update && apt-get install -y curl ca-certificates git openssh-client nodejs npm",
|
||||
`curl -fsSL https://releases.rivet.dev/sandbox-agent/${DaytonaProvider.SANDBOX_AGENT_VERSION}/install.sh | sh`,
|
||||
`bash -lc 'export PATH="$HOME/.local/bin:$PATH"; sandbox-agent install-agent codex || true; sandbox-agent install-agent claude || true'`
|
||||
`bash -lc 'export PATH="$HOME/.local/bin:$PATH"; sandbox-agent install-agent codex || true; sandbox-agent install-agent claude || true'`,
|
||||
);
|
||||
}
|
||||
|
||||
private async runCheckedCommand(
|
||||
sandboxId: string,
|
||||
command: string,
|
||||
label: string
|
||||
): Promise<void> {
|
||||
private async runCheckedCommand(sandboxId: string, command: string, label: string): Promise<void> {
|
||||
const client = this.requireClient();
|
||||
|
||||
const result = await this.withTimeout(`execute command (${label})`, () =>
|
||||
client.executeCommand(sandboxId, command)
|
||||
);
|
||||
const result = await this.withTimeout(`execute command (${label})`, () => client.executeCommand(sandboxId, command));
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(`daytona ${label} failed (${result.exitCode}): ${result.result}`);
|
||||
}
|
||||
|
|
@ -180,7 +171,7 @@ export class DaytonaProvider implements SandboxProvider {
|
|||
capabilities(): ProviderCapabilities {
|
||||
return {
|
||||
remote: true,
|
||||
supportsSessionReuse: true
|
||||
supportsSessionReuse: true,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -196,7 +187,7 @@ export class DaytonaProvider implements SandboxProvider {
|
|||
workspaceId: req.workspaceId,
|
||||
repoId: req.repoId,
|
||||
handoffId: req.handoffId,
|
||||
branchName: req.branchName
|
||||
branchName: req.branchName,
|
||||
});
|
||||
|
||||
const createStartedAt = Date.now();
|
||||
|
|
@ -212,12 +203,12 @@ export class DaytonaProvider implements SandboxProvider {
|
|||
"openhandoff.branch": req.branchName,
|
||||
},
|
||||
autoStopInterval: this.config.autoStopInterval,
|
||||
})
|
||||
}),
|
||||
);
|
||||
emitDebug("daytona.createSandbox.created", {
|
||||
sandboxId: sandbox.id,
|
||||
durationMs: Date.now() - createStartedAt,
|
||||
state: sandbox.state ?? null
|
||||
state: sandbox.state ?? null,
|
||||
});
|
||||
|
||||
const repoDir = `/home/daytona/openhandoff/${req.workspaceId}/${req.repoId}/${req.handoffId}/repo`;
|
||||
|
|
@ -229,13 +220,13 @@ export class DaytonaProvider implements SandboxProvider {
|
|||
[
|
||||
"bash",
|
||||
"-lc",
|
||||
`'set -euo pipefail; export DEBIAN_FRONTEND=noninteractive; if command -v git >/dev/null 2>&1 && command -v npx >/dev/null 2>&1; then exit 0; fi; apt-get update -y >/tmp/apt-update.log 2>&1; apt-get install -y git openssh-client ca-certificates nodejs npm >/tmp/apt-install.log 2>&1'`
|
||||
`'set -euo pipefail; export DEBIAN_FRONTEND=noninteractive; if command -v git >/dev/null 2>&1 && command -v npx >/dev/null 2>&1; then exit 0; fi; apt-get update -y >/tmp/apt-update.log 2>&1; apt-get install -y git openssh-client ca-certificates nodejs npm >/tmp/apt-install.log 2>&1'`,
|
||||
].join(" "),
|
||||
"install git + node toolchain"
|
||||
"install git + node toolchain",
|
||||
);
|
||||
emitDebug("daytona.createSandbox.install_toolchain.done", {
|
||||
sandboxId: sandbox.id,
|
||||
durationMs: Date.now() - installStartedAt
|
||||
durationMs: Date.now() - installStartedAt,
|
||||
});
|
||||
|
||||
const cloneStartedAt = Date.now();
|
||||
|
|
@ -260,14 +251,14 @@ export class DaytonaProvider implements SandboxProvider {
|
|||
`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 "openhandoff@local" >/dev/null 2>&1 || true`,
|
||||
`git config user.name "OpenHandoff" >/dev/null 2>&1 || true`,
|
||||
].join("; ")
|
||||
)}`
|
||||
].join("; "),
|
||||
)}`,
|
||||
].join(" "),
|
||||
"clone repo"
|
||||
"clone repo",
|
||||
);
|
||||
emitDebug("daytona.createSandbox.clone_repo.done", {
|
||||
sandboxId: sandbox.id,
|
||||
durationMs: Date.now() - cloneStartedAt
|
||||
durationMs: Date.now() - cloneStartedAt,
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
@ -280,7 +271,7 @@ export class DaytonaProvider implements SandboxProvider {
|
|||
remote: true,
|
||||
state: sandbox.state ?? null,
|
||||
cwd: repoDir,
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -290,17 +281,12 @@ export class DaytonaProvider implements SandboxProvider {
|
|||
await this.ensureStarted(req.sandboxId);
|
||||
|
||||
// Reconstruct cwd from sandbox labels written at create time.
|
||||
const info = await this.withTimeout("resume get sandbox", () =>
|
||||
client.getSandbox(req.sandboxId)
|
||||
);
|
||||
const info = await this.withTimeout("resume get sandbox", () => client.getSandbox(req.sandboxId));
|
||||
const labels = info.labels ?? {};
|
||||
const workspaceId = labels["openhandoff.workspace"] ?? req.workspaceId;
|
||||
const repoId = labels["openhandoff.repo_id"] ?? "";
|
||||
const handoffId = labels["openhandoff.handoff"] ?? "";
|
||||
const cwd =
|
||||
repoId && handoffId
|
||||
? `/home/daytona/openhandoff/${workspaceId}/${repoId}/${handoffId}/repo`
|
||||
: null;
|
||||
const cwd = repoId && handoffId ? `/home/daytona/openhandoff/${workspaceId}/${repoId}/${handoffId}/repo` : null;
|
||||
|
||||
return {
|
||||
sandboxId: req.sandboxId,
|
||||
|
|
@ -309,7 +295,7 @@ export class DaytonaProvider implements SandboxProvider {
|
|||
resumed: true,
|
||||
endpoint: this.config.endpoint ?? null,
|
||||
...(cwd ? { cwd } : {}),
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -359,9 +345,9 @@ export class DaytonaProvider implements SandboxProvider {
|
|||
[
|
||||
"bash",
|
||||
"-lc",
|
||||
`'set -euo pipefail; if command -v curl >/dev/null 2>&1; then exit 0; fi; export DEBIAN_FRONTEND=noninteractive; apt-get update -y >/tmp/apt-update.log 2>&1; apt-get install -y curl ca-certificates >/tmp/apt-install.log 2>&1'`
|
||||
`'set -euo pipefail; if command -v curl >/dev/null 2>&1; then exit 0; fi; export DEBIAN_FRONTEND=noninteractive; apt-get update -y >/tmp/apt-update.log 2>&1; apt-get install -y curl ca-certificates >/tmp/apt-install.log 2>&1'`,
|
||||
].join(" "),
|
||||
"install curl"
|
||||
"install curl",
|
||||
);
|
||||
|
||||
await this.runCheckedCommand(
|
||||
|
|
@ -369,9 +355,9 @@ export class DaytonaProvider implements SandboxProvider {
|
|||
[
|
||||
"bash",
|
||||
"-lc",
|
||||
`'set -euo pipefail; if command -v npx >/dev/null 2>&1; then exit 0; fi; export DEBIAN_FRONTEND=noninteractive; apt-get update -y >/tmp/apt-update.log 2>&1; apt-get install -y nodejs npm >/tmp/apt-install.log 2>&1'`
|
||||
`'set -euo pipefail; if command -v npx >/dev/null 2>&1; then exit 0; fi; export DEBIAN_FRONTEND=noninteractive; apt-get update -y >/tmp/apt-update.log 2>&1; apt-get install -y nodejs npm >/tmp/apt-install.log 2>&1'`,
|
||||
].join(" "),
|
||||
"install node toolchain"
|
||||
"install node toolchain",
|
||||
);
|
||||
|
||||
await this.runCheckedCommand(
|
||||
|
|
@ -379,9 +365,9 @@ export class DaytonaProvider implements SandboxProvider {
|
|||
[
|
||||
"bash",
|
||||
"-lc",
|
||||
`'set -euo pipefail; export PATH="$HOME/.local/bin:$PATH"; if sandbox-agent --version 2>/dev/null | grep -q "${DaytonaProvider.SANDBOX_AGENT_VERSION}"; then exit 0; fi; curl -fsSL https://releases.rivet.dev/sandbox-agent/${DaytonaProvider.SANDBOX_AGENT_VERSION}/install.sh | sh'`
|
||||
`'set -euo pipefail; export PATH="$HOME/.local/bin:$PATH"; if sandbox-agent --version 2>/dev/null | grep -q "${DaytonaProvider.SANDBOX_AGENT_VERSION}"; then exit 0; fi; curl -fsSL https://releases.rivet.dev/sandbox-agent/${DaytonaProvider.SANDBOX_AGENT_VERSION}/install.sh | sh'`,
|
||||
].join(" "),
|
||||
"install sandbox-agent"
|
||||
"install sandbox-agent",
|
||||
);
|
||||
|
||||
for (const agentId of DaytonaProvider.AGENT_IDS) {
|
||||
|
|
@ -389,7 +375,7 @@ export class DaytonaProvider implements SandboxProvider {
|
|||
await this.runCheckedCommand(
|
||||
req.sandboxId,
|
||||
["bash", "-lc", `'export PATH="$HOME/.local/bin:$PATH"; sandbox-agent install-agent ${agentId}'`].join(" "),
|
||||
`install agent ${agentId}`
|
||||
`install agent ${agentId}`,
|
||||
);
|
||||
} catch {
|
||||
// Some sandbox-agent builds may not ship every agent plugin; treat this as best-effort.
|
||||
|
|
@ -401,9 +387,9 @@ export class DaytonaProvider implements SandboxProvider {
|
|||
[
|
||||
"bash",
|
||||
"-lc",
|
||||
`'set -euo pipefail; export PATH="$HOME/.local/bin:$PATH"; command -v sandbox-agent >/dev/null 2>&1; if pgrep -x sandbox-agent >/dev/null; then exit 0; fi; nohup env SANDBOX_AGENT_ACP_REQUEST_TIMEOUT_MS=${acpRequestTimeoutMs} sandbox-agent server --no-token --host 0.0.0.0 --port ${DaytonaProvider.SANDBOX_AGENT_PORT} >/tmp/sandbox-agent.log 2>&1 &'`
|
||||
`'set -euo pipefail; export PATH="$HOME/.local/bin:$PATH"; command -v sandbox-agent >/dev/null 2>&1; if pgrep -x sandbox-agent >/dev/null; then exit 0; fi; nohup env SANDBOX_AGENT_ACP_REQUEST_TIMEOUT_MS=${acpRequestTimeoutMs} sandbox-agent server --no-token --host 0.0.0.0 --port ${DaytonaProvider.SANDBOX_AGENT_PORT} >/tmp/sandbox-agent.log 2>&1 &'`,
|
||||
].join(" "),
|
||||
"start sandbox-agent"
|
||||
"start sandbox-agent",
|
||||
);
|
||||
|
||||
await this.runCheckedCommand(
|
||||
|
|
@ -411,18 +397,16 @@ export class DaytonaProvider implements SandboxProvider {
|
|||
[
|
||||
"bash",
|
||||
"-lc",
|
||||
`'for i in $(seq 1 45); do curl -fsS "http://127.0.0.1:${DaytonaProvider.SANDBOX_AGENT_PORT}/v1/health" >/dev/null && exit 0; sleep 1; done; echo "sandbox-agent failed to become healthy" >&2; tail -n 80 /tmp/sandbox-agent.log >&2; exit 1'`
|
||||
`'for i in $(seq 1 45); do curl -fsS "http://127.0.0.1:${DaytonaProvider.SANDBOX_AGENT_PORT}/v1/health" >/dev/null && exit 0; sleep 1; done; echo "sandbox-agent failed to become healthy" >&2; tail -n 80 /tmp/sandbox-agent.log >&2; exit 1'`,
|
||||
].join(" "),
|
||||
"wait for sandbox-agent health"
|
||||
"wait for sandbox-agent health",
|
||||
);
|
||||
|
||||
const preview = await this.withTimeout("get preview endpoint", () =>
|
||||
client.getPreviewEndpoint(req.sandboxId, DaytonaProvider.SANDBOX_AGENT_PORT)
|
||||
);
|
||||
const preview = await this.withTimeout("get preview endpoint", () => client.getPreviewEndpoint(req.sandboxId, DaytonaProvider.SANDBOX_AGENT_PORT));
|
||||
|
||||
return {
|
||||
endpoint: preview.url,
|
||||
token: preview.token
|
||||
token: preview.token,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -436,9 +420,7 @@ export class DaytonaProvider implements SandboxProvider {
|
|||
}
|
||||
|
||||
try {
|
||||
const sandbox = await this.withTimeout("health get sandbox", () =>
|
||||
client.getSandbox(req.sandboxId)
|
||||
);
|
||||
const sandbox = await this.withTimeout("health get sandbox", () => client.getSandbox(req.sandboxId));
|
||||
const state = String(sandbox.state ?? "unknown");
|
||||
if (state.toLowerCase().includes("error")) {
|
||||
return {
|
||||
|
|
@ -461,15 +443,13 @@ export class DaytonaProvider implements SandboxProvider {
|
|||
|
||||
async attachTarget(req: AttachTargetRequest): Promise<AttachTarget> {
|
||||
return {
|
||||
target: `daytona://${req.sandboxId}`
|
||||
target: `daytona://${req.sandboxId}`,
|
||||
};
|
||||
}
|
||||
|
||||
async executeCommand(req: ExecuteSandboxCommandRequest): Promise<ExecuteSandboxCommandResult> {
|
||||
const client = this.requireClient();
|
||||
await this.ensureStarted(req.sandboxId);
|
||||
return await this.withTimeout(`execute command (${req.label ?? "command"})`, () =>
|
||||
client.executeCommand(req.sandboxId, req.command)
|
||||
);
|
||||
return await this.withTimeout(`execute command (${req.label ?? "command"})`, () => client.executeCommand(req.sandboxId, req.command));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,19 +42,25 @@ export function createProviderRegistry(config: AppConfig, driver?: BackendDriver
|
|||
},
|
||||
};
|
||||
|
||||
const local = new LocalProvider({
|
||||
rootDir: config.providers.local.rootDir,
|
||||
sandboxAgentPort: config.providers.local.sandboxAgentPort,
|
||||
}, gitDriver);
|
||||
const daytona = new DaytonaProvider({
|
||||
endpoint: config.providers.daytona.endpoint,
|
||||
apiKey: config.providers.daytona.apiKey,
|
||||
image: config.providers.daytona.image
|
||||
}, driver?.daytona);
|
||||
const local = new LocalProvider(
|
||||
{
|
||||
rootDir: config.providers.local.rootDir,
|
||||
sandboxAgentPort: config.providers.local.sandboxAgentPort,
|
||||
},
|
||||
gitDriver,
|
||||
);
|
||||
const daytona = new DaytonaProvider(
|
||||
{
|
||||
endpoint: config.providers.daytona.endpoint,
|
||||
apiKey: config.providers.daytona.apiKey,
|
||||
image: config.providers.daytona.image,
|
||||
},
|
||||
driver?.daytona,
|
||||
);
|
||||
|
||||
const map: Record<ProviderId, SandboxProvider> = {
|
||||
local,
|
||||
daytona
|
||||
daytona,
|
||||
};
|
||||
|
||||
return {
|
||||
|
|
@ -66,6 +72,6 @@ export function createProviderRegistry(config: AppConfig, driver?: BackendDriver
|
|||
},
|
||||
defaultProviderId(): ProviderId {
|
||||
return config.providers.daytona.apiKey ? "daytona" : "local";
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,13 +44,7 @@ function expandHome(value: string): string {
|
|||
|
||||
async function branchExists(repoPath: string, branchName: string): Promise<boolean> {
|
||||
try {
|
||||
await execFileAsync("git", [
|
||||
"-C",
|
||||
repoPath,
|
||||
"show-ref",
|
||||
"--verify",
|
||||
`refs/remotes/origin/${branchName}`,
|
||||
]);
|
||||
await execFileAsync("git", ["-C", repoPath, "show-ref", "--verify", `refs/remotes/origin/${branchName}`]);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
|
|
@ -59,9 +53,7 @@ async function branchExists(repoPath: string, branchName: string): Promise<boole
|
|||
|
||||
async function checkoutBranch(repoPath: string, branchName: string, git: GitDriver): Promise<void> {
|
||||
await git.fetch(repoPath);
|
||||
const targetRef = (await branchExists(repoPath, branchName))
|
||||
? `origin/${branchName}`
|
||||
: await git.remoteDefaultBaseRef(repoPath);
|
||||
const targetRef = (await branchExists(repoPath, branchName)) ? `origin/${branchName}` : await git.remoteDefaultBaseRef(repoPath);
|
||||
await execFileAsync("git", ["-C", repoPath, "checkout", "-B", branchName, targetRef], {
|
||||
env: process.env as Record<string, string>,
|
||||
});
|
||||
|
|
@ -76,9 +68,7 @@ export class LocalProvider implements SandboxProvider {
|
|||
) {}
|
||||
|
||||
private rootDir(): string {
|
||||
return expandHome(
|
||||
this.config.rootDir?.trim() || "~/.local/share/openhandoff/local-sandboxes",
|
||||
);
|
||||
return expandHome(this.config.rootDir?.trim() || "~/.local/share/openhandoff/local-sandboxes");
|
||||
}
|
||||
|
||||
private sandboxRoot(workspaceId: string, sandboxId: string): string {
|
||||
|
|
@ -89,11 +79,7 @@ export class LocalProvider implements SandboxProvider {
|
|||
return resolve(this.sandboxRoot(workspaceId, sandboxId), "repo");
|
||||
}
|
||||
|
||||
private sandboxHandle(
|
||||
workspaceId: string,
|
||||
sandboxId: string,
|
||||
repoDir: string,
|
||||
): SandboxHandle {
|
||||
private sandboxHandle(workspaceId: string, sandboxId: string, repoDir: string): SandboxHandle {
|
||||
return {
|
||||
sandboxId,
|
||||
switchTarget: `local://${repoDir}`,
|
||||
|
|
@ -242,9 +228,7 @@ export class LocalProvider implements SandboxProvider {
|
|||
const detail = error as { stdout?: string; stderr?: string; code?: number };
|
||||
return {
|
||||
exitCode: typeof detail.code === "number" ? detail.code : 1,
|
||||
result: [detail.stdout, detail.stderr, error instanceof Error ? error.message : String(error)]
|
||||
.filter(Boolean)
|
||||
.join(""),
|
||||
result: [detail.stdout, detail.stderr, error instanceof Error ? error.message : String(error)].filter(Boolean).join(""),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,13 +39,14 @@ export function deriveFallbackTitle(task: string, explicitTitle?: string): strin
|
|||
|
||||
const lowered = source.toLowerCase();
|
||||
|
||||
const typePrefix = lowered.includes("fix") || lowered.includes("bug")
|
||||
? "fix"
|
||||
: lowered.includes("doc") || lowered.includes("readme")
|
||||
? "docs"
|
||||
: lowered.includes("refactor")
|
||||
? "refactor"
|
||||
: "feat";
|
||||
const typePrefix =
|
||||
lowered.includes("fix") || lowered.includes("bug")
|
||||
? "fix"
|
||||
: lowered.includes("doc") || lowered.includes("readme")
|
||||
? "docs"
|
||||
: lowered.includes("refactor")
|
||||
? "refactor"
|
||||
: "feat";
|
||||
|
||||
const cleaned = source
|
||||
.split("")
|
||||
|
|
@ -88,9 +89,7 @@ export function sanitizeBranchName(input: string): string {
|
|||
return trimmed.slice(0, 50).replace(/-+$/g, "");
|
||||
}
|
||||
|
||||
export function resolveCreateFlowDecision(
|
||||
input: ResolveCreateFlowDecisionInput
|
||||
): ResolveCreateFlowDecisionResult {
|
||||
export function resolveCreateFlowDecision(input: ResolveCreateFlowDecisionInput): ResolveCreateFlowDecisionResult {
|
||||
const explicitBranch = input.explicitBranchName?.trim();
|
||||
const title = deriveFallbackTitle(input.task, input.explicitTitle);
|
||||
const generatedBase = sanitizeBranchName(title) || "handoff";
|
||||
|
|
@ -98,16 +97,11 @@ export function resolveCreateFlowDecision(
|
|||
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 conflicts = (name: string): boolean =>
|
||||
existingBranches.has(name) || existingHandoffBranches.has(name);
|
||||
const existingHandoffBranches = new Set(input.handoffBranches.map((value) => value.trim()).filter((value) => value.length > 0));
|
||||
const conflicts = (name: string): boolean => existingBranches.has(name) || existingHandoffBranches.has(name);
|
||||
|
||||
if (explicitBranch && conflicts(branchBase)) {
|
||||
throw new Error(
|
||||
`Branch '${branchBase}' already exists. Choose a different --name/--branch value.`
|
||||
);
|
||||
throw new Error(`Branch '${branchBase}' already exists. Choose a different --name/--branch value.`);
|
||||
}
|
||||
|
||||
if (explicitBranch) {
|
||||
|
|
@ -123,6 +117,6 @@ export function resolveCreateFlowDecision(
|
|||
|
||||
return {
|
||||
title,
|
||||
branchName: candidate
|
||||
branchName: candidate,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,11 +15,6 @@ export function openhandoffDataDir(config: AppConfig): string {
|
|||
return resolve(dirname(dbPath));
|
||||
}
|
||||
|
||||
export function openhandoffRepoClonePath(
|
||||
config: AppConfig,
|
||||
workspaceId: string,
|
||||
repoId: string
|
||||
): string {
|
||||
export function openhandoffRepoClonePath(config: AppConfig, workspaceId: string, repoId: string): string {
|
||||
return resolve(join(openhandoffDataDir(config), "repos", workspaceId, repoId));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,11 +23,10 @@ export function setWindowStatus(branchName: string, status: string): number {
|
|||
|
||||
let stdout: string;
|
||||
try {
|
||||
stdout = execFileSync(
|
||||
"tmux",
|
||||
["list-windows", "-a", "-F", "#{session_name}:#{window_id}:#{window_name}"],
|
||||
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }
|
||||
);
|
||||
stdout = execFileSync("tmux", ["list-windows", "-a", "-F", "#{session_name}:#{window_id}:#{window_name}"], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
});
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -51,7 +50,7 @@ export function setWindowStatus(branchName: string, status: string): number {
|
|||
|
||||
const newName = `${symbol} ${branchName}`;
|
||||
spawnSync("tmux", ["rename-window", "-t", `${sessionName}:${windowId}`, newName], {
|
||||
stdio: "ignore"
|
||||
stdio: "ignore",
|
||||
});
|
||||
count += 1;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,5 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
deriveFallbackTitle,
|
||||
resolveCreateFlowDecision,
|
||||
sanitizeBranchName
|
||||
} from "../src/services/create-flow.js";
|
||||
import { deriveFallbackTitle, resolveCreateFlowDecision, sanitizeBranchName } from "../src/services/create-flow.js";
|
||||
|
||||
describe("create flow decision", () => {
|
||||
it("derives a conventional-style fallback title from task text", () => {
|
||||
|
|
@ -25,7 +21,7 @@ describe("create flow decision", () => {
|
|||
const resolved = resolveCreateFlowDecision({
|
||||
task: "Add auth",
|
||||
localBranches: ["feat-add-auth"],
|
||||
handoffBranches: ["feat-add-auth-2"]
|
||||
handoffBranches: ["feat-add-auth-2"],
|
||||
});
|
||||
|
||||
expect(resolved.title).toBe("feat: Add auth");
|
||||
|
|
@ -38,8 +34,8 @@ describe("create flow decision", () => {
|
|||
task: "new task",
|
||||
explicitBranchName: "existing-branch",
|
||||
localBranches: ["existing-branch"],
|
||||
handoffBranches: []
|
||||
})
|
||||
handoffBranches: [],
|
||||
}),
|
||||
).toThrow("already exists");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ function createProviderWithClient(client: DaytonaClientLike): DaytonaProvider {
|
|||
apiKey: "test-key",
|
||||
image: "ubuntu:24.04",
|
||||
},
|
||||
daytonaDriver
|
||||
daytonaDriver,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -112,7 +112,7 @@ describe("daytona provider snapshot image behavior", () => {
|
|||
});
|
||||
|
||||
const startCommand = client.executedCommands.find((command) =>
|
||||
command.includes("nohup env SANDBOX_AGENT_ACP_REQUEST_TIMEOUT_MS=240000 sandbox-agent server")
|
||||
command.includes("nohup env SANDBOX_AGENT_ACP_REQUEST_TIMEOUT_MS=240000 sandbox-agent server"),
|
||||
);
|
||||
|
||||
const joined = client.executedCommands.join("\n");
|
||||
|
|
@ -149,13 +149,15 @@ describe("daytona provider snapshot image behavior", () => {
|
|||
|
||||
try {
|
||||
const provider = createProviderWithClient(hangingClient);
|
||||
await expect(provider.createSandbox({
|
||||
workspaceId: "default",
|
||||
repoId: "repo-1",
|
||||
repoRemote: "https://github.com/acme/repo.git",
|
||||
branchName: "feature/test",
|
||||
handoffId: "handoff-timeout",
|
||||
})).rejects.toThrow("daytona create sandbox timed out after 120ms");
|
||||
await expect(
|
||||
provider.createSandbox({
|
||||
workspaceId: "default",
|
||||
repoId: "repo-1",
|
||||
repoRemote: "https://github.com/acme/repo.git",
|
||||
branchName: "feature/test",
|
||||
handoffId: "handoff-timeout",
|
||||
}),
|
||||
).rejects.toThrow("daytona create sandbox timed out after 120ms");
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.HF_DAYTONA_REQUEST_TIMEOUT_MS;
|
||||
|
|
@ -173,7 +175,7 @@ describe("daytona provider snapshot image behavior", () => {
|
|||
workspaceId: "default",
|
||||
sandboxId: "sandbox-1",
|
||||
command: "echo backend-push",
|
||||
label: "manual push"
|
||||
label: "manual push",
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
|
|
|
|||
|
|
@ -2,11 +2,7 @@ import { chmodSync, mkdtempSync, writeFileSync, readFileSync } from "node:fs";
|
|||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
gitSpiceAvailable,
|
||||
gitSpiceListStack,
|
||||
gitSpiceRestackSubtree
|
||||
} from "../src/integrations/git-spice/index.js";
|
||||
import { gitSpiceAvailable, gitSpiceListStack, gitSpiceRestackSubtree } from "../src/integrations/git-spice/index.js";
|
||||
|
||||
function makeTempDir(prefix: string): string {
|
||||
return mkdtempSync(join(tmpdir(), prefix));
|
||||
|
|
@ -17,10 +13,7 @@ function writeScript(path: string, body: string): void {
|
|||
chmodSync(path, 0o755);
|
||||
}
|
||||
|
||||
async function withEnv<T>(
|
||||
updates: Record<string, string | undefined>,
|
||||
fn: () => Promise<T>
|
||||
): Promise<T> {
|
||||
async function withEnv<T>(updates: Record<string, string | undefined>, fn: () => Promise<T>): Promise<T> {
|
||||
const previous = new Map<string, string | undefined>();
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
previous.set(key, process.env[key]);
|
||||
|
|
@ -57,21 +50,21 @@ describe("git-spice integration", () => {
|
|||
"fi",
|
||||
'if [ \"$1\" = \"log\" ]; then',
|
||||
" echo 'noise line'",
|
||||
" echo '{\"branch\":\"feature/a\",\"parent\":\"main\"}'",
|
||||
' echo \'{"branch":"feature/a","parent":"main"}\'',
|
||||
" echo '{bad json'",
|
||||
" echo '{\"name\":\"feature/b\",\"parentBranch\":\"feature/a\"}'",
|
||||
" echo '{\"name\":\"feature/a\",\"parent\":\"main\"}'",
|
||||
' echo \'{"name":"feature/b","parentBranch":"feature/a"}\'',
|
||||
' echo \'{"name":"feature/a","parent":"main"}\'',
|
||||
" exit 0",
|
||||
"fi",
|
||||
"exit 1"
|
||||
].join("\n")
|
||||
"exit 1",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
await withEnv({ HF_GIT_SPICE_BIN: scriptPath }, async () => {
|
||||
const rows = await gitSpiceListStack(repoPath);
|
||||
expect(rows).toEqual([
|
||||
{ branchName: "feature/a", parentBranch: "main" },
|
||||
{ branchName: "feature/b", parentBranch: "feature/a" }
|
||||
{ branchName: "feature/b", parentBranch: "feature/a" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -94,18 +87,18 @@ describe("git-spice integration", () => {
|
|||
'if [ \"$1\" = \"branch\" ] && [ \"$2\" = \"restack\" ] && [ \"$5\" = \"--no-prompt\" ]; then',
|
||||
" exit 0",
|
||||
"fi",
|
||||
"exit 1"
|
||||
].join("\n")
|
||||
"exit 1",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
await withEnv(
|
||||
{
|
||||
HF_GIT_SPICE_BIN: scriptPath,
|
||||
SPICE_LOG_PATH: logPath
|
||||
SPICE_LOG_PATH: logPath,
|
||||
},
|
||||
async () => {
|
||||
await gitSpiceRestackSubtree(repoPath, "feature/a");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const lines = readFileSync(logPath, "utf8")
|
||||
|
|
@ -125,12 +118,12 @@ describe("git-spice integration", () => {
|
|||
await withEnv(
|
||||
{
|
||||
HF_GIT_SPICE_BIN: "/non-existent/hf-git-spice-binary",
|
||||
PATH: "/non-existent/bin"
|
||||
PATH: "/non-existent/bin",
|
||||
},
|
||||
async () => {
|
||||
const available = await gitSpiceAvailable(repoPath);
|
||||
expect(available).toBe(false);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,10 +13,7 @@ export function createTestConfig(overrides?: Partial<AppConfig>): AppConfig {
|
|||
backend: {
|
||||
host: "127.0.0.1",
|
||||
port: 7741,
|
||||
dbPath: join(
|
||||
tmpdir(),
|
||||
`hf-test-${Date.now()}-${Math.random().toString(16).slice(2)}.db`
|
||||
),
|
||||
dbPath: join(tmpdir(), `hf-test-${Date.now()}-${Math.random().toString(16).slice(2)}.db`),
|
||||
opencode_poll_interval: 2,
|
||||
github_poll_interval: 30,
|
||||
backup_interval_secs: 3600,
|
||||
|
|
@ -29,10 +26,7 @@ export function createTestConfig(overrides?: Partial<AppConfig>): AppConfig {
|
|||
});
|
||||
}
|
||||
|
||||
export function createTestRuntimeContext(
|
||||
driver: BackendDriver,
|
||||
configOverrides?: Partial<AppConfig>
|
||||
): { config: AppConfig } {
|
||||
export function createTestRuntimeContext(driver: BackendDriver, configOverrides?: Partial<AppConfig>): { config: AppConfig } {
|
||||
const config = createTestConfig(configOverrides);
|
||||
const providers = createProviderRegistry(config, driver);
|
||||
initActorRuntimeContext(config, providers, undefined, driver);
|
||||
|
|
|
|||
|
|
@ -62,18 +62,14 @@ export function createTestGithubDriver(overrides?: Partial<GithubDriver>): Githu
|
|||
};
|
||||
}
|
||||
|
||||
export function createTestSandboxAgentDriver(
|
||||
overrides?: Partial<SandboxAgentDriver>
|
||||
): SandboxAgentDriver {
|
||||
export function createTestSandboxAgentDriver(overrides?: Partial<SandboxAgentDriver>): SandboxAgentDriver {
|
||||
return {
|
||||
createClient: (_opts) => createTestSandboxAgentClient(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function createTestSandboxAgentClient(
|
||||
overrides?: Partial<SandboxAgentClientLike>
|
||||
): SandboxAgentClientLike {
|
||||
export function createTestSandboxAgentClient(overrides?: Partial<SandboxAgentClientLike>): SandboxAgentClientLike {
|
||||
return {
|
||||
createSession: async (_prompt) => ({ id: "test-session-1", status: "running" }),
|
||||
sessionStatus: async (sessionId) => ({ id: sessionId, status: "running" }),
|
||||
|
|
@ -92,18 +88,14 @@ export function createTestSandboxAgentClient(
|
|||
};
|
||||
}
|
||||
|
||||
export function createTestDaytonaDriver(
|
||||
overrides?: Partial<DaytonaDriver>
|
||||
): DaytonaDriver {
|
||||
export function createTestDaytonaDriver(overrides?: Partial<DaytonaDriver>): DaytonaDriver {
|
||||
return {
|
||||
createClient: (_opts) => createTestDaytonaClient(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function createTestDaytonaClient(
|
||||
overrides?: Partial<DaytonaClientLike>
|
||||
): DaytonaClientLike {
|
||||
export function createTestDaytonaClient(overrides?: Partial<DaytonaClientLike>): DaytonaClientLike {
|
||||
return {
|
||||
createSandbox: async () => ({ id: "sandbox-test-1", state: "started" }),
|
||||
getSandbox: async (sandboxId) => ({ id: sandboxId, state: "started" }),
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
projectKey,
|
||||
projectPrSyncKey,
|
||||
sandboxInstanceKey,
|
||||
workspaceKey
|
||||
workspaceKey,
|
||||
} from "../src/actors/keys.js";
|
||||
|
||||
describe("actor keys", () => {
|
||||
|
|
@ -20,7 +20,7 @@ describe("actor keys", () => {
|
|||
historyKey("default", "repo"),
|
||||
projectPrSyncKey("default", "repo"),
|
||||
projectBranchSyncKey("default", "repo"),
|
||||
handoffStatusSyncKey("default", "repo", "handoff", "sandbox-1", "session-1")
|
||||
handoffStatusSyncKey("default", "repo", "handoff", "sandbox-1", "session-1"),
|
||||
];
|
||||
|
||||
for (const key of keys) {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ describe("malformed URI handling", () => {
|
|||
fetch: async (_req: Request): Promise<Response> => {
|
||||
// Simulate what happens when rivetkit's router encounters a malformed URI
|
||||
throw new URIError("URI malformed");
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const safeFetch = async (req: Request): Promise<Response> => {
|
||||
|
|
@ -30,7 +30,7 @@ describe("malformed URI handling", () => {
|
|||
const mockApp = {
|
||||
fetch: async (_req: Request): Promise<Response> => {
|
||||
throw new TypeError("some other error");
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const safeFetch = async (req: Request): Promise<Response> => {
|
||||
|
|
@ -51,7 +51,7 @@ describe("malformed URI handling", () => {
|
|||
const mockApp = {
|
||||
fetch: async (_req: Request): Promise<Response> => {
|
||||
return new Response("OK", { status: 200 });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const safeFetch = async (req: Request): Promise<Response> => {
|
||||
|
|
|
|||
|
|
@ -14,12 +14,12 @@ function makeConfig(): AppConfig {
|
|||
opencode_poll_interval: 2,
|
||||
github_poll_interval: 30,
|
||||
backup_interval_secs: 3600,
|
||||
backup_retention_days: 7
|
||||
backup_retention_days: 7,
|
||||
},
|
||||
providers: {
|
||||
local: {},
|
||||
daytona: { image: "ubuntu:24.04" }
|
||||
}
|
||||
daytona: { image: "ubuntu:24.04" },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -40,7 +40,7 @@ describe("provider registry", () => {
|
|||
apiKey: "test-token",
|
||||
},
|
||||
},
|
||||
})
|
||||
}),
|
||||
);
|
||||
expect(registry.defaultProviderId()).toBe("daytona");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,33 +3,23 @@ import { normalizeRemoteUrl, repoIdFromRemote } from "../src/services/repo.js";
|
|||
|
||||
describe("normalizeRemoteUrl", () => {
|
||||
test("accepts GitHub shorthand owner/repo", () => {
|
||||
expect(normalizeRemoteUrl("rivet-dev/openhandoff")).toBe(
|
||||
"https://github.com/rivet-dev/openhandoff.git"
|
||||
);
|
||||
expect(normalizeRemoteUrl("rivet-dev/openhandoff")).toBe("https://github.com/rivet-dev/openhandoff.git");
|
||||
});
|
||||
|
||||
test("accepts github.com/owner/repo without scheme", () => {
|
||||
expect(normalizeRemoteUrl("github.com/rivet-dev/openhandoff")).toBe(
|
||||
"https://github.com/rivet-dev/openhandoff.git"
|
||||
);
|
||||
expect(normalizeRemoteUrl("github.com/rivet-dev/openhandoff")).toBe("https://github.com/rivet-dev/openhandoff.git");
|
||||
});
|
||||
|
||||
test("canonicalizes GitHub repo URLs without .git", () => {
|
||||
expect(normalizeRemoteUrl("https://github.com/rivet-dev/openhandoff")).toBe(
|
||||
"https://github.com/rivet-dev/openhandoff.git"
|
||||
);
|
||||
expect(normalizeRemoteUrl("https://github.com/rivet-dev/openhandoff")).toBe("https://github.com/rivet-dev/openhandoff.git");
|
||||
});
|
||||
|
||||
test("canonicalizes GitHub non-clone URLs (e.g. /tree/main)", () => {
|
||||
expect(normalizeRemoteUrl("https://github.com/rivet-dev/openhandoff/tree/main")).toBe(
|
||||
"https://github.com/rivet-dev/openhandoff.git"
|
||||
);
|
||||
expect(normalizeRemoteUrl("https://github.com/rivet-dev/openhandoff/tree/main")).toBe("https://github.com/rivet-dev/openhandoff.git");
|
||||
});
|
||||
|
||||
test("does not rewrite scp-style ssh remotes", () => {
|
||||
expect(normalizeRemoteUrl("git@github.com:rivet-dev/openhandoff.git")).toBe(
|
||||
"git@github.com:rivet-dev/openhandoff.git"
|
||||
);
|
||||
expect(normalizeRemoteUrl("git@github.com:rivet-dev/openhandoff.git")).toBe("git@github.com:rivet-dev/openhandoff.git");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,5 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
normalizeParentBranch,
|
||||
parentLookupFromStack,
|
||||
sortBranchesForOverview,
|
||||
} from "../src/actors/project/stack-model.js";
|
||||
import { normalizeParentBranch, parentLookupFromStack, sortBranchesForOverview } from "../src/actors/project/stack-model.js";
|
||||
|
||||
describe("stack-model", () => {
|
||||
it("normalizes self-parent references to null", () => {
|
||||
|
|
@ -33,12 +29,6 @@ describe("stack-model", () => {
|
|||
{ branchName: "cycle-b", parentBranch: "cycle-a", updatedAt: 250 },
|
||||
]);
|
||||
|
||||
expect(rows.map((row) => row.branchName)).toEqual([
|
||||
"main",
|
||||
"feature/a",
|
||||
"feature/b",
|
||||
"cycle-a",
|
||||
"cycle-b",
|
||||
]);
|
||||
expect(rows.map((row) => row.branchName)).toEqual(["main", "feature/a", "feature/b", "cycle-a", "cycle-b"]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -24,11 +24,7 @@ function createRepo(): { repoPath: string } {
|
|||
return { repoPath };
|
||||
}
|
||||
|
||||
async function waitForWorkspaceRows(
|
||||
ws: any,
|
||||
workspaceId: string,
|
||||
expectedCount: number
|
||||
) {
|
||||
async function waitForWorkspaceRows(ws: any, workspaceId: string, expectedCount: number) {
|
||||
for (let attempt = 0; attempt < 40; attempt += 1) {
|
||||
const rows = await ws.listHandoffs({ workspaceId });
|
||||
if (rows.length >= expectedCount) {
|
||||
|
|
@ -40,18 +36,16 @@ async function waitForWorkspaceRows(
|
|||
}
|
||||
|
||||
describe("workspace isolation", () => {
|
||||
it.skipIf(!runActorIntegration)(
|
||||
"keeps handoff lists isolated by workspace",
|
||||
async (t) => {
|
||||
it.skipIf(!runActorIntegration)("keeps handoff lists isolated by workspace", async (t) => {
|
||||
const testDriver = createTestDriver();
|
||||
createTestRuntimeContext(testDriver);
|
||||
|
||||
const { client } = await setupTest(t, registry);
|
||||
const wsA = await client.workspace.getOrCreate(workspaceKey("alpha"), {
|
||||
createWithInput: "alpha"
|
||||
createWithInput: "alpha",
|
||||
});
|
||||
const wsB = await client.workspace.getOrCreate(workspaceKey("beta"), {
|
||||
createWithInput: "beta"
|
||||
createWithInput: "beta",
|
||||
});
|
||||
|
||||
const { repoPath } = createRepo();
|
||||
|
|
@ -64,7 +58,7 @@ describe("workspace isolation", () => {
|
|||
task: "task A",
|
||||
providerId: "daytona",
|
||||
explicitBranchName: "feature/a",
|
||||
explicitTitle: "A"
|
||||
explicitTitle: "A",
|
||||
});
|
||||
|
||||
await wsB.createHandoff({
|
||||
|
|
@ -73,7 +67,7 @@ describe("workspace isolation", () => {
|
|||
task: "task B",
|
||||
providerId: "daytona",
|
||||
explicitBranchName: "feature/b",
|
||||
explicitTitle: "B"
|
||||
explicitTitle: "B",
|
||||
});
|
||||
|
||||
const aRows = await waitForWorkspaceRows(wsA, "alpha", 1);
|
||||
|
|
@ -84,6 +78,5 @@ describe("workspace isolation", () => {
|
|||
expect(aRows[0]?.workspaceId).toBe("alpha");
|
||||
expect(bRows[0]?.workspaceId).toBe("beta");
|
||||
expect(aRows[0]?.handoffId).not.toBe(bRows[0]?.handoffId);
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -51,13 +51,19 @@ for (const t of targets) {
|
|||
const wfStateRow = db.query("SELECT value FROM kv WHERE hex(key)=?").get("0715041501");
|
||||
const wfState = wfStateRow?.value ? decodeAscii(new Uint8Array(wfStateRow.value)) : null;
|
||||
|
||||
console.log(JSON.stringify({
|
||||
handoffId: t.handoffId,
|
||||
actorId: t.actorId,
|
||||
wfState,
|
||||
names: wh.nameRegistry,
|
||||
entries: enriched,
|
||||
}, null, 2));
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
handoffId: t.handoffId,
|
||||
actorId: t.actorId,
|
||||
wfState,
|
||||
names: wh.nameRegistry,
|
||||
entries: enriched,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
clearTimeout(to);
|
||||
ws.close();
|
||||
|
|
|
|||
|
|
@ -1,10 +1,5 @@
|
|||
import { Database } from "bun:sqlite";
|
||||
import {
|
||||
TO_CLIENT_VERSIONED,
|
||||
TO_SERVER_VERSIONED,
|
||||
CURRENT_VERSION,
|
||||
decodeWorkflowHistoryTransport,
|
||||
} from "rivetkit/inspector";
|
||||
import { TO_CLIENT_VERSIONED, TO_SERVER_VERSIONED, CURRENT_VERSION, decodeWorkflowHistoryTransport } from "rivetkit/inspector";
|
||||
import { decodeReadRangeWire } from "/rivet-handoff-fixes/rivetkit-typescript/packages/traces/src/encoding.ts";
|
||||
import { readRangeWireToOtlp } from "/rivet-handoff-fixes/rivetkit-typescript/packages/traces/src/read-range.ts";
|
||||
|
||||
|
|
@ -46,13 +41,30 @@ ws.onmessage = (ev) => {
|
|||
|
||||
if (msg.body.tag === "QueueResponse") {
|
||||
const status = msg.body.val.status;
|
||||
console.log(JSON.stringify({ tag: "QueueResponse", size: Number(status.size), truncated: status.truncated, messages: status.messages.map((m) => ({ id: Number(m.id), name: m.name, createdAtMs: Number(m.createdAtMs) })) }, null, 2));
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
tag: "QueueResponse",
|
||||
size: Number(status.size),
|
||||
truncated: status.truncated,
|
||||
messages: status.messages.map((m) => ({ id: Number(m.id), name: m.name, createdAtMs: Number(m.createdAtMs) })),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.body.tag === "WorkflowHistoryResponse") {
|
||||
const wh = decodeWorkflowHistoryTransport(msg.body.val.history);
|
||||
console.log(JSON.stringify({ tag: "WorkflowHistoryResponse", isWorkflowEnabled: msg.body.val.isWorkflowEnabled, entryCount: wh.entries.length, names: wh.nameRegistry }, null, 2));
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{ tag: "WorkflowHistoryResponse", isWorkflowEnabled: msg.body.val.isWorkflowEnabled, entryCount: wh.entries.length, names: wh.nameRegistry },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -35,11 +35,17 @@ for (const actorId of actorIds) {
|
|||
|
||||
const hasCreateSandboxStepName = names.includes("init-create-sandbox") || names.includes("init_create_sandbox");
|
||||
|
||||
console.log(JSON.stringify({
|
||||
actorId,
|
||||
wfState,
|
||||
hasCreateSandboxStepName,
|
||||
names,
|
||||
queue: queueRows,
|
||||
}, null, 2));
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
actorId,
|
||||
wfState,
|
||||
hasCreateSandboxStepName,
|
||||
names,
|
||||
queue: queueRows,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,5 @@ export default defineConfig({
|
|||
testTimeout: 15_000,
|
||||
hookTimeout: 20_000,
|
||||
setupFiles: ["./test/setup.ts"],
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,5 @@
|
|||
import * as childProcess from "node:child_process";
|
||||
import {
|
||||
closeSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync
|
||||
} from "node:fs";
|
||||
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
|
@ -141,7 +133,7 @@ function removeStateFiles(host: string, port: number): void {
|
|||
async function checkHealth(host: string, port: number): Promise<boolean> {
|
||||
return await checkBackendHealth({
|
||||
endpoint: `http://${host}:${port}/api/rivet`,
|
||||
timeoutMs: HEALTH_TIMEOUT_MS
|
||||
timeoutMs: HEALTH_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -206,25 +198,14 @@ function resolveLaunchSpec(host: string, port: number): LaunchSpec {
|
|||
return {
|
||||
command: resolveBunCommand(),
|
||||
args: [backendEntry, "start", "--host", host, "--port", String(port)],
|
||||
cwd: repoRoot
|
||||
cwd: repoRoot,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
command: "pnpm",
|
||||
args: [
|
||||
"--filter",
|
||||
"@openhandoff/backend",
|
||||
"exec",
|
||||
"bun",
|
||||
"src/index.ts",
|
||||
"start",
|
||||
"--host",
|
||||
host,
|
||||
"--port",
|
||||
String(port)
|
||||
],
|
||||
cwd: repoRoot
|
||||
args: ["--filter", "@openhandoff/backend", "exec", "bun", "src/index.ts", "start", "--host", host, "--port", String(port)],
|
||||
cwd: repoRoot,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -252,7 +233,7 @@ async function startBackend(host: string, port: number): Promise<void> {
|
|||
cwd: launch.cwd,
|
||||
detached: true,
|
||||
stdio: ["ignore", fd, fd],
|
||||
env: process.env
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
|
|
@ -298,7 +279,7 @@ function findProcessOnPort(port: number): number | null {
|
|||
const out = childProcess
|
||||
.execFileSync("lsof", ["-i", `:${port}`, "-t", "-sTCP:LISTEN"], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"]
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
})
|
||||
.trim();
|
||||
|
||||
|
|
@ -372,7 +353,7 @@ export async function getBackendStatus(host: string, port: number): Promise<Back
|
|||
pid,
|
||||
version: readBackendVersion(host, port),
|
||||
versionCurrent: isVersionCurrent(host, port),
|
||||
logPath
|
||||
logPath,
|
||||
};
|
||||
}
|
||||
removeStateFiles(host, port);
|
||||
|
|
@ -384,7 +365,7 @@ export async function getBackendStatus(host: string, port: number): Promise<Back
|
|||
pid: null,
|
||||
version: readBackendVersion(host, port),
|
||||
versionCurrent: isVersionCurrent(host, port),
|
||||
logPath
|
||||
logPath,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -393,7 +374,7 @@ export async function getBackendStatus(host: string, port: number): Promise<Back
|
|||
pid: null,
|
||||
version: readBackendVersion(host, port),
|
||||
versionCurrent: false,
|
||||
logPath
|
||||
logPath,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,3 @@
|
|||
declare const __HF_BUILD_ID__: string | undefined;
|
||||
|
||||
export const CLI_BUILD_ID =
|
||||
typeof __HF_BUILD_ID__ === "string" && __HF_BUILD_ID__.trim().length > 0
|
||||
? __HF_BUILD_ID__.trim()
|
||||
: "dev";
|
||||
|
||||
export const CLI_BUILD_ID = typeof __HF_BUILD_ID__ === "string" && __HF_BUILD_ID__.trim().length > 0 ? __HF_BUILD_ID__.trim() : "dev";
|
||||
|
|
|
|||
|
|
@ -3,19 +3,8 @@ import { spawnSync } from "node:child_process";
|
|||
import { existsSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { AgentTypeSchema, CreateHandoffInputSchema, type HandoffRecord } from "@openhandoff/shared";
|
||||
import {
|
||||
readBackendMetadata,
|
||||
createBackendClientFromConfig,
|
||||
formatRelativeAge,
|
||||
groupHandoffStatus,
|
||||
summarizeHandoffs
|
||||
} from "@openhandoff/client";
|
||||
import {
|
||||
ensureBackendRunning,
|
||||
getBackendStatus,
|
||||
parseBackendPort,
|
||||
stopBackend
|
||||
} from "./backend/manager.js";
|
||||
import { readBackendMetadata, createBackendClientFromConfig, formatRelativeAge, groupHandoffStatus, summarizeHandoffs } from "@openhandoff/client";
|
||||
import { ensureBackendRunning, getBackendStatus, parseBackendPort, stopBackend } from "./backend/manager.js";
|
||||
import { openEditorForTask } from "./task-editor.js";
|
||||
import { spawnCreateTmuxWindow } from "./tmux.js";
|
||||
import { loadConfig, resolveWorkspace, saveConfig } from "./workspace/config.js";
|
||||
|
|
@ -26,11 +15,7 @@ async function ensureBunRuntime(): Promise<void> {
|
|||
}
|
||||
|
||||
const preferred = process.env.HF_BUN?.trim();
|
||||
const candidates = [
|
||||
preferred,
|
||||
`${homedir()}/.bun/bin/bun`,
|
||||
"bun"
|
||||
].filter((item): item is string => Boolean(item && item.length > 0));
|
||||
const candidates = [preferred, `${homedir()}/.bun/bin/bun`, "bun"].filter((item): item is string => Boolean(item && item.length > 0));
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const command = candidate;
|
||||
|
|
@ -41,7 +26,7 @@ async function ensureBunRuntime(): Promise<void> {
|
|||
|
||||
const child = spawnSync(command, [process.argv[1] ?? "", ...process.argv.slice(2)], {
|
||||
stdio: "inherit",
|
||||
env: process.env
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
if (child.error) {
|
||||
|
|
@ -70,11 +55,7 @@ function hasFlag(args: string[], flag: string): boolean {
|
|||
return args.includes(flag);
|
||||
}
|
||||
|
||||
function parseIntOption(
|
||||
value: string | undefined,
|
||||
fallback: number,
|
||||
label: string
|
||||
): number {
|
||||
function parseIntOption(value: string | undefined, fallback: number, label: string): number {
|
||||
if (!value) {
|
||||
return fallback;
|
||||
}
|
||||
|
|
@ -204,8 +185,8 @@ async function handleBackend(args: string[]): Promise<void> {
|
|||
backend: {
|
||||
...config.backend,
|
||||
host,
|
||||
port
|
||||
}
|
||||
port,
|
||||
},
|
||||
};
|
||||
|
||||
if (sub === "start") {
|
||||
|
|
@ -229,9 +210,7 @@ async function handleBackend(args: string[]): Promise<void> {
|
|||
const pid = status.pid ?? "unknown";
|
||||
const version = status.version ?? "unknown";
|
||||
const stale = status.running && !status.versionCurrent ? " [outdated]" : "";
|
||||
console.log(
|
||||
`running=${status.running} pid=${pid} version=${version}${stale} host=${host} port=${port} log=${status.logPath}`
|
||||
);
|
||||
console.log(`running=${status.running} pid=${pid} version=${version}${stale} host=${host} port=${port} log=${status.logPath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -239,7 +218,7 @@ async function handleBackend(args: string[]): Promise<void> {
|
|||
await ensureBackendRunning(backendConfig);
|
||||
const metadata = await readBackendMetadata({
|
||||
endpoint: `http://${host}:${port}/api/rivet`,
|
||||
timeoutMs: 4_000
|
||||
timeoutMs: 4_000,
|
||||
});
|
||||
const managerEndpoint = metadata.clientEndpoint ?? `http://${host}:${port}`;
|
||||
const inspectorUrl = `https://inspect.rivet.dev?u=${encodeURIComponent(managerEndpoint)}`;
|
||||
|
|
@ -424,7 +403,7 @@ async function waitForHandoffReady(
|
|||
client: ReturnType<typeof createBackendClientFromConfig>,
|
||||
workspaceId: string,
|
||||
handoffId: string,
|
||||
timeoutMs: number
|
||||
timeoutMs: number,
|
||||
): Promise<HandoffRecord> {
|
||||
const start = Date.now();
|
||||
let delayMs = 250;
|
||||
|
|
@ -478,7 +457,7 @@ async function handleCreate(args: string[]): Promise<void> {
|
|||
explicitTitle: explicitTitle || undefined,
|
||||
explicitBranchName: explicitBranchName || undefined,
|
||||
agentType,
|
||||
onBranch
|
||||
onBranch,
|
||||
});
|
||||
|
||||
const created = await client.createHandoff(payload);
|
||||
|
|
@ -496,7 +475,7 @@ async function handleCreate(args: string[]): Promise<void> {
|
|||
const tmuxResult = spawnCreateTmuxWindow({
|
||||
branchName: handoff.branchName ?? handoff.handoffId,
|
||||
targetPath: switched.switchTarget || attached.target,
|
||||
sessionId: attached.sessionId
|
||||
sessionId: attached.sessionId,
|
||||
});
|
||||
|
||||
if (tmuxResult.created) {
|
||||
|
|
@ -507,7 +486,7 @@ async function handleCreate(args: string[]): Promise<void> {
|
|||
console.log("");
|
||||
console.log(`Run: hf switch ${handoff.handoffId}`);
|
||||
if ((switched.switchTarget || attached.target).startsWith("/")) {
|
||||
console.log(`cd ${(switched.switchTarget || attached.target)}`);
|
||||
console.log(`cd ${switched.switchTarget || attached.target}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -539,23 +518,21 @@ async function handleStatus(args: string[]): Promise<void> {
|
|||
handoffs: {
|
||||
total: summary.total,
|
||||
byStatus: summary.byStatus,
|
||||
byProvider: summary.byProvider
|
||||
}
|
||||
byProvider: summary.byProvider,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
2,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`workspace=${workspaceId}`);
|
||||
console.log(
|
||||
`backend running=${backendStatus.running} pid=${backendStatus.pid ?? "unknown"} version=${backendStatus.version ?? "unknown"}`
|
||||
);
|
||||
console.log(`backend running=${backendStatus.running} pid=${backendStatus.pid ?? "unknown"} version=${backendStatus.version ?? "unknown"}`);
|
||||
console.log(`handoffs total=${summary.total}`);
|
||||
console.log(
|
||||
`status queued=${summary.byStatus.queued} running=${summary.byStatus.running} idle=${summary.byStatus.idle} archived=${summary.byStatus.archived} killed=${summary.byStatus.killed} error=${summary.byStatus.error}`
|
||||
`status queued=${summary.byStatus.queued} running=${summary.byStatus.running} idle=${summary.byStatus.idle} archived=${summary.byStatus.archived} killed=${summary.byStatus.killed} error=${summary.byStatus.error}`,
|
||||
);
|
||||
const providerSummary = Object.entries(summary.byProvider)
|
||||
.map(([provider, count]) => `${provider}=${count}`)
|
||||
|
|
@ -579,7 +556,7 @@ async function handleHistory(args: string[]): Promise<void> {
|
|||
workspaceId,
|
||||
limit,
|
||||
branch: branch || undefined,
|
||||
handoffId: handoffId || undefined
|
||||
handoffId: handoffId || undefined,
|
||||
});
|
||||
|
||||
if (hasFlag(args, "--json")) {
|
||||
|
|
@ -748,7 +725,7 @@ async function main(): Promise<void> {
|
|||
}
|
||||
|
||||
main().catch((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.stack ?? err.message : String(err);
|
||||
const msg = err instanceof Error ? (err.stack ?? err.message) : String(err);
|
||||
console.error(msg);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,11 +3,7 @@ import { tmpdir } from "node:os";
|
|||
import { join } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const DEFAULT_EDITOR_TEMPLATE = [
|
||||
"# Enter handoff task details below.",
|
||||
"# Lines starting with # are ignored.",
|
||||
""
|
||||
].join("\n");
|
||||
const DEFAULT_EDITOR_TEMPLATE = ["# Enter handoff task details below.", "# Lines starting with # are ignored.", ""].join("\n");
|
||||
|
||||
export function sanitizeEditorTask(input: string): string {
|
||||
return input
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ const DEFAULT_THEME: TuiTheme = {
|
|||
reviewApproved: "#22c55e",
|
||||
reviewChanges: "#ef4444",
|
||||
reviewPending: "#eab308",
|
||||
reviewNone: "#6b7280"
|
||||
reviewNone: "#6b7280",
|
||||
};
|
||||
|
||||
const OPENCODE_THEME_PACK = opencodeThemePackJson as Record<string, unknown>;
|
||||
|
|
@ -102,7 +102,7 @@ export function resolveTuiTheme(config: AppConfig, baseDir = cwd()): TuiThemeRes
|
|||
theme: candidate.theme,
|
||||
name: candidate.name,
|
||||
source: "openhandoff config",
|
||||
mode
|
||||
mode,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -121,7 +121,7 @@ export function resolveTuiTheme(config: AppConfig, baseDir = cwd()): TuiThemeRes
|
|||
theme: DEFAULT_THEME,
|
||||
name: "opencode-default",
|
||||
source: "default",
|
||||
mode
|
||||
mode,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -150,7 +150,7 @@ function loadOpencodeThemeFromConfig(mode: ThemeMode, baseDir: string): TuiTheme
|
|||
theme: candidate.theme,
|
||||
name: candidate.name,
|
||||
source: `opencode config (${path})`,
|
||||
mode
|
||||
mode,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -182,20 +182,15 @@ function loadOpencodeThemeFromState(mode: ThemeMode, baseDir: string): TuiThemeR
|
|||
theme: candidate.theme,
|
||||
name: candidate.name,
|
||||
source: `opencode state (${path})`,
|
||||
mode
|
||||
mode,
|
||||
};
|
||||
}
|
||||
|
||||
function loadFromSpec(
|
||||
spec: string,
|
||||
searchDirs: string[],
|
||||
mode: ThemeMode,
|
||||
baseDir: string
|
||||
): ThemeCandidate | null {
|
||||
function loadFromSpec(spec: string, searchDirs: string[], mode: ThemeMode, baseDir: string): ThemeCandidate | null {
|
||||
if (isDefaultThemeName(spec)) {
|
||||
return {
|
||||
theme: DEFAULT_THEME,
|
||||
name: "opencode-default"
|
||||
name: "opencode-default",
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -229,7 +224,7 @@ function loadFromSpec(
|
|||
if (theme) {
|
||||
return {
|
||||
theme,
|
||||
name: spec
|
||||
name: spec,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -253,7 +248,7 @@ function loadThemeFromPath(path: string, mode: ThemeMode): ThemeCandidate | null
|
|||
}
|
||||
return {
|
||||
theme,
|
||||
name: themeNameFromPath(path)
|
||||
name: themeNameFromPath(path),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
|
|
@ -269,7 +264,7 @@ function loadThemeFromPath(path: string, mode: ThemeMode): ThemeCandidate | null
|
|||
if (opencodeTheme) {
|
||||
return {
|
||||
theme: opencodeTheme,
|
||||
name: themeNameFromPath(path)
|
||||
name: themeNameFromPath(path),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -280,7 +275,7 @@ function loadThemeFromPath(path: string, mode: ThemeMode): ThemeCandidate | null
|
|||
|
||||
return {
|
||||
theme: paletteTheme,
|
||||
name: themeNameFromPath(path)
|
||||
name: themeNameFromPath(path),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -292,12 +287,7 @@ function themeNameFromPath(path: string): string {
|
|||
return base;
|
||||
}
|
||||
|
||||
function themeFromOpencodeValue(
|
||||
value: unknown,
|
||||
searchDirs: string[],
|
||||
mode: ThemeMode,
|
||||
baseDir: string
|
||||
): ThemeCandidate | null {
|
||||
function themeFromOpencodeValue(value: unknown, searchDirs: string[], mode: ThemeMode, baseDir: string): ThemeCandidate | null {
|
||||
if (typeof value === "string") {
|
||||
return loadFromSpec(value, searchDirs, mode, baseDir);
|
||||
}
|
||||
|
|
@ -311,7 +301,7 @@ function themeFromOpencodeValue(
|
|||
if (theme) {
|
||||
return {
|
||||
theme,
|
||||
name: typeof value.name === "string" ? value.name : "inline"
|
||||
name: typeof value.name === "string" ? value.name : "inline",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -320,7 +310,7 @@ function themeFromOpencodeValue(
|
|||
if (paletteTheme) {
|
||||
return {
|
||||
theme: paletteTheme,
|
||||
name: typeof value.name === "string" ? value.name : "inline"
|
||||
name: typeof value.name === "string" ? value.name : "inline",
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -382,10 +372,7 @@ function themeFromOpencodeJson(value: unknown, mode: ThemeMode): TuiTheme | null
|
|||
const info = opencodeColor(themeMap, defs, mode, "info") ?? DEFAULT_THEME.info;
|
||||
const diffAdd = opencodeColor(themeMap, defs, mode, "diffAdded") ?? success;
|
||||
const diffDel = opencodeColor(themeMap, defs, mode, "diffRemoved") ?? error;
|
||||
const diffSep =
|
||||
opencodeColor(themeMap, defs, mode, "diffContext") ??
|
||||
opencodeColor(themeMap, defs, mode, "diffHunkHeader") ??
|
||||
muted;
|
||||
const diffSep = opencodeColor(themeMap, defs, mode, "diffContext") ?? opencodeColor(themeMap, defs, mode, "diffHunkHeader") ?? muted;
|
||||
|
||||
return {
|
||||
background,
|
||||
|
|
@ -416,7 +403,7 @@ function themeFromOpencodeJson(value: unknown, mode: ThemeMode): TuiTheme | null
|
|||
reviewApproved: success,
|
||||
reviewChanges: error,
|
||||
reviewPending: warning,
|
||||
reviewNone: muted
|
||||
reviewNone: muted,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -428,13 +415,7 @@ function opencodeColor(themeMap: JsonObject, defs: JsonObject, mode: ThemeMode,
|
|||
return resolveOpencodeColor(raw, themeMap, defs, mode, 0);
|
||||
}
|
||||
|
||||
function resolveOpencodeColor(
|
||||
value: unknown,
|
||||
themeMap: JsonObject,
|
||||
defs: JsonObject,
|
||||
mode: ThemeMode,
|
||||
depth: number
|
||||
): string | null {
|
||||
function resolveOpencodeColor(value: unknown, themeMap: JsonObject, defs: JsonObject, mode: ThemeMode, depth: number): string | null {
|
||||
if (depth > 12) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -533,7 +514,7 @@ function themeFromAny(value: unknown): TuiTheme | null {
|
|||
reviewApproved: pick(["review_approved", "approved"], success),
|
||||
reviewChanges: pick(["review_changes", "changes"], error),
|
||||
reviewPending: pick(["review_pending", "pending"], warning),
|
||||
reviewNone: pick(["review_none", "review_unknown"], muted)
|
||||
reviewNone: pick(["review_none", "review_unknown"], muted),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,12 +20,7 @@ export interface SpawnCreateTmuxWindowInput {
|
|||
|
||||
export interface SpawnCreateTmuxWindowResult {
|
||||
created: boolean;
|
||||
reason:
|
||||
| "created"
|
||||
| "not-in-tmux"
|
||||
| "not-local-path"
|
||||
| "window-exists"
|
||||
| "tmux-new-window-failed";
|
||||
reason: "created" | "not-in-tmux" | "not-local-path" | "window-exists" | "tmux-new-window-failed";
|
||||
}
|
||||
|
||||
function isTmuxSession(): boolean {
|
||||
|
|
@ -63,10 +58,7 @@ function resolveOpencodeBinary(): string {
|
|||
return "opencode";
|
||||
}
|
||||
|
||||
const bundledCandidates = [
|
||||
`${homedir()}/.local/share/sandbox-agent/bin/opencode`,
|
||||
`${homedir()}/.opencode/bin/opencode`
|
||||
];
|
||||
const bundledCandidates = [`${homedir()}/.local/share/sandbox-agent/bin/opencode`, `${homedir()}/.opencode/bin/opencode`];
|
||||
|
||||
for (const candidate of bundledCandidates) {
|
||||
if (existsSync(candidate)) {
|
||||
|
|
@ -79,15 +71,7 @@ function resolveOpencodeBinary(): string {
|
|||
|
||||
function attachCommand(sessionId: string, targetPath: string, endpoint: string): string {
|
||||
const opencode = resolveOpencodeBinary();
|
||||
return [
|
||||
shellEscape(opencode),
|
||||
"attach",
|
||||
shellEscape(endpoint),
|
||||
"--session",
|
||||
shellEscape(sessionId),
|
||||
"--dir",
|
||||
shellEscape(targetPath)
|
||||
].join(" ");
|
||||
return [shellEscape(opencode), "attach", shellEscape(endpoint), "--session", shellEscape(sessionId), "--dir", shellEscape(targetPath)].join(" ");
|
||||
}
|
||||
|
||||
export function stripStatusPrefix(windowName: string): string {
|
||||
|
|
@ -99,11 +83,7 @@ export function stripStatusPrefix(windowName: string): string {
|
|||
}
|
||||
|
||||
export function findTmuxWindowsByBranch(branchName: string): TmuxWindowMatch[] {
|
||||
const output = spawnSync(
|
||||
"tmux",
|
||||
["list-windows", "-a", "-F", "#{session_name}:#{window_id}:#{window_name}"],
|
||||
{ encoding: "utf8" }
|
||||
);
|
||||
const output = spawnSync("tmux", ["list-windows", "-a", "-F", "#{session_name}:#{window_id}:#{window_name}"], { encoding: "utf8" });
|
||||
|
||||
if (output.error || output.status !== 0 || !output.stdout) {
|
||||
return [];
|
||||
|
|
@ -128,16 +108,14 @@ export function findTmuxWindowsByBranch(branchName: string): TmuxWindowMatch[] {
|
|||
|
||||
matches.push({
|
||||
target: `${sessionName}:${windowId}`,
|
||||
windowName
|
||||
windowName,
|
||||
});
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
export function spawnCreateTmuxWindow(
|
||||
input: SpawnCreateTmuxWindowInput
|
||||
): SpawnCreateTmuxWindowResult {
|
||||
export function spawnCreateTmuxWindow(input: SpawnCreateTmuxWindowInput): SpawnCreateTmuxWindowResult {
|
||||
if (!isTmuxSession()) {
|
||||
return { created: false, reason: "not-in-tmux" };
|
||||
}
|
||||
|
|
@ -154,21 +132,10 @@ export function spawnCreateTmuxWindow(
|
|||
const endpoint = input.opencodeEndpoint ?? DEFAULT_OPENCODE_ENDPOINT;
|
||||
let output = "";
|
||||
try {
|
||||
output = execFileSync(
|
||||
"tmux",
|
||||
[
|
||||
"new-window",
|
||||
"-d",
|
||||
"-P",
|
||||
"-F",
|
||||
"#{window_id}",
|
||||
"-n",
|
||||
windowName,
|
||||
"-c",
|
||||
input.targetPath
|
||||
],
|
||||
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }
|
||||
);
|
||||
output = execFileSync("tmux", ["new-window", "-d", "-P", "-F", "#{window_id}", "-n", windowName, "-c", input.targetPath], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
} catch {
|
||||
return { created: false, reason: "tmux-new-window-failed" };
|
||||
}
|
||||
|
|
@ -184,11 +151,10 @@ export function spawnCreateTmuxWindow(
|
|||
// Split left pane horizontally → creates right pane; capture its pane ID
|
||||
let rightPane: string;
|
||||
try {
|
||||
rightPane = execFileSync(
|
||||
"tmux",
|
||||
["split-window", "-h", "-P", "-F", "#{pane_id}", "-t", leftPane, "-c", input.targetPath],
|
||||
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }
|
||||
).trim();
|
||||
rightPane = execFileSync("tmux", ["split-window", "-h", "-P", "-F", "#{pane_id}", "-t", leftPane, "-c", input.targetPath], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
}).trim();
|
||||
} catch {
|
||||
return { created: true, reason: "created" };
|
||||
}
|
||||
|
|
@ -206,13 +172,7 @@ export function spawnCreateTmuxWindow(
|
|||
|
||||
// Editor in left pane, agent attach in top-right pane
|
||||
runTmux(["send-keys", "-t", leftPane, "nvim .", "Enter"]);
|
||||
runTmux([
|
||||
"send-keys",
|
||||
"-t",
|
||||
rightPane,
|
||||
attachCommand(input.sessionId, input.targetPath, endpoint),
|
||||
"Enter"
|
||||
]);
|
||||
runTmux(["send-keys", "-t", rightPane, attachCommand(input.sessionId, input.targetPath, endpoint), "Enter"]);
|
||||
runTmux(["select-pane", "-t", rightPane]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,6 @@
|
|||
import type { AppConfig, HandoffRecord } from "@openhandoff/shared";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
createBackendClientFromConfig,
|
||||
filterHandoffs,
|
||||
formatRelativeAge,
|
||||
groupHandoffStatus
|
||||
} from "@openhandoff/client";
|
||||
import { createBackendClientFromConfig, filterHandoffs, formatRelativeAge, groupHandoffStatus } from "@openhandoff/client";
|
||||
import { CLI_BUILD_ID } from "./build-id.js";
|
||||
import { resolveTuiTheme, type TuiTheme } from "./theme.js";
|
||||
|
||||
|
|
@ -31,7 +26,7 @@ const HELP_LINES = [
|
|||
"Esc / Ctrl-C cancel",
|
||||
"",
|
||||
"Legend",
|
||||
"Agent: \u{1F916} running \u{1F4AC} idle \u25CC queued"
|
||||
"Agent: \u{1F916} running \u{1F4AC} idle \u25CC queued",
|
||||
];
|
||||
|
||||
const COLUMN_WIDTHS = {
|
||||
|
|
@ -41,7 +36,7 @@ const COLUMN_WIDTHS = {
|
|||
author: 10,
|
||||
ci: 7,
|
||||
review: 8,
|
||||
age: 5
|
||||
age: 5,
|
||||
} as const;
|
||||
|
||||
interface DisplayRow {
|
||||
|
|
@ -145,15 +140,17 @@ function agentSymbol(status: HandoffRecord["status"]): string {
|
|||
function toDisplayRow(row: HandoffRecord): DisplayRow {
|
||||
const conflictPrefix = row.conflictsWithMain === "true" ? "\u26A0 " : "";
|
||||
|
||||
const prLabel = row.prUrl
|
||||
? `#${row.prUrl.match(/\/pull\/(\d+)/)?.[1] ?? "?"}`
|
||||
: row.prSubmitted ? "sub" : "-";
|
||||
const prLabel = row.prUrl ? `#${row.prUrl.match(/\/pull\/(\d+)/)?.[1] ?? "?"}` : row.prSubmitted ? "sub" : "-";
|
||||
|
||||
const ciLabel = row.ciStatus ?? "-";
|
||||
const reviewLabel = row.reviewStatus
|
||||
? row.reviewStatus === "approved" ? "ok"
|
||||
: row.reviewStatus === "changes_requested" ? "chg"
|
||||
: row.reviewStatus === "pending" ? "..." : row.reviewStatus
|
||||
? row.reviewStatus === "approved"
|
||||
? "ok"
|
||||
: row.reviewStatus === "changes_requested"
|
||||
? "chg"
|
||||
: row.reviewStatus === "pending"
|
||||
? "..."
|
||||
: row.reviewStatus
|
||||
: "-";
|
||||
|
||||
return {
|
||||
|
|
@ -164,7 +161,7 @@ function toDisplayRow(row: HandoffRecord): DisplayRow {
|
|||
author: row.prAuthor ?? "-",
|
||||
ci: ciLabel,
|
||||
review: reviewLabel,
|
||||
age: formatRelativeAge(row.updatedAt)
|
||||
age: formatRelativeAge(row.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -189,18 +186,12 @@ export function formatRows(
|
|||
status: string,
|
||||
searchQuery = "",
|
||||
showHelp = false,
|
||||
options: RenderOptions = {}
|
||||
options: RenderOptions = {},
|
||||
): string {
|
||||
const totalWidth = options.width ?? process.stdout.columns ?? 120;
|
||||
const totalHeight = Math.max(6, options.height ?? process.stdout.rows ?? 24);
|
||||
const fixedWidth =
|
||||
COLUMN_WIDTHS.diff +
|
||||
COLUMN_WIDTHS.agent +
|
||||
COLUMN_WIDTHS.pr +
|
||||
COLUMN_WIDTHS.author +
|
||||
COLUMN_WIDTHS.ci +
|
||||
COLUMN_WIDTHS.review +
|
||||
COLUMN_WIDTHS.age;
|
||||
COLUMN_WIDTHS.diff + COLUMN_WIDTHS.agent + COLUMN_WIDTHS.pr + COLUMN_WIDTHS.author + COLUMN_WIDTHS.ci + COLUMN_WIDTHS.review + COLUMN_WIDTHS.age;
|
||||
const separators = 7;
|
||||
const prefixWidth = 2;
|
||||
const branchWidth = Math.max(20, totalWidth - (fixedWidth + separators + prefixWidth));
|
||||
|
|
@ -208,7 +199,7 @@ export function formatRows(
|
|||
const branchHeader = searchQuery ? `Branch/PR: ${searchQuery}_` : "Branch/PR (type to filter)";
|
||||
const header = [
|
||||
` ${pad(branchHeader, branchWidth)} ${pad("Diff", COLUMN_WIDTHS.diff)} ${pad("Agent", COLUMN_WIDTHS.agent)} ${pad("PR", COLUMN_WIDTHS.pr)} ${pad("Author", COLUMN_WIDTHS.author)} ${pad("CI", COLUMN_WIDTHS.ci)} ${pad("Review", COLUMN_WIDTHS.review)} ${pad("Age", COLUMN_WIDTHS.age)}`,
|
||||
"-".repeat(Math.max(24, Math.min(totalWidth, 180)))
|
||||
"-".repeat(Math.max(24, Math.min(totalWidth, 180))),
|
||||
];
|
||||
|
||||
const body =
|
||||
|
|
@ -220,14 +211,7 @@ export function formatRows(
|
|||
return `${marker}${pad(display.name, branchWidth)} ${pad(display.diff, COLUMN_WIDTHS.diff)} ${pad(display.agent, COLUMN_WIDTHS.agent)} ${pad(display.pr, COLUMN_WIDTHS.pr)} ${pad(display.author, COLUMN_WIDTHS.author)} ${pad(display.ci, COLUMN_WIDTHS.ci)} ${pad(display.review, COLUMN_WIDTHS.review)} ${pad(display.age, COLUMN_WIDTHS.age)}`;
|
||||
});
|
||||
|
||||
const footer = fitLine(
|
||||
buildFooterLine(
|
||||
totalWidth,
|
||||
["Ctrl-H:cheatsheet", `workspace:${workspaceId}`, status],
|
||||
`v${CLI_BUILD_ID}`
|
||||
),
|
||||
totalWidth,
|
||||
);
|
||||
const footer = fitLine(buildFooterLine(totalWidth, ["Ctrl-H:cheatsheet", `workspace:${workspaceId}`, status], `v${CLI_BUILD_ID}`), totalWidth);
|
||||
|
||||
const contentHeight = totalHeight - 1;
|
||||
const lines = [...header, ...body].map((line) => fitLine(line, totalWidth));
|
||||
|
|
@ -256,7 +240,10 @@ export function formatRows(
|
|||
|
||||
interface OpenTuiLike {
|
||||
createCliRenderer?: (options?: Record<string, unknown>) => Promise<any>;
|
||||
TextRenderable?: new (ctx: any, options: { id: string; content: string }) => {
|
||||
TextRenderable?: new (
|
||||
ctx: any,
|
||||
options: { id: string; content: string },
|
||||
) => {
|
||||
content: unknown;
|
||||
fg?: string;
|
||||
bg?: string;
|
||||
|
|
@ -325,10 +312,7 @@ export async function runTui(config: AppConfig, workspaceId: string): Promise<vo
|
|||
const core = (await import("@opentui/core")) as OpenTuiLike;
|
||||
const createCliRenderer = core.createCliRenderer;
|
||||
const TextRenderable = core.TextRenderable;
|
||||
const styleApi =
|
||||
core.fg && core.bg && core.StyledText
|
||||
? { fg: core.fg, bg: core.bg, StyledText: core.StyledText }
|
||||
: null;
|
||||
const styleApi = core.fg && core.bg && core.StyledText ? { fg: core.fg, bg: core.bg, StyledText: core.StyledText } : null;
|
||||
|
||||
if (!createCliRenderer || !TextRenderable) {
|
||||
throw new Error("OpenTUI runtime missing createCliRenderer/TextRenderable exports");
|
||||
|
|
@ -339,7 +323,7 @@ export async function runTui(config: AppConfig, workspaceId: string): Promise<vo
|
|||
const renderer = await createCliRenderer({ exitOnCtrlC: false });
|
||||
const text = new TextRenderable(renderer, {
|
||||
id: "openhandoff-switch",
|
||||
content: "Loading..."
|
||||
content: "Loading...",
|
||||
});
|
||||
text.fg = themeResolution.theme.text;
|
||||
text.bg = themeResolution.theme.background;
|
||||
|
|
@ -376,11 +360,9 @@ export async function runTui(config: AppConfig, workspaceId: string): Promise<vo
|
|||
}
|
||||
const output = formatRows(filteredRows, selected, workspaceId, status, searchQuery, showHelp, {
|
||||
width: renderer.width ?? process.stdout.columns,
|
||||
height: renderer.height ?? process.stdout.rows
|
||||
height: renderer.height ?? process.stdout.rows,
|
||||
});
|
||||
text.content = styleApi
|
||||
? buildStyledContent(output, themeResolution.theme, styleApi)
|
||||
: output;
|
||||
text.content = styleApi ? buildStyledContent(output, themeResolution.theme, styleApi) : output;
|
||||
renderer.requestRender();
|
||||
};
|
||||
|
||||
|
|
@ -439,11 +421,7 @@ export async function runTui(config: AppConfig, workspaceId: string): Promise<vo
|
|||
close();
|
||||
};
|
||||
|
||||
const runActionWithRefresh = async (
|
||||
label: string,
|
||||
fn: () => Promise<void>,
|
||||
success: string
|
||||
): Promise<void> => {
|
||||
const runActionWithRefresh = async (label: string, fn: () => Promise<void>, success: string): Promise<void> => {
|
||||
if (busy) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -469,9 +447,7 @@ export async function runTui(config: AppConfig, workspaceId: string): Promise<vo
|
|||
process.once("SIGINT", handleSignal);
|
||||
process.once("SIGTERM", handleSignal);
|
||||
|
||||
const keyInput = (renderer.keyInput ?? renderer.keyHandler) as
|
||||
| { on: (name: string, cb: (event: KeyEventLike) => void) => void }
|
||||
| undefined;
|
||||
const keyInput = (renderer.keyInput ?? renderer.keyHandler) as { on: (name: string, cb: (event: KeyEventLike) => void) => void } | undefined;
|
||||
|
||||
if (!keyInput) {
|
||||
clearInterval(timer);
|
||||
|
|
@ -577,11 +553,7 @@ export async function runTui(config: AppConfig, workspaceId: string): Promise<vo
|
|||
if (!row) {
|
||||
return;
|
||||
}
|
||||
void runActionWithRefresh(
|
||||
`archiving ${row.handoffId}`,
|
||||
async () => client.runAction(workspaceId, row.handoffId, "archive"),
|
||||
`archived ${row.handoffId}`
|
||||
);
|
||||
void runActionWithRefresh(`archiving ${row.handoffId}`, async () => client.runAction(workspaceId, row.handoffId, "archive"), `archived ${row.handoffId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -590,11 +562,7 @@ export async function runTui(config: AppConfig, workspaceId: string): Promise<vo
|
|||
if (!row) {
|
||||
return;
|
||||
}
|
||||
void runActionWithRefresh(
|
||||
`syncing ${row.handoffId}`,
|
||||
async () => client.runAction(workspaceId, row.handoffId, "sync"),
|
||||
`synced ${row.handoffId}`
|
||||
);
|
||||
void runActionWithRefresh(`syncing ${row.handoffId}`, async () => client.runAction(workspaceId, row.handoffId, "sync"), `synced ${row.handoffId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -609,7 +577,7 @@ export async function runTui(config: AppConfig, workspaceId: string): Promise<vo
|
|||
await client.runAction(workspaceId, row.handoffId, "merge");
|
||||
await client.runAction(workspaceId, row.handoffId, "archive");
|
||||
},
|
||||
`merged+archived ${row.handoffId}`
|
||||
`merged+archived ${row.handoffId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import type { ChildProcess } from "node:child_process";
|
|||
|
||||
const { spawnMock, execFileSyncMock } = vi.hoisted(() => ({
|
||||
spawnMock: vi.fn(),
|
||||
execFileSyncMock: vi.fn()
|
||||
execFileSyncMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:child_process", async () => {
|
||||
|
|
@ -15,7 +15,7 @@ vi.mock("node:child_process", async () => {
|
|||
return {
|
||||
...actual,
|
||||
spawn: spawnMock,
|
||||
execFileSync: execFileSyncMock
|
||||
execFileSync: execFileSyncMock,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -37,16 +37,16 @@ function healthyMetadataResponse(): { ok: boolean; json: () => Promise<unknown>
|
|||
json: async () => ({
|
||||
runtime: "rivetkit",
|
||||
actorNames: {
|
||||
workspace: {}
|
||||
}
|
||||
})
|
||||
workspace: {},
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function unhealthyMetadataResponse(): { ok: boolean; json: () => Promise<unknown> } {
|
||||
return {
|
||||
ok: false,
|
||||
json: async () => ({})
|
||||
json: async () => ({}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -66,11 +66,11 @@ describe("backend manager", () => {
|
|||
opencode_poll_interval: 2,
|
||||
github_poll_interval: 30,
|
||||
backup_interval_secs: 3600,
|
||||
backup_retention_days: 7
|
||||
backup_retention_days: 7,
|
||||
},
|
||||
providers: {
|
||||
daytona: { image: "ubuntu:24.04" }
|
||||
}
|
||||
daytona: { image: "ubuntu:24.04" },
|
||||
},
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -116,7 +116,7 @@ describe("backend manager", () => {
|
|||
|
||||
const fakeChild = Object.assign(new EventEmitter(), {
|
||||
pid: process.pid,
|
||||
unref: vi.fn()
|
||||
unref: vi.fn(),
|
||||
}) as unknown as ChildProcess;
|
||||
spawnMock.mockReturnValue(fakeChild);
|
||||
|
||||
|
|
@ -125,14 +125,8 @@ describe("backend manager", () => {
|
|||
expect(spawnMock).toHaveBeenCalledTimes(1);
|
||||
const launchCommand = spawnMock.mock.calls[0]?.[0];
|
||||
const launchArgs = spawnMock.mock.calls[0]?.[1] as string[] | undefined;
|
||||
expect(
|
||||
launchCommand === "pnpm" ||
|
||||
launchCommand === "bun" ||
|
||||
(typeof launchCommand === "string" && launchCommand.endsWith("/bun"))
|
||||
).toBe(true);
|
||||
expect(launchArgs).toEqual(
|
||||
expect.arrayContaining(["start", "--host", config.backend.host, "--port", String(config.backend.port)])
|
||||
);
|
||||
expect(launchCommand === "pnpm" || launchCommand === "bun" || (typeof launchCommand === "string" && launchCommand.endsWith("/bun"))).toBe(true);
|
||||
expect(launchArgs).toEqual(expect.arrayContaining(["start", "--host", config.backend.host, "--port", String(config.backend.port)]));
|
||||
if (launchCommand === "pnpm") {
|
||||
expect(launchArgs).toEqual(expect.arrayContaining(["exec", "bun", "src/index.ts"]));
|
||||
}
|
||||
|
|
@ -148,9 +142,7 @@ describe("backend manager", () => {
|
|||
mkdirSync(stateDir, { recursive: true });
|
||||
writeFileSync(versionPath, "test-build", "utf8");
|
||||
|
||||
const fetchMock = vi
|
||||
.fn<() => Promise<{ ok: boolean; json: () => Promise<unknown> }>>()
|
||||
.mockResolvedValue(healthyMetadataResponse());
|
||||
const fetchMock = vi.fn<() => Promise<{ ok: boolean; json: () => Promise<unknown> }>>().mockResolvedValue(healthyMetadataResponse());
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
await ensureBackendRunning(config);
|
||||
|
|
|
|||
|
|
@ -23,4 +23,3 @@ with more detail
|
|||
expect(value).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -29,11 +29,11 @@ describe("resolveTuiTheme", () => {
|
|||
opencode_poll_interval: 2,
|
||||
github_poll_interval: 30,
|
||||
backup_interval_secs: 3600,
|
||||
backup_retention_days: 7
|
||||
backup_retention_days: 7,
|
||||
},
|
||||
providers: {
|
||||
daytona: { image: "ubuntu:24.04" }
|
||||
}
|
||||
daytona: { image: "ubuntu:24.04" },
|
||||
},
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -64,11 +64,7 @@ describe("resolveTuiTheme", () => {
|
|||
withEnv("XDG_STATE_HOME", stateHome);
|
||||
withEnv("XDG_CONFIG_HOME", configHome);
|
||||
mkdirSync(join(stateHome, "opencode"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(stateHome, "opencode", "kv.json"),
|
||||
JSON.stringify({ theme: "gruvbox", theme_mode: "dark" }),
|
||||
"utf8"
|
||||
);
|
||||
writeFileSync(join(stateHome, "opencode", "kv.json"), JSON.stringify({ theme: "gruvbox", theme_mode: "dark" }), "utf8");
|
||||
|
||||
const resolution = resolveTuiTheme(baseConfig, tempDir);
|
||||
|
||||
|
|
@ -85,11 +81,7 @@ describe("resolveTuiTheme", () => {
|
|||
withEnv("XDG_STATE_HOME", stateHome);
|
||||
withEnv("XDG_CONFIG_HOME", configHome);
|
||||
mkdirSync(join(stateHome, "opencode"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(stateHome, "opencode", "kv.json"),
|
||||
JSON.stringify({ theme: "orng", theme_mode: "dark" }),
|
||||
"utf8"
|
||||
);
|
||||
writeFileSync(join(stateHome, "opencode", "kv.json"), JSON.stringify({ theme: "orng", theme_mode: "dark" }), "utf8");
|
||||
|
||||
const resolution = resolveTuiTheme(baseConfig, tempDir);
|
||||
|
||||
|
|
|
|||
|
|
@ -23,8 +23,8 @@ const sample: HandoffRecord = {
|
|||
switchTarget: "daytona://sandbox-1",
|
||||
cwd: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
updatedAt: 1,
|
||||
},
|
||||
],
|
||||
agentType: null,
|
||||
prSubmitted: false,
|
||||
|
|
@ -38,7 +38,7 @@ const sample: HandoffRecord = {
|
|||
hasUnpushed: null,
|
||||
parentBranch: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
updatedAt: 1,
|
||||
};
|
||||
|
||||
describe("formatRows", () => {
|
||||
|
|
@ -60,7 +60,7 @@ describe("formatRows", () => {
|
|||
it("pins footer to the last terminal row", () => {
|
||||
const output = formatRows([sample], 0, "default", "ready", "", false, {
|
||||
width: 80,
|
||||
height: 12
|
||||
height: 12,
|
||||
});
|
||||
const lines = output.split("\n");
|
||||
expect(lines).toHaveLength(12);
|
||||
|
|
@ -83,8 +83,8 @@ describe("search", () => {
|
|||
handoffId: "handoff-2",
|
||||
branchName: "docs/update-intro",
|
||||
title: "Docs Intro Refresh",
|
||||
status: "idle"
|
||||
}
|
||||
status: "idle",
|
||||
},
|
||||
];
|
||||
expect(filterHandoffs(rows, "doc")).toHaveLength(1);
|
||||
expect(filterHandoffs(rows, "h2")).toHaveLength(1);
|
||||
|
|
|
|||
|
|
@ -15,11 +15,11 @@ describe("cli workspace resolution", () => {
|
|||
opencode_poll_interval: 2,
|
||||
github_poll_interval: 30,
|
||||
backup_interval_secs: 3600,
|
||||
backup_retention_days: 7
|
||||
backup_retention_days: 7,
|
||||
},
|
||||
providers: {
|
||||
daytona: { image: "ubuntu:24.04" }
|
||||
}
|
||||
daytona: { image: "ubuntu:24.04" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolveWorkspace(undefined, config)).toBe("team");
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ function sourceId(): string {
|
|||
try {
|
||||
const raw = execSync("git rev-parse --short HEAD", {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"]
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
if (raw.length > 0) {
|
||||
return raw;
|
||||
|
|
@ -48,7 +48,6 @@ export default defineConfig({
|
|||
format: ["esm"],
|
||||
dts: true,
|
||||
define: {
|
||||
__HF_BUILD_ID__: JSON.stringify(buildId)
|
||||
}
|
||||
__HF_BUILD_ID__: JSON.stringify(buildId),
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ import type {
|
|||
RepoStackActionInput,
|
||||
RepoStackActionResult,
|
||||
RepoRecord,
|
||||
SwitchResult
|
||||
SwitchResult,
|
||||
} from "@openhandoff/shared";
|
||||
import { sandboxInstanceKey, workspaceKey } from "./keys.js";
|
||||
|
||||
|
|
@ -94,7 +94,11 @@ interface WorkspaceHandle {
|
|||
}
|
||||
|
||||
interface SandboxInstanceHandle {
|
||||
createSession(input: { prompt: string; cwd?: string; agent?: AgentType | "opencode" }): Promise<{ id: string | null; status: "running" | "idle" | "error"; error?: string }>;
|
||||
createSession(input: {
|
||||
prompt: string;
|
||||
cwd?: string;
|
||||
agent?: AgentType | "opencode";
|
||||
}): Promise<{ id: string | null; status: "running" | "idle" | "error"; error?: string }>;
|
||||
listSessions(input?: { cursor?: string; limit?: number }): Promise<{ items: SandboxSessionRecord[]; nextCursor?: string }>;
|
||||
listSessionEvents(input: { sessionId: string; cursor?: string; limit?: number }): Promise<{ items: SandboxSessionEventRecord[]; nextCursor?: string }>;
|
||||
sendPrompt(input: { sessionId: string; prompt: string; notification?: boolean }): Promise<void>;
|
||||
|
|
@ -148,13 +152,13 @@ export interface BackendClient {
|
|||
workspaceId: string,
|
||||
providerId: ProviderId,
|
||||
sandboxId: string,
|
||||
input?: { cursor?: string; limit?: number }
|
||||
input?: { cursor?: string; limit?: number },
|
||||
): Promise<{ items: SandboxSessionRecord[]; nextCursor?: string }>;
|
||||
listSandboxSessionEvents(
|
||||
workspaceId: string,
|
||||
providerId: ProviderId,
|
||||
sandboxId: string,
|
||||
input: { sessionId: string; cursor?: string; limit?: number }
|
||||
input: { sessionId: string; cursor?: string; limit?: number },
|
||||
): Promise<{ items: SandboxSessionEventRecord[]; nextCursor?: string }>;
|
||||
sendSandboxPrompt(input: {
|
||||
workspaceId: string;
|
||||
|
|
@ -168,31 +172,22 @@ export interface BackendClient {
|
|||
workspaceId: string,
|
||||
providerId: ProviderId,
|
||||
sandboxId: string,
|
||||
sessionId: string
|
||||
sessionId: string,
|
||||
): Promise<{ id: string; status: "running" | "idle" | "error" }>;
|
||||
sandboxProviderState(
|
||||
workspaceId: string,
|
||||
providerId: ProviderId,
|
||||
sandboxId: string
|
||||
sandboxId: string,
|
||||
): Promise<{ providerId: ProviderId; sandboxId: string; state: string; at: number }>;
|
||||
getWorkbench(workspaceId: string): Promise<HandoffWorkbenchSnapshot>;
|
||||
subscribeWorkbench(workspaceId: string, listener: () => void): () => void;
|
||||
createWorkbenchHandoff(
|
||||
workspaceId: string,
|
||||
input: HandoffWorkbenchCreateHandoffInput
|
||||
): Promise<HandoffWorkbenchCreateHandoffResponse>;
|
||||
createWorkbenchHandoff(workspaceId: string, input: HandoffWorkbenchCreateHandoffInput): Promise<HandoffWorkbenchCreateHandoffResponse>;
|
||||
markWorkbenchUnread(workspaceId: string, input: HandoffWorkbenchSelectInput): Promise<void>;
|
||||
renameWorkbenchHandoff(workspaceId: string, input: HandoffWorkbenchRenameInput): Promise<void>;
|
||||
renameWorkbenchBranch(workspaceId: string, input: HandoffWorkbenchRenameInput): Promise<void>;
|
||||
createWorkbenchSession(
|
||||
workspaceId: string,
|
||||
input: HandoffWorkbenchSelectInput & { model?: string }
|
||||
): Promise<{ tabId: string }>;
|
||||
createWorkbenchSession(workspaceId: string, input: HandoffWorkbenchSelectInput & { model?: string }): Promise<{ tabId: string }>;
|
||||
renameWorkbenchSession(workspaceId: string, input: HandoffWorkbenchRenameSessionInput): Promise<void>;
|
||||
setWorkbenchSessionUnread(
|
||||
workspaceId: string,
|
||||
input: HandoffWorkbenchSetSessionUnreadInput
|
||||
): Promise<void>;
|
||||
setWorkbenchSessionUnread(workspaceId: string, input: HandoffWorkbenchSetSessionUnreadInput): Promise<void>;
|
||||
updateWorkbenchDraft(workspaceId: string, input: HandoffWorkbenchUpdateDraftInput): Promise<void>;
|
||||
changeWorkbenchModel(workspaceId: string, input: HandoffWorkbenchChangeModelInput): Promise<void>;
|
||||
sendWorkbenchMessage(workspaceId: string, input: HandoffWorkbenchSendMessageInput): Promise<void>;
|
||||
|
|
@ -211,7 +206,7 @@ export function rivetEndpoint(config: AppConfig): string {
|
|||
export function createBackendClientFromConfig(config: AppConfig): BackendClient {
|
||||
return createBackendClient({
|
||||
endpoint: rivetEndpoint(config),
|
||||
defaultWorkspaceId: config.workspace.default
|
||||
defaultWorkspaceId: config.workspace.default,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -250,7 +245,7 @@ async function fetchJsonWithTimeout(url: string, timeoutMs: number): Promise<unk
|
|||
async function fetchMetadataWithRetry(
|
||||
endpoint: string,
|
||||
namespace: string | undefined,
|
||||
opts: { timeoutMs: number; requestTimeoutMs: number }
|
||||
opts: { timeoutMs: number; requestTimeoutMs: number },
|
||||
): Promise<RivetMetadataResponse> {
|
||||
const base = new URL(endpoint);
|
||||
base.pathname = base.pathname.replace(/\/$/, "") + "/metadata";
|
||||
|
|
@ -268,10 +263,7 @@ async function fetchMetadataWithRetry(
|
|||
const data = json as Record<string, unknown>;
|
||||
return {
|
||||
runtime: typeof data.runtime === "string" ? data.runtime : undefined,
|
||||
actorNames:
|
||||
data.actorNames && typeof data.actorNames === "object"
|
||||
? (data.actorNames as Record<string, unknown>)
|
||||
: undefined,
|
||||
actorNames: data.actorNames && typeof data.actorNames === "object" ? (data.actorNames as Record<string, unknown>) : undefined,
|
||||
clientEndpoint: typeof data.clientEndpoint === "string" ? data.clientEndpoint : undefined,
|
||||
clientNamespace: typeof data.clientNamespace === "string" ? data.clientNamespace : undefined,
|
||||
clientToken: typeof data.clientToken === "string" ? data.clientToken : undefined,
|
||||
|
|
@ -286,11 +278,7 @@ async function fetchMetadataWithRetry(
|
|||
}
|
||||
}
|
||||
|
||||
export async function readBackendMetadata(input: {
|
||||
endpoint: string;
|
||||
namespace?: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<BackendMetadata> {
|
||||
export async function readBackendMetadata(input: { endpoint: string; namespace?: string; timeoutMs?: number }): Promise<BackendMetadata> {
|
||||
const base = new URL(input.endpoint);
|
||||
base.pathname = base.pathname.replace(/\/$/, "") + "/metadata";
|
||||
if (input.namespace) {
|
||||
|
|
@ -304,21 +292,14 @@ export async function readBackendMetadata(input: {
|
|||
const data = json as Record<string, unknown>;
|
||||
return {
|
||||
runtime: typeof data.runtime === "string" ? data.runtime : undefined,
|
||||
actorNames:
|
||||
data.actorNames && typeof data.actorNames === "object"
|
||||
? (data.actorNames as Record<string, unknown>)
|
||||
: undefined,
|
||||
actorNames: data.actorNames && typeof data.actorNames === "object" ? (data.actorNames as Record<string, unknown>) : undefined,
|
||||
clientEndpoint: typeof data.clientEndpoint === "string" ? data.clientEndpoint : undefined,
|
||||
clientNamespace: typeof data.clientNamespace === "string" ? data.clientNamespace : undefined,
|
||||
clientToken: typeof data.clientToken === "string" ? data.clientToken : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function checkBackendHealth(input: {
|
||||
endpoint: string;
|
||||
namespace?: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<boolean> {
|
||||
export async function checkBackendHealth(input: { endpoint: string; namespace?: string; timeoutMs?: number }): Promise<boolean> {
|
||||
try {
|
||||
const metadata = await readBackendMetadata(input);
|
||||
return metadata.runtime === "rivetkit" && Boolean(metadata.actorNames);
|
||||
|
|
@ -327,11 +308,7 @@ export async function checkBackendHealth(input: {
|
|||
}
|
||||
}
|
||||
|
||||
async function probeMetadataEndpoint(
|
||||
endpoint: string,
|
||||
namespace: string | undefined,
|
||||
timeoutMs: number
|
||||
): Promise<boolean> {
|
||||
async function probeMetadataEndpoint(endpoint: string, namespace: string | undefined, timeoutMs: number): Promise<boolean> {
|
||||
try {
|
||||
const base = new URL(endpoint);
|
||||
base.pathname = base.pathname.replace(/\/$/, "") + "/metadata";
|
||||
|
|
@ -370,19 +347,15 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
|
|||
const initialNamespace = undefined;
|
||||
const metadata = await fetchMetadataWithRetry(options.endpoint, initialNamespace, {
|
||||
timeoutMs: 30_000,
|
||||
requestTimeoutMs: 8_000
|
||||
requestTimeoutMs: 8_000,
|
||||
});
|
||||
|
||||
// Candidate endpoint: manager endpoint if provided, otherwise stick to the configured endpoint.
|
||||
const candidateEndpoint = metadata.clientEndpoint
|
||||
? rewriteLoopbackClientEndpoint(metadata.clientEndpoint, configuredOrigin)
|
||||
: options.endpoint;
|
||||
const candidateEndpoint = metadata.clientEndpoint ? rewriteLoopbackClientEndpoint(metadata.clientEndpoint, configuredOrigin) : options.endpoint;
|
||||
|
||||
// If the manager port isn't reachable from this client (common behind reverse proxies),
|
||||
// fall back to the configured serverless endpoint to avoid hanging requests.
|
||||
const shouldUseCandidate = metadata.clientEndpoint
|
||||
? await probeMetadataEndpoint(candidateEndpoint, metadata.clientNamespace, 1_500)
|
||||
: true;
|
||||
const shouldUseCandidate = metadata.clientEndpoint ? await probeMetadataEndpoint(candidateEndpoint, metadata.clientNamespace, 1_500) : true;
|
||||
const resolvedEndpoint = shouldUseCandidate ? candidateEndpoint : options.endpoint;
|
||||
|
||||
return createClient({
|
||||
|
|
@ -399,14 +372,10 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
|
|||
|
||||
const workspace = async (workspaceId: string): Promise<WorkspaceHandle> =>
|
||||
(await getClient()).workspace.getOrCreate(workspaceKey(workspaceId), {
|
||||
createWithInput: workspaceId
|
||||
createWithInput: workspaceId,
|
||||
});
|
||||
|
||||
const sandboxByKey = async (
|
||||
workspaceId: string,
|
||||
providerId: ProviderId,
|
||||
sandboxId: string
|
||||
): Promise<SandboxInstanceHandle> => {
|
||||
const sandboxByKey = async (workspaceId: string, providerId: ProviderId, sandboxId: string): Promise<SandboxInstanceHandle> => {
|
||||
const client = await getClient();
|
||||
return (client as any).sandboxInstance.get(sandboxInstanceKey(workspaceId, providerId, sandboxId));
|
||||
};
|
||||
|
|
@ -416,11 +385,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
|
|||
return message.includes("Actor not found");
|
||||
}
|
||||
|
||||
const sandboxByActorIdFromHandoff = async (
|
||||
workspaceId: string,
|
||||
providerId: ProviderId,
|
||||
sandboxId: string
|
||||
): Promise<SandboxInstanceHandle | null> => {
|
||||
const sandboxByActorIdFromHandoff = async (workspaceId: string, providerId: ProviderId, sandboxId: string): Promise<SandboxInstanceHandle | null> => {
|
||||
const ws = await workspace(workspaceId);
|
||||
const rows = await ws.listHandoffs({ workspaceId });
|
||||
const candidates = [...rows].sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
|
|
@ -431,12 +396,13 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
|
|||
if (detail.providerId !== providerId) {
|
||||
continue;
|
||||
}
|
||||
const sandbox = detail.sandboxes.find((sb) =>
|
||||
sb.sandboxId === sandboxId &&
|
||||
sb.providerId === providerId &&
|
||||
typeof (sb as any).sandboxActorId === "string" &&
|
||||
(sb as any).sandboxActorId.length > 0
|
||||
) as ({ sandboxActorId?: string } | undefined);
|
||||
const sandbox = detail.sandboxes.find(
|
||||
(sb) =>
|
||||
sb.sandboxId === sandboxId &&
|
||||
sb.providerId === providerId &&
|
||||
typeof (sb as any).sandboxActorId === "string" &&
|
||||
(sb as any).sandboxActorId.length > 0,
|
||||
) as { sandboxActorId?: string } | undefined;
|
||||
if (sandbox?.sandboxActorId) {
|
||||
const client = await getClient();
|
||||
return (client as any).sandboxInstance.getForId(sandbox.sandboxActorId);
|
||||
|
|
@ -457,7 +423,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
|
|||
workspaceId: string,
|
||||
providerId: ProviderId,
|
||||
sandboxId: string,
|
||||
run: (handle: SandboxInstanceHandle) => Promise<T>
|
||||
run: (handle: SandboxInstanceHandle) => Promise<T>,
|
||||
): Promise<T> => {
|
||||
const handle = await sandboxByKey(workspaceId, providerId, sandboxId);
|
||||
try {
|
||||
|
|
@ -553,7 +519,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
|
|||
async getHandoff(workspaceId: string, handoffId: string): Promise<HandoffRecord> {
|
||||
return (await workspace(workspaceId)).getHandoff({
|
||||
workspaceId,
|
||||
handoffId
|
||||
handoffId,
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -569,7 +535,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
|
|||
return (await workspace(workspaceId)).attachHandoff({
|
||||
workspaceId,
|
||||
handoffId,
|
||||
reason: "cli.attach"
|
||||
reason: "cli.attach",
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -578,7 +544,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
|
|||
await (await workspace(workspaceId)).pushHandoff({
|
||||
workspaceId,
|
||||
handoffId,
|
||||
reason: "cli.push"
|
||||
reason: "cli.push",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -586,7 +552,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
|
|||
await (await workspace(workspaceId)).syncHandoff({
|
||||
workspaceId,
|
||||
handoffId,
|
||||
reason: "cli.sync"
|
||||
reason: "cli.sync",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -594,7 +560,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
|
|||
await (await workspace(workspaceId)).mergeHandoff({
|
||||
workspaceId,
|
||||
handoffId,
|
||||
reason: "cli.merge"
|
||||
reason: "cli.merge",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -602,14 +568,14 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
|
|||
await (await workspace(workspaceId)).archiveHandoff({
|
||||
workspaceId,
|
||||
handoffId,
|
||||
reason: "cli.archive"
|
||||
reason: "cli.archive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
await (await workspace(workspaceId)).killHandoff({
|
||||
workspaceId,
|
||||
handoffId,
|
||||
reason: "cli.kill"
|
||||
reason: "cli.kill",
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -621,23 +587,19 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
|
|||
cwd?: string;
|
||||
agent?: AgentType | "opencode";
|
||||
}): Promise<{ id: string; status: "running" | "idle" | "error" }> {
|
||||
const created = await withSandboxHandle(
|
||||
input.workspaceId,
|
||||
input.providerId,
|
||||
input.sandboxId,
|
||||
async (handle) =>
|
||||
handle.createSession({
|
||||
prompt: input.prompt,
|
||||
cwd: input.cwd,
|
||||
agent: input.agent
|
||||
})
|
||||
const created = await withSandboxHandle(input.workspaceId, input.providerId, input.sandboxId, async (handle) =>
|
||||
handle.createSession({
|
||||
prompt: input.prompt,
|
||||
cwd: input.cwd,
|
||||
agent: input.agent,
|
||||
}),
|
||||
);
|
||||
if (!created.id) {
|
||||
throw new Error(created.error ?? "sandbox session creation failed");
|
||||
}
|
||||
return {
|
||||
id: created.id,
|
||||
status: created.status
|
||||
status: created.status,
|
||||
};
|
||||
},
|
||||
|
||||
|
|
@ -645,28 +607,18 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
|
|||
workspaceId: string,
|
||||
providerId: ProviderId,
|
||||
sandboxId: string,
|
||||
input?: { cursor?: string; limit?: number }
|
||||
input?: { cursor?: string; limit?: number },
|
||||
): Promise<{ items: SandboxSessionRecord[]; nextCursor?: string }> {
|
||||
return await withSandboxHandle(
|
||||
workspaceId,
|
||||
providerId,
|
||||
sandboxId,
|
||||
async (handle) => handle.listSessions(input ?? {})
|
||||
);
|
||||
return await withSandboxHandle(workspaceId, providerId, sandboxId, async (handle) => handle.listSessions(input ?? {}));
|
||||
},
|
||||
|
||||
async listSandboxSessionEvents(
|
||||
workspaceId: string,
|
||||
providerId: ProviderId,
|
||||
sandboxId: string,
|
||||
input: { sessionId: string; cursor?: string; limit?: number }
|
||||
input: { sessionId: string; cursor?: string; limit?: number },
|
||||
): Promise<{ items: SandboxSessionEventRecord[]; nextCursor?: string }> {
|
||||
return await withSandboxHandle(
|
||||
workspaceId,
|
||||
providerId,
|
||||
sandboxId,
|
||||
async (handle) => handle.listSessionEvents(input)
|
||||
);
|
||||
return await withSandboxHandle(workspaceId, providerId, sandboxId, async (handle) => handle.listSessionEvents(input));
|
||||
},
|
||||
|
||||
async sendSandboxPrompt(input: {
|
||||
|
|
@ -677,16 +629,12 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
|
|||
prompt: string;
|
||||
notification?: boolean;
|
||||
}): Promise<void> {
|
||||
await withSandboxHandle(
|
||||
input.workspaceId,
|
||||
input.providerId,
|
||||
input.sandboxId,
|
||||
async (handle) =>
|
||||
handle.sendPrompt({
|
||||
sessionId: input.sessionId,
|
||||
prompt: input.prompt,
|
||||
notification: input.notification
|
||||
})
|
||||
await withSandboxHandle(input.workspaceId, input.providerId, input.sandboxId, async (handle) =>
|
||||
handle.sendPrompt({
|
||||
sessionId: input.sessionId,
|
||||
prompt: input.prompt,
|
||||
notification: input.notification,
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
|
|
@ -694,27 +642,17 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
|
|||
workspaceId: string,
|
||||
providerId: ProviderId,
|
||||
sandboxId: string,
|
||||
sessionId: string
|
||||
sessionId: string,
|
||||
): Promise<{ id: string; status: "running" | "idle" | "error" }> {
|
||||
return await withSandboxHandle(
|
||||
workspaceId,
|
||||
providerId,
|
||||
sandboxId,
|
||||
async (handle) => handle.sessionStatus({ sessionId })
|
||||
);
|
||||
return await withSandboxHandle(workspaceId, providerId, sandboxId, async (handle) => handle.sessionStatus({ sessionId }));
|
||||
},
|
||||
|
||||
async sandboxProviderState(
|
||||
workspaceId: string,
|
||||
providerId: ProviderId,
|
||||
sandboxId: string
|
||||
sandboxId: string,
|
||||
): Promise<{ providerId: ProviderId; sandboxId: string; state: string; at: number }> {
|
||||
return await withSandboxHandle(
|
||||
workspaceId,
|
||||
providerId,
|
||||
sandboxId,
|
||||
async (handle) => handle.providerState()
|
||||
);
|
||||
return await withSandboxHandle(workspaceId, providerId, sandboxId, async (handle) => handle.providerState());
|
||||
},
|
||||
|
||||
async getWorkbench(workspaceId: string): Promise<HandoffWorkbenchSnapshot> {
|
||||
|
|
@ -725,10 +663,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
|
|||
return subscribeWorkbench(workspaceId, listener);
|
||||
},
|
||||
|
||||
async createWorkbenchHandoff(
|
||||
workspaceId: string,
|
||||
input: HandoffWorkbenchCreateHandoffInput
|
||||
): Promise<HandoffWorkbenchCreateHandoffResponse> {
|
||||
async createWorkbenchHandoff(workspaceId: string, input: HandoffWorkbenchCreateHandoffInput): Promise<HandoffWorkbenchCreateHandoffResponse> {
|
||||
return (await workspace(workspaceId)).createWorkbenchHandoff(input);
|
||||
},
|
||||
|
||||
|
|
@ -744,45 +679,27 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
|
|||
await (await workspace(workspaceId)).renameWorkbenchBranch(input);
|
||||
},
|
||||
|
||||
async createWorkbenchSession(
|
||||
workspaceId: string,
|
||||
input: HandoffWorkbenchSelectInput & { model?: string }
|
||||
): Promise<{ tabId: string }> {
|
||||
async createWorkbenchSession(workspaceId: string, input: HandoffWorkbenchSelectInput & { model?: string }): Promise<{ tabId: string }> {
|
||||
return await (await workspace(workspaceId)).createWorkbenchSession(input);
|
||||
},
|
||||
|
||||
async renameWorkbenchSession(
|
||||
workspaceId: string,
|
||||
input: HandoffWorkbenchRenameSessionInput
|
||||
): Promise<void> {
|
||||
async renameWorkbenchSession(workspaceId: string, input: HandoffWorkbenchRenameSessionInput): Promise<void> {
|
||||
await (await workspace(workspaceId)).renameWorkbenchSession(input);
|
||||
},
|
||||
|
||||
async setWorkbenchSessionUnread(
|
||||
workspaceId: string,
|
||||
input: HandoffWorkbenchSetSessionUnreadInput
|
||||
): Promise<void> {
|
||||
async setWorkbenchSessionUnread(workspaceId: string, input: HandoffWorkbenchSetSessionUnreadInput): Promise<void> {
|
||||
await (await workspace(workspaceId)).setWorkbenchSessionUnread(input);
|
||||
},
|
||||
|
||||
async updateWorkbenchDraft(
|
||||
workspaceId: string,
|
||||
input: HandoffWorkbenchUpdateDraftInput
|
||||
): Promise<void> {
|
||||
async updateWorkbenchDraft(workspaceId: string, input: HandoffWorkbenchUpdateDraftInput): Promise<void> {
|
||||
await (await workspace(workspaceId)).updateWorkbenchDraft(input);
|
||||
},
|
||||
|
||||
async changeWorkbenchModel(
|
||||
workspaceId: string,
|
||||
input: HandoffWorkbenchChangeModelInput
|
||||
): Promise<void> {
|
||||
async changeWorkbenchModel(workspaceId: string, input: HandoffWorkbenchChangeModelInput): Promise<void> {
|
||||
await (await workspace(workspaceId)).changeWorkbenchModel(input);
|
||||
},
|
||||
|
||||
async sendWorkbenchMessage(
|
||||
workspaceId: string,
|
||||
input: HandoffWorkbenchSendMessageInput
|
||||
): Promise<void> {
|
||||
async sendWorkbenchMessage(workspaceId: string, input: HandoffWorkbenchSendMessageInput): Promise<void> {
|
||||
await (await workspace(workspaceId)).sendWorkbenchMessage(input);
|
||||
},
|
||||
|
||||
|
|
@ -809,13 +726,13 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
|
|||
}
|
||||
|
||||
await (await workspace(workspaceId)).useWorkspace({
|
||||
workspaceId
|
||||
workspaceId,
|
||||
});
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
async useWorkspace(workspaceId: string): Promise<{ workspaceId: string }> {
|
||||
return (await workspace(workspaceId)).useWorkspace({ workspaceId });
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,11 +12,7 @@ export function handoffKey(workspaceId: string, repoId: string, handoffId: strin
|
|||
return ["ws", workspaceId, "project", repoId, "handoff", handoffId];
|
||||
}
|
||||
|
||||
export function sandboxInstanceKey(
|
||||
workspaceId: string,
|
||||
providerId: string,
|
||||
sandboxId: string
|
||||
): ActorKey {
|
||||
export function sandboxInstanceKey(workspaceId: string, providerId: string, sandboxId: string): ActorKey {
|
||||
return ["ws", workspaceId, "provider", providerId, "sandbox", sandboxId];
|
||||
}
|
||||
|
||||
|
|
@ -32,13 +28,7 @@ export function projectBranchSyncKey(workspaceId: string, repoId: string): Actor
|
|||
return ["ws", workspaceId, "project", repoId, "branch-sync"];
|
||||
}
|
||||
|
||||
export function handoffStatusSyncKey(
|
||||
workspaceId: string,
|
||||
repoId: string,
|
||||
handoffId: string,
|
||||
sandboxId: string,
|
||||
sessionId: string
|
||||
): ActorKey {
|
||||
export function handoffStatusSyncKey(workspaceId: string, repoId: string, handoffId: string, sandboxId: 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];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,7 +84,9 @@ class MockWorkbenchStore implements HandoffWorkbenchClient {
|
|||
id: tabId,
|
||||
sessionId: tabId,
|
||||
sessionName: "Session 1",
|
||||
agent: providerAgent(MODEL_GROUPS.find((group) => group.models.some((model) => model.id === (input.model ?? "claude-sonnet-4")))?.provider ?? "Claude"),
|
||||
agent: providerAgent(
|
||||
MODEL_GROUPS.find((group) => group.models.some((model) => model.id === (input.model ?? "claude-sonnet-4")))?.provider ?? "Claude",
|
||||
),
|
||||
model: input.model ?? "claude-sonnet-4",
|
||||
status: "idle",
|
||||
thinkingSinceMs: null,
|
||||
|
|
@ -311,9 +313,7 @@ class MockWorkbenchStore implements HandoffWorkbenchClient {
|
|||
async setSessionUnread(input: HandoffWorkbenchSetSessionUnreadInput): Promise<void> {
|
||||
this.updateHandoff(input.handoffId, (currentHandoff) => ({
|
||||
...currentHandoff,
|
||||
tabs: currentHandoff.tabs.map((candidate) =>
|
||||
candidate.id === input.tabId ? { ...candidate, unread: input.unread } : candidate,
|
||||
),
|
||||
tabs: currentHandoff.tabs.map((candidate) => (candidate.id === input.tabId ? { ...candidate, unread: input.unread } : candidate)),
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
@ -324,9 +324,7 @@ class MockWorkbenchStore implements HandoffWorkbenchClient {
|
|||
}
|
||||
this.updateHandoff(input.handoffId, (currentHandoff) => ({
|
||||
...currentHandoff,
|
||||
tabs: currentHandoff.tabs.map((candidate) =>
|
||||
candidate.id === input.tabId ? { ...candidate, sessionName: title } : candidate,
|
||||
),
|
||||
tabs: currentHandoff.tabs.map((candidate) => (candidate.id === input.tabId ? { ...candidate, sessionName: title } : candidate)),
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -192,8 +192,6 @@ class RemoteWorkbenchStore implements HandoffWorkbenchClient {
|
|||
}
|
||||
}
|
||||
|
||||
export function createRemoteWorkbenchClient(
|
||||
options: RemoteWorkbenchClientOptions,
|
||||
): HandoffWorkbenchClient {
|
||||
export function createRemoteWorkbenchClient(options: RemoteWorkbenchClientOptions): HandoffWorkbenchClient {
|
||||
return new RemoteWorkbenchStore(options);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,6 @@
|
|||
import type { HandoffRecord, HandoffStatus } from "@openhandoff/shared";
|
||||
|
||||
export const HANDOFF_STATUS_GROUPS = [
|
||||
"queued",
|
||||
"running",
|
||||
"idle",
|
||||
"archived",
|
||||
"killed",
|
||||
"error"
|
||||
] as const;
|
||||
export const HANDOFF_STATUS_GROUPS = ["queued", "running", "idle", "archived", "killed", "error"] as const;
|
||||
|
||||
export type HandoffStatusGroup = (typeof HANDOFF_STATUS_GROUPS)[number];
|
||||
|
||||
|
|
@ -27,7 +20,7 @@ const QUEUED_STATUSES = new Set<HandoffStatus>([
|
|||
"archive_release_sandbox",
|
||||
"archive_finalize",
|
||||
"kill_destroy_sandbox",
|
||||
"kill_finalize"
|
||||
"kill_finalize",
|
||||
]);
|
||||
|
||||
export function groupHandoffStatus(status: HandoffStatus): HandoffStatusGroup {
|
||||
|
|
@ -47,7 +40,7 @@ function emptyStatusCounts(): Record<HandoffStatusGroup, number> {
|
|||
idle: 0,
|
||||
archived: 0,
|
||||
killed: 0,
|
||||
error: 0
|
||||
error: 0,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -78,14 +71,7 @@ export function filterHandoffs(rows: HandoffRecord[], query: string): HandoffRec
|
|||
}
|
||||
|
||||
return rows.filter((row) => {
|
||||
const fields = [
|
||||
row.branchName ?? "",
|
||||
row.title ?? "",
|
||||
row.handoffId,
|
||||
row.task,
|
||||
row.prAuthor ?? "",
|
||||
row.reviewer ?? ""
|
||||
];
|
||||
const fields = [row.branchName ?? "", row.title ?? "", row.handoffId, row.task, row.prAuthor ?? "", row.reviewer ?? ""];
|
||||
return fields.some((field) => fuzzyMatch(field, q));
|
||||
});
|
||||
}
|
||||
|
|
@ -113,6 +99,6 @@ export function summarizeHandoffs(rows: HandoffRecord[]): HandoffSummary {
|
|||
return {
|
||||
total: rows.length,
|
||||
byStatus,
|
||||
byProvider
|
||||
byProvider,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,9 +45,7 @@ export interface HandoffWorkbenchClient {
|
|||
changeModel(input: HandoffWorkbenchChangeModelInput): Promise<void>;
|
||||
}
|
||||
|
||||
export function createHandoffWorkbenchClient(
|
||||
options: CreateHandoffWorkbenchClientOptions,
|
||||
): HandoffWorkbenchClient {
|
||||
export function createHandoffWorkbenchClient(options: CreateHandoffWorkbenchClientOptions): HandoffWorkbenchClient {
|
||||
if (options.mode === "mock") {
|
||||
return getSharedMockWorkbenchClient();
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue