Stabilize SDK mode integration test

This commit is contained in:
Nathan Flurry 2026-03-10 22:37:27 -07:00
parent 24e99ac5e7
commit ec8b6afea9
274 changed files with 5412 additions and 7893 deletions

View file

@ -1,10 +1,8 @@
{ {
"mcpServers": { "mcpServers": {
"everything": { "everything": {
"args": [ "args": ["@modelcontextprotocol/server-everything"],
"@modelcontextprotocol/server-everything"
],
"command": "npx" "command": "npx"
} }
} }
} }

View file

@ -1,131 +1,119 @@
{ {
"$schema": "https://mintlify.com/docs.json", "$schema": "https://mintlify.com/docs.json",
"theme": "willow", "theme": "willow",
"name": "Sandbox Agent SDK", "name": "Sandbox Agent SDK",
"appearance": { "appearance": {
"default": "dark", "default": "dark",
"strict": true "strict": true
}, },
"colors": { "colors": {
"primary": "#ff4f00", "primary": "#ff4f00",
"light": "#ff4f00", "light": "#ff4f00",
"dark": "#ff4f00" "dark": "#ff4f00"
}, },
"favicon": "/favicon.svg", "favicon": "/favicon.svg",
"logo": { "logo": {
"light": "/logo/light.svg", "light": "/logo/light.svg",
"dark": "/logo/dark.svg" "dark": "/logo/dark.svg"
}, },
"integrations": { "integrations": {
"posthog": { "posthog": {
"apiKey": "phc_6kfTNEAVw7rn1LA51cO3D69FefbKupSWFaM7OUgEpEo", "apiKey": "phc_6kfTNEAVw7rn1LA51cO3D69FefbKupSWFaM7OUgEpEo",
"apiHost": "https://ph.rivet.gg", "apiHost": "https://ph.rivet.gg",
"sessionRecording": true "sessionRecording": true
} }
}, },
"navbar": { "navbar": {
"links": [ "links": [
{ {
"label": "Gigacode", "label": "Gigacode",
"icon": "terminal", "icon": "terminal",
"href": "https://github.com/rivet-dev/sandbox-agent/tree/main/gigacode" "href": "https://github.com/rivet-dev/sandbox-agent/tree/main/gigacode"
}, },
{ {
"label": "Discord", "label": "Discord",
"icon": "discord", "icon": "discord",
"href": "https://discord.gg/auCecybynK" "href": "https://discord.gg/auCecybynK"
}, },
{ {
"type": "github", "type": "github",
"href": "https://github.com/rivet-dev/sandbox-agent" "href": "https://github.com/rivet-dev/sandbox-agent"
} }
] ]
}, },
"navigation": { "navigation": {
"tabs": [ "tabs": [
{ {
"tab": "Documentation", "tab": "Documentation",
"pages": [ "pages": [
{ {
"group": "Getting started", "group": "Getting started",
"pages": [ "pages": [
"quickstart", "quickstart",
"sdk-overview", "sdk-overview",
"react-components", "react-components",
{ {
"group": "Deploy", "group": "Deploy",
"icon": "server", "icon": "server",
"pages": [ "pages": [
"deploy/local", "deploy/local",
"deploy/computesdk", "deploy/computesdk",
"deploy/e2b", "deploy/e2b",
"deploy/daytona", "deploy/daytona",
"deploy/vercel", "deploy/vercel",
"deploy/cloudflare", "deploy/cloudflare",
"deploy/docker", "deploy/docker",
"deploy/boxlite" "deploy/boxlite"
] ]
} }
] ]
}, },
{ {
"group": "Agent", "group": "Agent",
"pages": [ "pages": ["agent-sessions", "attachments", "skills-config", "mcp-config", "custom-tools"]
"agent-sessions", },
"attachments", {
"skills-config", "group": "System",
"mcp-config", "pages": ["file-system", "processes"]
"custom-tools" },
] {
}, "group": "Orchestration",
{ "pages": ["architecture", "session-persistence", "observability", "multiplayer", "security"]
"group": "System", },
"pages": ["file-system", "processes"] {
}, "group": "Reference",
{ "pages": [
"group": "Orchestration", "agent-capabilities",
"pages": [ "cli",
"architecture", "inspector",
"session-persistence", "opencode-compatibility",
"observability", {
"multiplayer", "group": "More",
"security" "pages": [
] "credentials",
}, "daemon",
{ "cors",
"group": "Reference", "session-restoration",
"pages": [ "telemetry",
"agent-capabilities", {
"cli", "group": "AI",
"inspector", "pages": ["ai/skill", "ai/llms-txt"]
"opencode-compatibility", }
{ ]
"group": "More", }
"pages": [ ]
"credentials", }
"daemon", ]
"cors", },
"session-restoration", {
"telemetry", "tab": "HTTP API",
{ "pages": [
"group": "AI", {
"pages": ["ai/skill", "ai/llms-txt"] "group": "HTTP Reference",
} "openapi": "openapi.json"
] }
} ]
] }
} ]
] }
},
{
"tab": "HTTP API",
"pages": [
{
"group": "HTTP Reference",
"openapi": "openapi.json"
}
]
}
]
}
} }

View file

@ -20,9 +20,7 @@
"paths": { "paths": {
"/v1/acp": { "/v1/acp": {
"get": { "get": {
"tags": [ "tags": ["v1"],
"v1"
],
"operationId": "get_v1_acp_servers", "operationId": "get_v1_acp_servers",
"responses": { "responses": {
"200": { "200": {
@ -40,9 +38,7 @@
}, },
"/v1/acp/{server_id}": { "/v1/acp/{server_id}": {
"get": { "get": {
"tags": [ "tags": ["v1"],
"v1"
],
"operationId": "get_v1_acp", "operationId": "get_v1_acp",
"parameters": [ "parameters": [
{ {
@ -92,9 +88,7 @@
} }
}, },
"post": { "post": {
"tags": [ "tags": ["v1"],
"v1"
],
"operationId": "post_v1_acp", "operationId": "post_v1_acp",
"parameters": [ "parameters": [
{ {
@ -204,9 +198,7 @@
} }
}, },
"delete": { "delete": {
"tags": [ "tags": ["v1"],
"v1"
],
"operationId": "delete_v1_acp", "operationId": "delete_v1_acp",
"parameters": [ "parameters": [
{ {
@ -228,9 +220,7 @@
}, },
"/v1/agents": { "/v1/agents": {
"get": { "get": {
"tags": [ "tags": ["v1"],
"v1"
],
"operationId": "get_v1_agents", "operationId": "get_v1_agents",
"parameters": [ "parameters": [
{ {
@ -280,9 +270,7 @@
}, },
"/v1/agents/{agent}": { "/v1/agents/{agent}": {
"get": { "get": {
"tags": [ "tags": ["v1"],
"v1"
],
"operationId": "get_v1_agent", "operationId": "get_v1_agent",
"parameters": [ "parameters": [
{ {
@ -351,9 +339,7 @@
}, },
"/v1/agents/{agent}/install": { "/v1/agents/{agent}/install": {
"post": { "post": {
"tags": [ "tags": ["v1"],
"v1"
],
"operationId": "post_v1_agent_install", "operationId": "post_v1_agent_install",
"parameters": [ "parameters": [
{ {
@ -412,9 +398,7 @@
}, },
"/v1/config/mcp": { "/v1/config/mcp": {
"get": { "get": {
"tags": [ "tags": ["v1"],
"v1"
],
"operationId": "get_v1_config_mcp", "operationId": "get_v1_config_mcp",
"parameters": [ "parameters": [
{ {
@ -460,9 +444,7 @@
} }
}, },
"put": { "put": {
"tags": [ "tags": ["v1"],
"v1"
],
"operationId": "put_v1_config_mcp", "operationId": "put_v1_config_mcp",
"parameters": [ "parameters": [
{ {
@ -501,9 +483,7 @@
} }
}, },
"delete": { "delete": {
"tags": [ "tags": ["v1"],
"v1"
],
"operationId": "delete_v1_config_mcp", "operationId": "delete_v1_config_mcp",
"parameters": [ "parameters": [
{ {
@ -534,9 +514,7 @@
}, },
"/v1/config/skills": { "/v1/config/skills": {
"get": { "get": {
"tags": [ "tags": ["v1"],
"v1"
],
"operationId": "get_v1_config_skills", "operationId": "get_v1_config_skills",
"parameters": [ "parameters": [
{ {
@ -582,9 +560,7 @@
} }
}, },
"put": { "put": {
"tags": [ "tags": ["v1"],
"v1"
],
"operationId": "put_v1_config_skills", "operationId": "put_v1_config_skills",
"parameters": [ "parameters": [
{ {
@ -623,9 +599,7 @@
} }
}, },
"delete": { "delete": {
"tags": [ "tags": ["v1"],
"v1"
],
"operationId": "delete_v1_config_skills", "operationId": "delete_v1_config_skills",
"parameters": [ "parameters": [
{ {
@ -656,9 +630,7 @@
}, },
"/v1/fs/entries": { "/v1/fs/entries": {
"get": { "get": {
"tags": [ "tags": ["v1"],
"v1"
],
"operationId": "get_v1_fs_entries", "operationId": "get_v1_fs_entries",
"parameters": [ "parameters": [
{ {
@ -691,9 +663,7 @@
}, },
"/v1/fs/entry": { "/v1/fs/entry": {
"delete": { "delete": {
"tags": [ "tags": ["v1"],
"v1"
],
"operationId": "delete_v1_fs_entry", "operationId": "delete_v1_fs_entry",
"parameters": [ "parameters": [
{ {
@ -732,9 +702,7 @@
}, },
"/v1/fs/file": { "/v1/fs/file": {
"get": { "get": {
"tags": [ "tags": ["v1"],
"v1"
],
"operationId": "get_v1_fs_file", "operationId": "get_v1_fs_file",
"parameters": [ "parameters": [
{ {
@ -754,9 +722,7 @@
} }
}, },
"put": { "put": {
"tags": [ "tags": ["v1"],
"v1"
],
"operationId": "put_v1_fs_file", "operationId": "put_v1_fs_file",
"parameters": [ "parameters": [
{ {
@ -796,9 +762,7 @@
}, },
"/v1/fs/mkdir": { "/v1/fs/mkdir": {
"post": { "post": {
"tags": [ "tags": ["v1"],
"v1"
],
"operationId": "post_v1_fs_mkdir", "operationId": "post_v1_fs_mkdir",
"parameters": [ "parameters": [
{ {
@ -827,9 +791,7 @@
}, },
"/v1/fs/move": { "/v1/fs/move": {
"post": { "post": {
"tags": [ "tags": ["v1"],
"v1"
],
"operationId": "post_v1_fs_move", "operationId": "post_v1_fs_move",
"requestBody": { "requestBody": {
"content": { "content": {
@ -857,9 +819,7 @@
}, },
"/v1/fs/stat": { "/v1/fs/stat": {
"get": { "get": {
"tags": [ "tags": ["v1"],
"v1"
],
"operationId": "get_v1_fs_stat", "operationId": "get_v1_fs_stat",
"parameters": [ "parameters": [
{ {
@ -888,9 +848,7 @@
}, },
"/v1/fs/upload-batch": { "/v1/fs/upload-batch": {
"post": { "post": {
"tags": [ "tags": ["v1"],
"v1"
],
"operationId": "post_v1_fs_upload_batch", "operationId": "post_v1_fs_upload_batch",
"parameters": [ "parameters": [
{ {
@ -931,9 +889,7 @@
}, },
"/v1/health": { "/v1/health": {
"get": { "get": {
"tags": [ "tags": ["v1"],
"v1"
],
"operationId": "get_v1_health", "operationId": "get_v1_health",
"responses": { "responses": {
"200": { "200": {
@ -951,9 +907,7 @@
}, },
"/v1/processes": { "/v1/processes": {
"get": { "get": {
"tags": [ "tags": ["v1"],
"v1"
],
"summary": "List all managed processes.", "summary": "List all managed processes.",
"description": "Returns a list of all processes (running and exited) currently tracked\nby the runtime, sorted by process ID.", "description": "Returns a list of all processes (running and exited) currently tracked\nby the runtime, sorted by process ID.",
"operationId": "get_v1_processes", "operationId": "get_v1_processes",
@ -981,9 +935,7 @@
} }
}, },
"post": { "post": {
"tags": [ "tags": ["v1"],
"v1"
],
"summary": "Create a long-lived managed process.", "summary": "Create a long-lived managed process.",
"description": "Spawns a new process with the given command and arguments. Supports both\npipe-based and PTY (tty) modes. Returns the process descriptor on success.", "description": "Spawns a new process with the given command and arguments. Supports both\npipe-based and PTY (tty) modes. Returns the process descriptor on success.",
"operationId": "post_v1_processes", "operationId": "post_v1_processes",
@ -1043,9 +995,7 @@
}, },
"/v1/processes/config": { "/v1/processes/config": {
"get": { "get": {
"tags": [ "tags": ["v1"],
"v1"
],
"summary": "Get process runtime configuration.", "summary": "Get process runtime configuration.",
"description": "Returns the current runtime configuration for the process management API,\nincluding limits for concurrency, timeouts, and buffer sizes.", "description": "Returns the current runtime configuration for the process management API,\nincluding limits for concurrency, timeouts, and buffer sizes.",
"operationId": "get_v1_processes_config", "operationId": "get_v1_processes_config",
@ -1073,9 +1023,7 @@
} }
}, },
"post": { "post": {
"tags": [ "tags": ["v1"],
"v1"
],
"summary": "Update process runtime configuration.", "summary": "Update process runtime configuration.",
"description": "Replaces the runtime configuration for the process management API.\nValidates that all values are non-zero and clamps default timeout to max.", "description": "Replaces the runtime configuration for the process management API.\nValidates that all values are non-zero and clamps default timeout to max.",
"operationId": "post_v1_processes_config", "operationId": "post_v1_processes_config",
@ -1125,9 +1073,7 @@
}, },
"/v1/processes/run": { "/v1/processes/run": {
"post": { "post": {
"tags": [ "tags": ["v1"],
"v1"
],
"summary": "Run a one-shot command.", "summary": "Run a one-shot command.",
"description": "Executes a command to completion and returns its stdout, stderr, exit code,\nand duration. Supports configurable timeout and output size limits.", "description": "Executes a command to completion and returns its stdout, stderr, exit code,\nand duration. Supports configurable timeout and output size limits.",
"operationId": "post_v1_processes_run", "operationId": "post_v1_processes_run",
@ -1177,9 +1123,7 @@
}, },
"/v1/processes/{id}": { "/v1/processes/{id}": {
"get": { "get": {
"tags": [ "tags": ["v1"],
"v1"
],
"summary": "Get a single process by ID.", "summary": "Get a single process by ID.",
"description": "Returns the current state of a managed process including its status,\nPID, exit code, and creation/exit timestamps.", "description": "Returns the current state of a managed process including its status,\nPID, exit code, and creation/exit timestamps.",
"operationId": "get_v1_process", "operationId": "get_v1_process",
@ -1228,9 +1172,7 @@
} }
}, },
"delete": { "delete": {
"tags": [ "tags": ["v1"],
"v1"
],
"summary": "Delete a process record.", "summary": "Delete a process record.",
"description": "Removes a stopped process from the runtime. Returns 409 if the process\nis still running; stop or kill it first.", "description": "Removes a stopped process from the runtime. Returns 409 if the process\nis still running; stop or kill it first.",
"operationId": "delete_v1_process", "operationId": "delete_v1_process",
@ -1284,9 +1226,7 @@
}, },
"/v1/processes/{id}/input": { "/v1/processes/{id}/input": {
"post": { "post": {
"tags": [ "tags": ["v1"],
"v1"
],
"summary": "Write input to a process.", "summary": "Write input to a process.",
"description": "Sends data to a process's stdin (pipe mode) or PTY writer (tty mode).\nData can be encoded as base64, utf8, or text. Returns 413 if the decoded\npayload exceeds the configured `maxInputBytesPerRequest` limit.", "description": "Sends data to a process's stdin (pipe mode) or PTY writer (tty mode).\nData can be encoded as base64, utf8, or text. Returns 413 if the decoded\npayload exceeds the configured `maxInputBytesPerRequest` limit.",
"operationId": "post_v1_process_input", "operationId": "post_v1_process_input",
@ -1367,9 +1307,7 @@
}, },
"/v1/processes/{id}/kill": { "/v1/processes/{id}/kill": {
"post": { "post": {
"tags": [ "tags": ["v1"],
"v1"
],
"summary": "Send SIGKILL to a process.", "summary": "Send SIGKILL to a process.",
"description": "Sends SIGKILL to the process and optionally waits up to `waitMs`\nmilliseconds for the process to exit before returning.", "description": "Sends SIGKILL to the process and optionally waits up to `waitMs`\nmilliseconds for the process to exit before returning.",
"operationId": "post_v1_process_kill", "operationId": "post_v1_process_kill",
@ -1432,9 +1370,7 @@
}, },
"/v1/processes/{id}/logs": { "/v1/processes/{id}/logs": {
"get": { "get": {
"tags": [ "tags": ["v1"],
"v1"
],
"summary": "Fetch process logs.", "summary": "Fetch process logs.",
"description": "Returns buffered log entries for a process. Supports filtering by stream\ntype, tail count, and sequence-based resumption. When `follow=true`,\nreturns an SSE stream that replays buffered entries then streams live output.", "description": "Returns buffered log entries for a process. Supports filtering by stream\ntype, tail count, and sequence-based resumption. When `follow=true`,\nreturns an SSE stream that replays buffered entries then streams live output.",
"operationId": "get_v1_process_logs", "operationId": "get_v1_process_logs",
@ -1532,9 +1468,7 @@
}, },
"/v1/processes/{id}/stop": { "/v1/processes/{id}/stop": {
"post": { "post": {
"tags": [ "tags": ["v1"],
"v1"
],
"summary": "Send SIGTERM to a process.", "summary": "Send SIGTERM to a process.",
"description": "Sends SIGTERM to the process and optionally waits up to `waitMs`\nmilliseconds for the process to exit before returning.", "description": "Sends SIGTERM to the process and optionally waits up to `waitMs`\nmilliseconds for the process to exit before returning.",
"operationId": "post_v1_process_stop", "operationId": "post_v1_process_stop",
@ -1597,9 +1531,7 @@
}, },
"/v1/processes/{id}/terminal/resize": { "/v1/processes/{id}/terminal/resize": {
"post": { "post": {
"tags": [ "tags": ["v1"],
"v1"
],
"summary": "Resize a process terminal.", "summary": "Resize a process terminal.",
"description": "Sets the PTY window size (columns and rows) for a tty-mode process and\nsends SIGWINCH so the child process can adapt.", "description": "Sets the PTY window size (columns and rows) for a tty-mode process and\nsends SIGWINCH so the child process can adapt.",
"operationId": "post_v1_process_terminal_resize", "operationId": "post_v1_process_terminal_resize",
@ -1680,9 +1612,7 @@
}, },
"/v1/processes/{id}/terminal/ws": { "/v1/processes/{id}/terminal/ws": {
"get": { "get": {
"tags": [ "tags": ["v1"],
"v1"
],
"summary": "Open an interactive WebSocket terminal session.", "summary": "Open an interactive WebSocket terminal session.",
"description": "Upgrades the connection to a WebSocket for bidirectional PTY I/O. Accepts\n`access_token` query param for browser-based auth (WebSocket API cannot\nsend custom headers). Streams raw PTY output as binary frames and accepts\nJSON control frames for input, resize, and close.", "description": "Upgrades the connection to a WebSocket for bidirectional PTY I/O. Accepts\n`access_token` query param for browser-based auth (WebSocket API cannot\nsend custom headers). Streams raw PTY output as binary frames and accepts\nJSON control frames for input, resize, and close.",
"operationId": "get_v1_process_terminal_ws", "operationId": "get_v1_process_terminal_ws",
@ -1759,9 +1689,7 @@
"schemas": { "schemas": {
"AcpEnvelope": { "AcpEnvelope": {
"type": "object", "type": "object",
"required": [ "required": ["jsonrpc"],
"jsonrpc"
],
"properties": { "properties": {
"error": { "error": {
"nullable": true "nullable": true
@ -1795,11 +1723,7 @@
}, },
"AcpServerInfo": { "AcpServerInfo": {
"type": "object", "type": "object",
"required": [ "required": ["serverId", "agent", "createdAtMs"],
"serverId",
"agent",
"createdAtMs"
],
"properties": { "properties": {
"agent": { "agent": {
"type": "string" "type": "string"
@ -1815,9 +1739,7 @@
}, },
"AcpServerListResponse": { "AcpServerListResponse": {
"type": "object", "type": "object",
"required": [ "required": ["servers"],
"servers"
],
"properties": { "properties": {
"servers": { "servers": {
"type": "array", "type": "array",
@ -1908,12 +1830,7 @@
}, },
"AgentInfo": { "AgentInfo": {
"type": "object", "type": "object",
"required": [ "required": ["id", "installed", "credentialsAvailable", "capabilities"],
"id",
"installed",
"credentialsAvailable",
"capabilities"
],
"properties": { "properties": {
"capabilities": { "capabilities": {
"$ref": "#/components/schemas/AgentCapabilities" "$ref": "#/components/schemas/AgentCapabilities"
@ -1956,11 +1873,7 @@
}, },
"AgentInstallArtifact": { "AgentInstallArtifact": {
"type": "object", "type": "object",
"required": [ "required": ["kind", "path", "source"],
"kind",
"path",
"source"
],
"properties": { "properties": {
"kind": { "kind": {
"type": "string" "type": "string"
@ -1996,10 +1909,7 @@
}, },
"AgentInstallResponse": { "AgentInstallResponse": {
"type": "object", "type": "object",
"required": [ "required": ["already_installed", "artifacts"],
"already_installed",
"artifacts"
],
"properties": { "properties": {
"already_installed": { "already_installed": {
"type": "boolean" "type": "boolean"
@ -2014,9 +1924,7 @@
}, },
"AgentListResponse": { "AgentListResponse": {
"type": "object", "type": "object",
"required": [ "required": ["agents"],
"agents"
],
"properties": { "properties": {
"agents": { "agents": {
"type": "array", "type": "array",
@ -2049,9 +1957,7 @@
}, },
"FsActionResponse": { "FsActionResponse": {
"type": "object", "type": "object",
"required": [ "required": ["path"],
"path"
],
"properties": { "properties": {
"path": { "path": {
"type": "string" "type": "string"
@ -2060,9 +1966,7 @@
}, },
"FsDeleteQuery": { "FsDeleteQuery": {
"type": "object", "type": "object",
"required": [ "required": ["path"],
"path"
],
"properties": { "properties": {
"path": { "path": {
"type": "string" "type": "string"
@ -2084,12 +1988,7 @@
}, },
"FsEntry": { "FsEntry": {
"type": "object", "type": "object",
"required": [ "required": ["name", "path", "entryType", "size"],
"name",
"path",
"entryType",
"size"
],
"properties": { "properties": {
"entryType": { "entryType": {
"$ref": "#/components/schemas/FsEntryType" "$ref": "#/components/schemas/FsEntryType"
@ -2113,17 +2012,11 @@
}, },
"FsEntryType": { "FsEntryType": {
"type": "string", "type": "string",
"enum": [ "enum": ["file", "directory"]
"file",
"directory"
]
}, },
"FsMoveRequest": { "FsMoveRequest": {
"type": "object", "type": "object",
"required": [ "required": ["from", "to"],
"from",
"to"
],
"properties": { "properties": {
"from": { "from": {
"type": "string" "type": "string"
@ -2139,10 +2032,7 @@
}, },
"FsMoveResponse": { "FsMoveResponse": {
"type": "object", "type": "object",
"required": [ "required": ["from", "to"],
"from",
"to"
],
"properties": { "properties": {
"from": { "from": {
"type": "string" "type": "string"
@ -2154,9 +2044,7 @@
}, },
"FsPathQuery": { "FsPathQuery": {
"type": "object", "type": "object",
"required": [ "required": ["path"],
"path"
],
"properties": { "properties": {
"path": { "path": {
"type": "string" "type": "string"
@ -2165,11 +2053,7 @@
}, },
"FsStat": { "FsStat": {
"type": "object", "type": "object",
"required": [ "required": ["path", "entryType", "size"],
"path",
"entryType",
"size"
],
"properties": { "properties": {
"entryType": { "entryType": {
"$ref": "#/components/schemas/FsEntryType" "$ref": "#/components/schemas/FsEntryType"
@ -2199,10 +2083,7 @@
}, },
"FsUploadBatchResponse": { "FsUploadBatchResponse": {
"type": "object", "type": "object",
"required": [ "required": ["paths", "truncated"],
"paths",
"truncated"
],
"properties": { "properties": {
"paths": { "paths": {
"type": "array", "type": "array",
@ -2217,10 +2098,7 @@
}, },
"FsWriteResponse": { "FsWriteResponse": {
"type": "object", "type": "object",
"required": [ "required": ["path", "bytesWritten"],
"path",
"bytesWritten"
],
"properties": { "properties": {
"bytesWritten": { "bytesWritten": {
"type": "integer", "type": "integer",
@ -2234,9 +2112,7 @@
}, },
"HealthResponse": { "HealthResponse": {
"type": "object", "type": "object",
"required": [ "required": ["status"],
"status"
],
"properties": { "properties": {
"status": { "status": {
"type": "string" "type": "string"
@ -2245,10 +2121,7 @@
}, },
"McpConfigQuery": { "McpConfigQuery": {
"type": "object", "type": "object",
"required": [ "required": ["directory", "mcpName"],
"directory",
"mcpName"
],
"properties": { "properties": {
"directory": { "directory": {
"type": "string" "type": "string"
@ -2262,10 +2135,7 @@
"oneOf": [ "oneOf": [
{ {
"type": "object", "type": "object",
"required": [ "required": ["command", "type"],
"command",
"type"
],
"properties": { "properties": {
"args": { "args": {
"type": "array", "type": "array",
@ -2299,18 +2169,13 @@
}, },
"type": { "type": {
"type": "string", "type": "string",
"enum": [ "enum": ["local"]
"local"
]
} }
} }
}, },
{ {
"type": "object", "type": "object",
"required": [ "required": ["url", "type"],
"url",
"type"
],
"properties": { "properties": {
"bearerTokenEnvVar": { "bearerTokenEnvVar": {
"type": "string", "type": "string",
@ -2358,9 +2223,7 @@
}, },
"type": { "type": {
"type": "string", "type": "string",
"enum": [ "enum": ["remote"]
"remote"
]
}, },
"url": { "url": {
"type": "string" "type": "string"
@ -2374,11 +2237,7 @@
}, },
"ProblemDetails": { "ProblemDetails": {
"type": "object", "type": "object",
"required": [ "required": ["type", "title", "status"],
"type",
"title",
"status"
],
"properties": { "properties": {
"detail": { "detail": {
"type": "string", "type": "string",
@ -2404,14 +2263,7 @@
}, },
"ProcessConfig": { "ProcessConfig": {
"type": "object", "type": "object",
"required": [ "required": ["maxConcurrentProcesses", "defaultRunTimeoutMs", "maxRunTimeoutMs", "maxOutputBytes", "maxLogBytesPerProcess", "maxInputBytesPerRequest"],
"maxConcurrentProcesses",
"defaultRunTimeoutMs",
"maxRunTimeoutMs",
"maxOutputBytes",
"maxLogBytesPerProcess",
"maxInputBytesPerRequest"
],
"properties": { "properties": {
"defaultRunTimeoutMs": { "defaultRunTimeoutMs": {
"type": "integer", "type": "integer",
@ -2443,9 +2295,7 @@
}, },
"ProcessCreateRequest": { "ProcessCreateRequest": {
"type": "object", "type": "object",
"required": [ "required": ["command"],
"command"
],
"properties": { "properties": {
"args": { "args": {
"type": "array", "type": "array",
@ -2476,15 +2326,7 @@
}, },
"ProcessInfo": { "ProcessInfo": {
"type": "object", "type": "object",
"required": [ "required": ["id", "command", "args", "tty", "interactive", "status", "createdAtMs"],
"id",
"command",
"args",
"tty",
"interactive",
"status",
"createdAtMs"
],
"properties": { "properties": {
"args": { "args": {
"type": "array", "type": "array",
@ -2535,9 +2377,7 @@
}, },
"ProcessInputRequest": { "ProcessInputRequest": {
"type": "object", "type": "object",
"required": [ "required": ["data"],
"data"
],
"properties": { "properties": {
"data": { "data": {
"type": "string" "type": "string"
@ -2550,9 +2390,7 @@
}, },
"ProcessInputResponse": { "ProcessInputResponse": {
"type": "object", "type": "object",
"required": [ "required": ["bytesWritten"],
"bytesWritten"
],
"properties": { "properties": {
"bytesWritten": { "bytesWritten": {
"type": "integer", "type": "integer",
@ -2562,9 +2400,7 @@
}, },
"ProcessListResponse": { "ProcessListResponse": {
"type": "object", "type": "object",
"required": [ "required": ["processes"],
"processes"
],
"properties": { "properties": {
"processes": { "processes": {
"type": "array", "type": "array",
@ -2576,13 +2412,7 @@
}, },
"ProcessLogEntry": { "ProcessLogEntry": {
"type": "object", "type": "object",
"required": [ "required": ["sequence", "stream", "timestampMs", "data", "encoding"],
"sequence",
"stream",
"timestampMs",
"data",
"encoding"
],
"properties": { "properties": {
"data": { "data": {
"type": "string" "type": "string"
@ -2634,11 +2464,7 @@
}, },
"ProcessLogsResponse": { "ProcessLogsResponse": {
"type": "object", "type": "object",
"required": [ "required": ["processId", "stream", "entries"],
"processId",
"stream",
"entries"
],
"properties": { "properties": {
"entries": { "entries": {
"type": "array", "type": "array",
@ -2656,18 +2482,11 @@
}, },
"ProcessLogsStream": { "ProcessLogsStream": {
"type": "string", "type": "string",
"enum": [ "enum": ["stdout", "stderr", "combined", "pty"]
"stdout",
"stderr",
"combined",
"pty"
]
}, },
"ProcessRunRequest": { "ProcessRunRequest": {
"type": "object", "type": "object",
"required": [ "required": ["command"],
"command"
],
"properties": { "properties": {
"args": { "args": {
"type": "array", "type": "array",
@ -2703,14 +2522,7 @@
}, },
"ProcessRunResponse": { "ProcessRunResponse": {
"type": "object", "type": "object",
"required": [ "required": ["timedOut", "stdout", "stderr", "stdoutTruncated", "stderrTruncated", "durationMs"],
"timedOut",
"stdout",
"stderr",
"stdoutTruncated",
"stderrTruncated",
"durationMs"
],
"properties": { "properties": {
"durationMs": { "durationMs": {
"type": "integer", "type": "integer",
@ -2752,17 +2564,11 @@
}, },
"ProcessState": { "ProcessState": {
"type": "string", "type": "string",
"enum": [ "enum": ["running", "exited"]
"running",
"exited"
]
}, },
"ProcessTerminalResizeRequest": { "ProcessTerminalResizeRequest": {
"type": "object", "type": "object",
"required": [ "required": ["cols", "rows"],
"cols",
"rows"
],
"properties": { "properties": {
"cols": { "cols": {
"type": "integer", "type": "integer",
@ -2778,10 +2584,7 @@
}, },
"ProcessTerminalResizeResponse": { "ProcessTerminalResizeResponse": {
"type": "object", "type": "object",
"required": [ "required": ["cols", "rows"],
"cols",
"rows"
],
"properties": { "properties": {
"cols": { "cols": {
"type": "integer", "type": "integer",
@ -2797,16 +2600,11 @@
}, },
"ServerStatus": { "ServerStatus": {
"type": "string", "type": "string",
"enum": [ "enum": ["running", "stopped"]
"running",
"stopped"
]
}, },
"ServerStatusInfo": { "ServerStatusInfo": {
"type": "object", "type": "object",
"required": [ "required": ["status"],
"status"
],
"properties": { "properties": {
"status": { "status": {
"$ref": "#/components/schemas/ServerStatus" "$ref": "#/components/schemas/ServerStatus"
@ -2821,10 +2619,7 @@
}, },
"SkillSource": { "SkillSource": {
"type": "object", "type": "object",
"required": [ "required": ["type", "source"],
"type",
"source"
],
"properties": { "properties": {
"ref": { "ref": {
"type": "string", "type": "string",
@ -2851,9 +2646,7 @@
}, },
"SkillsConfig": { "SkillsConfig": {
"type": "object", "type": "object",
"required": [ "required": ["sources"],
"sources"
],
"properties": { "properties": {
"sources": { "sources": {
"type": "array", "type": "array",
@ -2865,10 +2658,7 @@
}, },
"SkillsConfigQuery": { "SkillsConfigQuery": {
"type": "object", "type": "object",
"required": [ "required": ["directory", "skillName"],
"directory",
"skillName"
],
"properties": { "properties": {
"directory": { "directory": {
"type": "string" "type": "string"
@ -2886,4 +2676,4 @@
"description": "ACP proxy v1 API" "description": "ACP proxy v1 API"
} }
] ]
} }

View file

@ -11,17 +11,14 @@ setupImage();
console.log("Creating BoxLite sandbox..."); console.log("Creating BoxLite sandbox...");
const box = new SimpleBox({ const box = new SimpleBox({
rootfsPath: OCI_DIR, rootfsPath: OCI_DIR,
env, env,
ports: [{ hostPort: 3000, guestPort: 3000 }], ports: [{ hostPort: 3000, guestPort: 3000 }],
diskSizeGb: 4, diskSizeGb: 4,
}); });
console.log("Starting server..."); console.log("Starting server...");
const result = await box.exec( const result = await box.exec("sh", "-c", "nohup sandbox-agent server --no-token --host 0.0.0.0 --port 3000 >/tmp/sandbox-agent.log 2>&1 &");
"sh", "-c",
"nohup sandbox-agent server --no-token --host 0.0.0.0 --port 3000 >/tmp/sandbox-agent.log 2>&1 &",
);
if (result.exitCode !== 0) throw new Error(`Failed to start server: ${result.stderr}`); if (result.exitCode !== 0) throw new Error(`Failed to start server: ${result.stderr}`);
const baseUrl = "http://localhost:3000"; const baseUrl = "http://localhost:3000";
@ -36,9 +33,9 @@ console.log(" Press Ctrl+C to stop.");
const keepAlive = setInterval(() => {}, 60_000); const keepAlive = setInterval(() => {}, 60_000);
const cleanup = async () => { const cleanup = async () => {
clearInterval(keepAlive); clearInterval(keepAlive);
await box.stop(); await box.stop();
process.exit(0); process.exit(0);
}; };
process.once("SIGINT", cleanup); process.once("SIGINT", cleanup);
process.once("SIGTERM", cleanup); process.once("SIGTERM", cleanup);

View file

@ -5,12 +5,12 @@ export const DOCKER_IMAGE = "sandbox-agent-boxlite";
export const OCI_DIR = new URL("../oci-image", import.meta.url).pathname; export const OCI_DIR = new URL("../oci-image", import.meta.url).pathname;
export function setupImage() { export function setupImage() {
console.log(`Building image "${DOCKER_IMAGE}" (cached after first run)...`); console.log(`Building image "${DOCKER_IMAGE}" (cached after first run)...`);
execSync(`docker build -t ${DOCKER_IMAGE} ${new URL("..", import.meta.url).pathname}`, { stdio: "inherit" }); execSync(`docker build -t ${DOCKER_IMAGE} ${new URL("..", import.meta.url).pathname}`, { stdio: "inherit" });
if (!existsSync(`${OCI_DIR}/oci-layout`)) { if (!existsSync(`${OCI_DIR}/oci-layout`)) {
console.log("Exporting to OCI layout..."); console.log("Exporting to OCI layout...");
mkdirSync(OCI_DIR, { recursive: true }); mkdirSync(OCI_DIR, { recursive: true });
execSync(`docker save ${DOCKER_IMAGE} | tar -xf - -C ${OCI_DIR}`, { stdio: "inherit" }); execSync(`docker save ${DOCKER_IMAGE} | tar -xf - -C ${OCI_DIR}`, { stdio: "inherit" });
} }
} }

View file

@ -128,7 +128,7 @@ export function App() {
console.error("Event stream error:", err); console.error("Event stream error:", err);
} }
}, },
[log] [log],
); );
const send = useCallback(async () => { const send = useCallback(async () => {
@ -162,12 +162,7 @@ export function App() {
<div style={styles.connectForm}> <div style={styles.connectForm}>
<label style={styles.label}> <label style={styles.label}>
Sandbox name: Sandbox name:
<input <input style={styles.input} value={sandboxName} onChange={(e) => setSandboxName(e.target.value)} placeholder="demo" />
style={styles.input}
value={sandboxName}
onChange={(e) => setSandboxName(e.target.value)}
placeholder="demo"
/>
</label> </label>
<button style={styles.button} onClick={connect}> <button style={styles.button} onClick={connect}>
Connect Connect

View file

@ -5,5 +5,5 @@ import { App } from "./App";
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<App /> <App />
</StrictMode> </StrictMode>,
); );

View file

@ -2,65 +2,61 @@ import type { Sandbox } from "@cloudflare/sandbox";
import { SandboxAgent } from "sandbox-agent"; import { SandboxAgent } from "sandbox-agent";
export type PromptRequest = { export type PromptRequest = {
agent?: string; agent?: string;
prompt?: string; prompt?: string;
}; };
export async function runPromptEndpointStream( export async function runPromptEndpointStream(
sandbox: Sandbox, sandbox: Sandbox,
request: PromptRequest, request: PromptRequest,
port: number, port: number,
emit: (event: { type: string; [key: string]: unknown }) => Promise<void> | void, emit: (event: { type: string; [key: string]: unknown }) => Promise<void> | void,
): Promise<void> { ): Promise<void> {
const client = await SandboxAgent.connect({ const client = await SandboxAgent.connect({
fetch: (req, init) => fetch: (req, init) =>
sandbox.containerFetch( sandbox.containerFetch(
req, req,
{ {
...(init ?? {}), ...(init ?? {}),
// Cloudflare containerFetch may drop long-lived update streams when // Cloudflare containerFetch may drop long-lived update streams when
// a forwarded AbortSignal is cancelled; clear it for this path. // a forwarded AbortSignal is cancelled; clear it for this path.
signal: undefined, signal: undefined,
}, },
port, port,
), ),
}); });
let unsubscribe: (() => void) | undefined; let unsubscribe: (() => void) | undefined;
try { try {
const session = await client.createSession({ const session = await client.createSession({
agent: request.agent ?? "codex", agent: request.agent ?? "codex",
}); });
const promptText = const promptText = request.prompt?.trim() || "Reply with a short confirmation.";
request.prompt?.trim() || "Reply with a short confirmation."; await emit({
await emit({ type: "session.created",
type: "session.created", sessionId: session.id,
sessionId: session.id, agent: session.agent,
agent: session.agent, prompt: promptText,
prompt: promptText, });
});
let pendingWrites: Promise<void> = Promise.resolve(); let pendingWrites: Promise<void> = Promise.resolve();
unsubscribe = session.onEvent((event) => { unsubscribe = session.onEvent((event) => {
pendingWrites = pendingWrites pendingWrites = pendingWrites
.then(async () => { .then(async () => {
await emit({ type: "session.event", event }); await emit({ type: "session.event", event });
}) })
.catch(() => {}); .catch(() => {});
}); });
const response = await session.prompt([{ type: "text", text: promptText }]); const response = await session.prompt([{ type: "text", text: promptText }]);
await pendingWrites; await pendingWrites;
await emit({ type: "prompt.response", response }); await emit({ type: "prompt.response", response });
await emit({ type: "prompt.completed" }); await emit({ type: "prompt.completed" });
} finally { } finally {
if (unsubscribe) { if (unsubscribe) {
unsubscribe(); unsubscribe();
} }
await Promise.race([ await Promise.race([client.dispose(), new Promise((resolve) => setTimeout(resolve, 250))]);
client.dispose(), }
new Promise((resolve) => setTimeout(resolve, 250)),
]);
}
} }

View file

@ -15,8 +15,7 @@ import { fileURLToPath } from "node:url";
import { resolve } from "node:path"; import { resolve } from "node:path";
const PORT = 3000; const PORT = 3000;
const REQUEST_TIMEOUT_MS = const REQUEST_TIMEOUT_MS = Number.parseInt(process.env.COMPUTESDK_TIMEOUT_MS || "", 10) || 120_000;
Number.parseInt(process.env.COMPUTESDK_TIMEOUT_MS || "", 10) || 120_000;
/** /**
* Detects and validates the provider to use. * Detects and validates the provider to use.
@ -24,28 +23,22 @@ const REQUEST_TIMEOUT_MS =
*/ */
function resolveProvider(): ProviderName { function resolveProvider(): ProviderName {
const providerOverride = process.env.COMPUTESDK_PROVIDER; const providerOverride = process.env.COMPUTESDK_PROVIDER;
if (providerOverride) { if (providerOverride) {
if (!isValidProvider(providerOverride)) { if (!isValidProvider(providerOverride)) {
throw new Error( throw new Error(`Unsupported ComputeSDK provider "${providerOverride}". Supported providers: ${PROVIDER_NAMES.join(", ")}`);
`Unsupported ComputeSDK provider "${providerOverride}". Supported providers: ${PROVIDER_NAMES.join(", ")}`
);
} }
if (!isProviderAuthComplete(providerOverride)) { if (!isProviderAuthComplete(providerOverride)) {
const missing = getMissingEnvVars(providerOverride); const missing = getMissingEnvVars(providerOverride);
throw new Error( throw new Error(`Missing credentials for provider "${providerOverride}". Set: ${missing.join(", ")}`);
`Missing credentials for provider "${providerOverride}". Set: ${missing.join(", ")}`
);
} }
console.log(`Using ComputeSDK provider: ${providerOverride} (explicit)`); console.log(`Using ComputeSDK provider: ${providerOverride} (explicit)`);
return providerOverride as ProviderName; return providerOverride as ProviderName;
} }
const detected = detectProvider(); const detected = detectProvider();
if (!detected) { if (!detected) {
throw new Error( throw new Error(`No provider credentials found. Set one of: ${PROVIDER_NAMES.map((p) => getMissingEnvVars(p).join(", ")).join(" | ")}`);
`No provider credentials found. Set one of: ${PROVIDER_NAMES.map((p) => getMissingEnvVars(p).join(", ")).join(" | ")}`
);
} }
console.log(`Using ComputeSDK provider: ${detected} (auto-detected)`); console.log(`Using ComputeSDK provider: ${detected} (auto-detected)`);
return detected as ProviderName; return detected as ProviderName;
@ -53,20 +46,19 @@ function resolveProvider(): ProviderName {
function configureComputeSDK(): void { function configureComputeSDK(): void {
const provider = resolveProvider(); const provider = resolveProvider();
const config: ExplicitComputeConfig = { const config: ExplicitComputeConfig = {
provider, provider,
computesdkApiKey: process.env.COMPUTESDK_API_KEY, computesdkApiKey: process.env.COMPUTESDK_API_KEY,
requestTimeoutMs: REQUEST_TIMEOUT_MS, requestTimeoutMs: REQUEST_TIMEOUT_MS,
}; };
const providerConfig = getProviderConfigFromEnv(provider); const providerConfig = getProviderConfigFromEnv(provider);
if (Object.keys(providerConfig).length > 0) { if (Object.keys(providerConfig).length > 0) {
const configWithProvider = const configWithProvider = config as ExplicitComputeConfig & Record<ProviderName, Record<string, string>>;
config as ExplicitComputeConfig & Record<ProviderName, Record<string, string>>;
configWithProvider[provider] = providerConfig; configWithProvider[provider] = providerConfig;
} }
compute.setConfig(config); compute.setConfig(config);
} }
@ -149,9 +141,7 @@ export async function runComputeSdkExample(): Promise<void> {
await new Promise(() => {}); await new Promise(() => {});
} }
const isDirectRun = Boolean( const isDirectRun = Boolean(process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url));
process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)
);
if (isDirectRun) { if (isDirectRun) {
runComputeSdkExample().catch((error) => { runComputeSdkExample().catch((error) => {

View file

@ -5,12 +5,7 @@ import { setupComputeSdkSandboxAgent } from "../src/computesdk.ts";
const hasModal = Boolean(process.env.MODAL_TOKEN_ID && process.env.MODAL_TOKEN_SECRET); const hasModal = Boolean(process.env.MODAL_TOKEN_ID && process.env.MODAL_TOKEN_SECRET);
const hasVercel = Boolean(process.env.VERCEL_TOKEN || process.env.VERCEL_OIDC_TOKEN); const hasVercel = Boolean(process.env.VERCEL_TOKEN || process.env.VERCEL_OIDC_TOKEN);
const hasProviderKey = Boolean( const hasProviderKey = Boolean(
process.env.BLAXEL_API_KEY || process.env.BLAXEL_API_KEY || process.env.CSB_API_KEY || process.env.DAYTONA_API_KEY || process.env.E2B_API_KEY || hasModal || hasVercel,
process.env.CSB_API_KEY ||
process.env.DAYTONA_API_KEY ||
process.env.E2B_API_KEY ||
hasModal ||
hasVercel
); );
const shouldRun = Boolean(process.env.COMPUTESDK_API_KEY) && hasProviderKey; const shouldRun = Boolean(process.env.COMPUTESDK_API_KEY) && hasProviderKey;
@ -34,6 +29,6 @@ describe("computesdk example", () => {
await cleanup(); await cleanup();
} }
}, },
timeoutMs timeoutMs,
); );
}); });

View file

@ -5,23 +5,19 @@ import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared";
const daytona = new Daytona(); const daytona = new Daytona();
const envVars: Record<string, string> = {}; const envVars: Record<string, string> = {};
if (process.env.ANTHROPIC_API_KEY) if (process.env.ANTHROPIC_API_KEY) envVars.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
envVars.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; if (process.env.OPENAI_API_KEY) envVars.OPENAI_API_KEY = process.env.OPENAI_API_KEY;
if (process.env.OPENAI_API_KEY)
envVars.OPENAI_API_KEY = process.env.OPENAI_API_KEY;
// Build a custom image with sandbox-agent pre-installed (slower first run, faster subsequent runs) // Build a custom image with sandbox-agent pre-installed (slower first run, faster subsequent runs)
const image = Image.base("ubuntu:22.04").runCommands( const image = Image.base("ubuntu:22.04").runCommands(
"apt-get update && apt-get install -y curl ca-certificates", "apt-get update && apt-get install -y curl ca-certificates",
"curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh", "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh",
); );
console.log("Creating Daytona sandbox (first run builds the base image and may take a few minutes, subsequent runs are fast)..."); console.log("Creating Daytona sandbox (first run builds the base image and may take a few minutes, subsequent runs are fast)...");
const sandbox = await daytona.create({ envVars, image, autoStopInterval: 0 }, { timeout: 180 }); const sandbox = await daytona.create({ envVars, image, autoStopInterval: 0 }, { timeout: 180 });
await sandbox.process.executeCommand( await sandbox.process.executeCommand("nohup sandbox-agent server --no-token --host 0.0.0.0 --port 3000 >/tmp/sandbox-agent.log 2>&1 &");
"nohup sandbox-agent server --no-token --host 0.0.0.0 --port 3000 >/tmp/sandbox-agent.log 2>&1 &",
);
const baseUrl = (await sandbox.getSignedPreviewUrl(3000, 4 * 60 * 60)).url; const baseUrl = (await sandbox.getSignedPreviewUrl(3000, 4 * 60 * 60)).url;
@ -35,9 +31,9 @@ console.log(" Press Ctrl+C to stop.");
const keepAlive = setInterval(() => {}, 60_000); const keepAlive = setInterval(() => {}, 60_000);
const cleanup = async () => { const cleanup = async () => {
clearInterval(keepAlive); clearInterval(keepAlive);
await sandbox.delete(60); await sandbox.delete(60);
process.exit(0); process.exit(0);
}; };
process.once("SIGINT", cleanup); process.once("SIGINT", cleanup);
process.once("SIGTERM", cleanup); process.once("SIGTERM", cleanup);

View file

@ -5,10 +5,8 @@ import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared";
const daytona = new Daytona(); const daytona = new Daytona();
const envVars: Record<string, string> = {}; const envVars: Record<string, string> = {};
if (process.env.ANTHROPIC_API_KEY) if (process.env.ANTHROPIC_API_KEY) envVars.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
envVars.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; if (process.env.OPENAI_API_KEY) envVars.OPENAI_API_KEY = process.env.OPENAI_API_KEY;
if (process.env.OPENAI_API_KEY)
envVars.OPENAI_API_KEY = process.env.OPENAI_API_KEY;
// Use default image and install sandbox-agent at runtime (faster startup, no snapshot build) // Use default image and install sandbox-agent at runtime (faster startup, no snapshot build)
console.log("Creating Daytona sandbox..."); console.log("Creating Daytona sandbox...");
@ -16,17 +14,13 @@ const sandbox = await daytona.create({ envVars, autoStopInterval: 0 });
// Install sandbox-agent and start server // Install sandbox-agent and start server
console.log("Installing sandbox-agent..."); console.log("Installing sandbox-agent...");
await sandbox.process.executeCommand( await sandbox.process.executeCommand("curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh");
"curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh",
);
console.log("Installing agents..."); console.log("Installing agents...");
await sandbox.process.executeCommand("sandbox-agent install-agent claude"); await sandbox.process.executeCommand("sandbox-agent install-agent claude");
await sandbox.process.executeCommand("sandbox-agent install-agent codex"); await sandbox.process.executeCommand("sandbox-agent install-agent codex");
await sandbox.process.executeCommand( await sandbox.process.executeCommand("nohup sandbox-agent server --no-token --host 0.0.0.0 --port 3000 >/tmp/sandbox-agent.log 2>&1 &");
"nohup sandbox-agent server --no-token --host 0.0.0.0 --port 3000 >/tmp/sandbox-agent.log 2>&1 &",
);
const baseUrl = (await sandbox.getSignedPreviewUrl(3000, 4 * 60 * 60)).url; const baseUrl = (await sandbox.getSignedPreviewUrl(3000, 4 * 60 * 60)).url;
@ -40,9 +34,9 @@ console.log(" Press Ctrl+C to stop.");
const keepAlive = setInterval(() => {}, 60_000); const keepAlive = setInterval(() => {}, 60_000);
const cleanup = async () => { const cleanup = async () => {
clearInterval(keepAlive); clearInterval(keepAlive);
await sandbox.delete(60); await sandbox.delete(60);
process.exit(0); process.exit(0);
}; };
process.once("SIGINT", cleanup); process.once("SIGINT", cleanup);
process.once("SIGTERM", cleanup); process.once("SIGTERM", cleanup);

View file

@ -23,6 +23,6 @@ describe("daytona example", () => {
await cleanup(); await cleanup();
} }
}, },
timeoutMs timeoutMs,
); );
}); });

View file

@ -8,9 +8,7 @@ const IMAGE = "node:22-bookworm-slim";
const PORT = 3000; const PORT = 3000;
const agent = detectAgent(); const agent = detectAgent();
const codexAuthPath = process.env.HOME ? path.join(process.env.HOME, ".codex", "auth.json") : null; const codexAuthPath = process.env.HOME ? path.join(process.env.HOME, ".codex", "auth.json") : null;
const bindMounts = codexAuthPath && fs.existsSync(codexAuthPath) const bindMounts = codexAuthPath && fs.existsSync(codexAuthPath) ? [`${codexAuthPath}:/root/.codex/auth.json:ro`] : [];
? [`${codexAuthPath}:/root/.codex/auth.json:ro`]
: [];
const docker = new Docker({ socketPath: "/var/run/docker.sock" }); const docker = new Docker({ socketPath: "/var/run/docker.sock" });
@ -22,7 +20,7 @@ try {
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
docker.pull(IMAGE, (err: Error | null, stream: NodeJS.ReadableStream) => { docker.pull(IMAGE, (err: Error | null, stream: NodeJS.ReadableStream) => {
if (err) return reject(err); if (err) return reject(err);
docker.modem.followProgress(stream, (err: Error | null) => err ? reject(err) : resolve()); docker.modem.followProgress(stream, (err: Error | null) => (err ? reject(err) : resolve()));
}); });
}); });
} }
@ -30,13 +28,17 @@ try {
console.log("Starting container..."); console.log("Starting container...");
const container = await docker.createContainer({ const container = await docker.createContainer({
Image: IMAGE, Image: IMAGE,
Cmd: ["sh", "-c", [ Cmd: [
"apt-get update", "sh",
"DEBIAN_FRONTEND=noninteractive apt-get install -y curl ca-certificates bash libstdc++6", "-c",
"rm -rf /var/lib/apt/lists/*", [
"curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh", "apt-get update",
`sandbox-agent server --no-token --host 0.0.0.0 --port ${PORT}`, "DEBIAN_FRONTEND=noninteractive apt-get install -y curl ca-certificates bash libstdc++6",
].join(" && ")], "rm -rf /var/lib/apt/lists/*",
"curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh",
`sandbox-agent server --no-token --host 0.0.0.0 --port ${PORT}`,
].join(" && "),
],
Env: [ Env: [
process.env.ANTHROPIC_API_KEY ? `ANTHROPIC_API_KEY=${process.env.ANTHROPIC_API_KEY}` : "", process.env.ANTHROPIC_API_KEY ? `ANTHROPIC_API_KEY=${process.env.ANTHROPIC_API_KEY}` : "",
process.env.OPENAI_API_KEY ? `OPENAI_API_KEY=${process.env.OPENAI_API_KEY}` : "", process.env.OPENAI_API_KEY ? `OPENAI_API_KEY=${process.env.OPENAI_API_KEY}` : "",
@ -63,8 +65,12 @@ console.log(" Press Ctrl+C to stop.");
const keepAlive = setInterval(() => {}, 60_000); const keepAlive = setInterval(() => {}, 60_000);
const cleanup = async () => { const cleanup = async () => {
clearInterval(keepAlive); clearInterval(keepAlive);
try { await container.stop({ t: 5 }); } catch {} try {
try { await container.remove({ force: true }); } catch {} await container.stop({ t: 5 });
} catch {}
try {
await container.remove({ force: true });
} catch {}
process.exit(0); process.exit(0);
}; };
process.once("SIGINT", cleanup); process.once("SIGINT", cleanup);

View file

@ -23,6 +23,6 @@ describe("docker example", () => {
await cleanup(); await cleanup();
} }
}, },
timeoutMs timeoutMs,
); );
}); });

View file

@ -23,6 +23,6 @@ describe("e2b example", () => {
await cleanup(); await cleanup();
} }
}, },
timeoutMs timeoutMs,
); );
}); });

View file

@ -24,10 +24,7 @@ console.log("Uploading files via batch tar...");
const client = await SandboxAgent.connect({ baseUrl }); const client = await SandboxAgent.connect({ baseUrl });
const tarPath = path.join(tmpDir, "upload.tar"); const tarPath = path.join(tmpDir, "upload.tar");
await tar.create( await tar.create({ file: tarPath, cwd: tmpDir }, ["my-project"]);
{ file: tarPath, cwd: tmpDir },
["my-project"],
);
const tarBuffer = await fs.promises.readFile(tarPath); const tarBuffer = await fs.promises.readFile(tarPath);
const uploadResult = await client.uploadFsBatch(tarBuffer, { path: "/opt" }); const uploadResult = await client.uploadFsBatch(tarBuffer, { path: "/opt" });
console.log(` Uploaded ${uploadResult.paths.length} files: ${uploadResult.paths.join(", ")}`); console.log(` Uploaded ${uploadResult.paths.length} files: ${uploadResult.paths.join(", ")}`);
@ -54,4 +51,7 @@ console.log(' Try: "read the README in /opt/my-project"');
console.log(" Press Ctrl+C to stop."); console.log(" Press Ctrl+C to stop.");
const keepAlive = setInterval(() => {}, 60_000); const keepAlive = setInterval(() => {}, 60_000);
process.on("SIGINT", () => { clearInterval(keepAlive); cleanup().then(() => process.exit(0)); }); process.on("SIGINT", () => {
clearInterval(keepAlive);
cleanup().then(() => process.exit(0));
});

View file

@ -23,10 +23,7 @@ console.log("Uploading MCP server bundle...");
const client = await SandboxAgent.connect({ baseUrl }); const client = await SandboxAgent.connect({ baseUrl });
const bundle = await fs.promises.readFile(serverFile); const bundle = await fs.promises.readFile(serverFile);
const written = await client.writeFsFile( const written = await client.writeFsFile({ path: "/opt/mcp/custom-tools/mcp-server.cjs" }, bundle);
{ path: "/opt/mcp/custom-tools/mcp-server.cjs" },
bundle,
);
console.log(` Written: ${written.path} (${written.bytesWritten} bytes)`); console.log(` Written: ${written.path} (${written.bytesWritten} bytes)`);
// Create a session with the uploaded MCP server as a local command. // Create a session with the uploaded MCP server as a local command.
@ -35,12 +32,14 @@ const session = await client.createSession({
agent: detectAgent(), agent: detectAgent(),
sessionInit: { sessionInit: {
cwd: "/root", cwd: "/root",
mcpServers: [{ mcpServers: [
name: "customTools", {
command: "node", name: "customTools",
args: ["/opt/mcp/custom-tools/mcp-server.cjs"], command: "node",
env: [], args: ["/opt/mcp/custom-tools/mcp-server.cjs"],
}], env: [],
},
],
}, },
}); });
const sessionId = session.id; const sessionId = session.id;
@ -49,4 +48,7 @@ console.log(' Try: "generate a random number between 1 and 100"');
console.log(" Press Ctrl+C to stop."); console.log(" Press Ctrl+C to stop.");
const keepAlive = setInterval(() => {}, 60_000); const keepAlive = setInterval(() => {}, 60_000);
process.on("SIGINT", () => { clearInterval(keepAlive); cleanup().then(() => process.exit(0)); }); process.on("SIGINT", () => {
clearInterval(keepAlive);
cleanup().then(() => process.exit(0));
});

View file

@ -5,9 +5,7 @@ import { startDockerSandbox } from "@sandbox-agent/example-shared/docker";
console.log("Starting sandbox..."); console.log("Starting sandbox...");
const { baseUrl, cleanup } = await startDockerSandbox({ const { baseUrl, cleanup } = await startDockerSandbox({
port: 3002, port: 3002,
setupCommands: [ setupCommands: ["npm install -g --silent @modelcontextprotocol/server-everything@2026.1.26"],
"npm install -g --silent @modelcontextprotocol/server-everything@2026.1.26",
],
}); });
console.log("Creating session with everything MCP server..."); console.log("Creating session with everything MCP server...");
@ -16,12 +14,14 @@ const session = await client.createSession({
agent: detectAgent(), agent: detectAgent(),
sessionInit: { sessionInit: {
cwd: "/root", cwd: "/root",
mcpServers: [{ mcpServers: [
name: "everything", {
command: "mcp-server-everything", name: "everything",
args: [], command: "mcp-server-everything",
env: [], args: [],
}], env: [],
},
],
}, },
}); });
const sessionId = session.id; const sessionId = session.id;
@ -30,4 +30,7 @@ console.log(' Try: "generate a random number between 1 and 100"');
console.log(" Press Ctrl+C to stop."); console.log(" Press Ctrl+C to stop.");
const keepAlive = setInterval(() => {}, 60_000); const keepAlive = setInterval(() => {}, 60_000);
process.on("SIGINT", () => { clearInterval(keepAlive); cleanup().then(() => process.exit(0)); }); process.on("SIGINT", () => {
clearInterval(keepAlive);
cleanup().then(() => process.exit(0));
});

View file

@ -1,18 +1,12 @@
import { createInterface } from "node:readline/promises"; import { createInterface } from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process"; import { stdin as input, stdout as output } from "node:process";
import { Command } from "commander"; import { Command } from "commander";
import { import { SandboxAgent, type PermissionReply, type SessionPermissionRequest } from "sandbox-agent";
SandboxAgent,
type PermissionReply,
type SessionPermissionRequest,
} from "sandbox-agent";
const options = parseOptions(); const options = parseOptions();
const agent = options.agent.trim().toLowerCase(); const agent = options.agent.trim().toLowerCase();
const autoReply = parsePermissionReply(options.reply); const autoReply = parsePermissionReply(options.reply);
const promptText = const promptText = options.prompt?.trim() || `Create ./permission-example.txt with the text 'hello from the ${agent} permissions example'.`;
options.prompt?.trim() ||
`Create ./permission-example.txt with the text 'hello from the ${agent} permissions example'.`;
const sdk = await SandboxAgent.start({ const sdk = await SandboxAgent.start({
spawn: { spawn: {
@ -31,11 +25,7 @@ try {
: []; : [];
const modeOption = configOptions.find((option) => option.category === "mode"); const modeOption = configOptions.find((option) => option.category === "mode");
const availableModes = extractOptionValues(modeOption); const availableModes = extractOptionValues(modeOption);
const mode = const mode = options.mode?.trim() || (typeof modeOption?.currentValue === "string" ? modeOption.currentValue : "") || availableModes[0] || "";
options.mode?.trim() ||
(typeof modeOption?.currentValue === "string" ? modeOption.currentValue : "") ||
availableModes[0] ||
"";
console.log(`Agent: ${agent}`); console.log(`Agent: ${agent}`);
console.log(`Mode: ${mode || "(default)"}`); console.log(`Mode: ${mode || "(default)"}`);
@ -91,10 +81,7 @@ async function handlePermissionRequest(
await session.respondPermission(request.id, reply); await session.respondPermission(request.id, reply);
} }
async function promptForReply( async function promptForReply(request: SessionPermissionRequest, rl: ReturnType<typeof createInterface> | null): Promise<PermissionReply> {
request: SessionPermissionRequest,
rl: ReturnType<typeof createInterface> | null,
): Promise<PermissionReply> {
if (!rl) { if (!rl) {
return "reject"; return "reject";
} }
@ -136,8 +123,7 @@ function extractOptionValues(option: { options?: unknown[] } | undefined): strin
if (!nested || typeof nested !== "object") { if (!nested || typeof nested !== "object") {
continue; continue;
} }
const nestedValue = const nestedValue = "value" in nested && typeof nested.value === "string" ? nested.value : null;
"value" in nested && typeof nested.value === "string" ? nested.value : null;
if (nestedValue) { if (nestedValue) {
values.push(nestedValue); values.push(nestedValue);
} }

View file

@ -7,10 +7,7 @@ const persist = new InMemorySessionPersistDriver();
console.log("Starting sandbox..."); console.log("Starting sandbox...");
const sandbox = await startDockerSandbox({ const sandbox = await startDockerSandbox({
port: 3000, port: 3000,
setupCommands: [ setupCommands: ["sandbox-agent install-agent claude", "sandbox-agent install-agent codex"],
"sandbox-agent install-agent claude",
"sandbox-agent install-agent codex",
],
}); });
const sdk = await SandboxAgent.connect({ baseUrl: sandbox.baseUrl, persist }); const sdk = await SandboxAgent.connect({ baseUrl: sandbox.baseUrl, persist });

View file

@ -16,21 +16,47 @@ if (process.env.DATABASE_URL) {
connectionString = process.env.DATABASE_URL; connectionString = process.env.DATABASE_URL;
} else { } else {
const name = `persist-example-${randomUUID().slice(0, 8)}`; const name = `persist-example-${randomUUID().slice(0, 8)}`;
containerId = execFileSync("docker", [ containerId = execFileSync(
"run", "-d", "--rm", "--name", name, "docker",
"-e", "POSTGRES_USER=postgres", "-e", "POSTGRES_PASSWORD=postgres", "-e", "POSTGRES_DB=sandbox", [
"-p", "127.0.0.1::5432", "postgres:16-alpine", "run",
], { encoding: "utf8" }).trim(); "-d",
"--rm",
"--name",
name,
"-e",
"POSTGRES_USER=postgres",
"-e",
"POSTGRES_PASSWORD=postgres",
"-e",
"POSTGRES_DB=sandbox",
"-p",
"127.0.0.1::5432",
"postgres:16-alpine",
],
{ encoding: "utf8" },
).trim();
const port = execFileSync("docker", ["port", containerId, "5432/tcp"], { encoding: "utf8" }) const port = execFileSync("docker", ["port", containerId, "5432/tcp"], { encoding: "utf8" })
.trim().split("\n")[0]?.match(/:(\d+)$/)?.[1]; .trim()
.split("\n")[0]
?.match(/:(\d+)$/)?.[1];
connectionString = `postgres://postgres:postgres@127.0.0.1:${port}/sandbox`; connectionString = `postgres://postgres:postgres@127.0.0.1:${port}/sandbox`;
console.log(`Postgres on port ${port}`); console.log(`Postgres on port ${port}`);
const deadline = Date.now() + 30_000; const deadline = Date.now() + 30_000;
while (Date.now() < deadline) { while (Date.now() < deadline) {
const c = new Client({ connectionString }); const c = new Client({ connectionString });
try { await c.connect(); await c.query("SELECT 1"); await c.end(); break; } try {
catch { try { await c.end(); } catch {} await delay(250); } await c.connect();
await c.query("SELECT 1");
await c.end();
break;
} catch {
try {
await c.end();
} catch {}
await delay(250);
}
} }
} }
@ -40,10 +66,7 @@ try {
console.log("Starting sandbox..."); console.log("Starting sandbox...");
const sandbox = await startDockerSandbox({ const sandbox = await startDockerSandbox({
port: 3000, port: 3000,
setupCommands: [ setupCommands: ["sandbox-agent install-agent claude", "sandbox-agent install-agent codex"],
"sandbox-agent install-agent claude",
"sandbox-agent install-agent codex",
],
}); });
const sdk = await SandboxAgent.connect({ baseUrl: sandbox.baseUrl, persist }); const sdk = await SandboxAgent.connect({ baseUrl: sandbox.baseUrl, persist });
@ -71,6 +94,8 @@ try {
await sandbox.cleanup(); await sandbox.cleanup();
} finally { } finally {
if (containerId) { if (containerId) {
try { execFileSync("docker", ["rm", "-f", containerId], { stdio: "ignore" }); } catch {} try {
execFileSync("docker", ["rm", "-f", containerId], { stdio: "ignore" });
} catch {}
} }
} }

View file

@ -8,10 +8,7 @@ const persist = new SQLiteSessionPersistDriver({ filename: "./sessions.db" });
console.log("Starting sandbox..."); console.log("Starting sandbox...");
const sandbox = await startDockerSandbox({ const sandbox = await startDockerSandbox({
port: 3000, port: 3000,
setupCommands: [ setupCommands: ["sandbox-agent install-agent claude", "sandbox-agent install-agent codex"],
"sandbox-agent install-agent claude",
"sandbox-agent install-agent codex",
],
}); });
const sdk = await SandboxAgent.connect({ baseUrl: sandbox.baseUrl, persist }); const sdk = await SandboxAgent.connect({ baseUrl: sandbox.baseUrl, persist });

View file

@ -40,7 +40,7 @@ const DIRECT_CREDENTIAL_KEYS = [
function stripShellQuotes(value: string): string { function stripShellQuotes(value: string): string {
const trimmed = value.trim(); const trimmed = value.trim();
if (trimmed.length >= 2 && trimmed.startsWith("\"") && trimmed.endsWith("\"")) { if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) {
return trimmed.slice(1, -1); return trimmed.slice(1, -1);
} }
if (trimmed.length >= 2 && trimmed.startsWith("'") && trimmed.endsWith("'")) { if (trimmed.length >= 2 && trimmed.startsWith("'") && trimmed.endsWith("'")) {
@ -107,11 +107,7 @@ function collectCredentialEnv(): Record<string, string> {
const merged: Record<string, string> = {}; const merged: Record<string, string> = {};
let extracted: Record<string, string> = {}; let extracted: Record<string, string> = {};
try { try {
const output = execFileSync( const output = execFileSync("sandbox-agent", ["credentials", "extract-env"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
"sandbox-agent",
["credentials", "extract-env"],
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
);
extracted = parseExtractedCredentials(output); extracted = parseExtractedCredentials(output);
} catch { } catch {
// Fall back to direct env vars if extraction is unavailable. // Fall back to direct env vars if extraction is unavailable.
@ -132,10 +128,7 @@ function shellSingleQuotedLiteral(value: string): string {
} }
function stripAnsi(value: string): string { function stripAnsi(value: string): string {
return value.replace( return value.replace(/[\u001B\u009B][[\]()#;?]*(?:(?:[a-zA-Z\d]*(?:;[a-zA-Z\d]*)*)?\u0007|(?:\d{1,4}(?:;\d{0,4})*)?[0-9A-ORZcf-nqry=><])/g, "");
/[\u001B\u009B][[\]()#;?]*(?:(?:[a-zA-Z\d]*(?:;[a-zA-Z\d]*)*)?\u0007|(?:\d{1,4}(?:;\d{0,4})*)?[0-9A-ORZcf-nqry=><])/g,
"",
);
} }
async function ensureExampleImage(_docker: Docker): Promise<string> { async function ensureExampleImage(_docker: Docker): Promise<string> {
@ -145,11 +138,7 @@ async function ensureExampleImage(_docker: Docker): Promise<string> {
if (dev) { if (dev) {
console.log(" Building sandbox image from source (may take a while, only runs once)..."); console.log(" Building sandbox image from source (may take a while, only runs once)...");
try { try {
execFileSync("docker", [ execFileSync("docker", ["build", "-t", imageName, "-f", path.join(DOCKERFILE_DIR, "Dockerfile.dev"), REPO_ROOT], {
"build", "-t", imageName,
"-f", path.join(DOCKERFILE_DIR, "Dockerfile.dev"),
REPO_ROOT,
], {
stdio: ["ignore", "ignore", "pipe"], stdio: ["ignore", "ignore", "pipe"],
}); });
} catch (err: unknown) { } catch (err: unknown) {
@ -224,19 +213,13 @@ export async function startDockerSandbox(opts: DockerSandboxOptions): Promise<Do
image = await ensureExampleImage(docker); image = await ensureExampleImage(docker);
} }
const bootCommands = [ const bootCommands = [...setupCommands, `sandbox-agent server --no-token --host 0.0.0.0 --port ${port}`];
...setupCommands,
`sandbox-agent server --no-token --host 0.0.0.0 --port ${port}`,
];
const container = await docker.createContainer({ const container = await docker.createContainer({
Image: image, Image: image,
WorkingDir: "/root", WorkingDir: "/root",
Cmd: ["sh", "-c", bootCommands.join(" && ")], Cmd: ["sh", "-c", bootCommands.join(" && ")],
Env: [ Env: [...Object.entries(credentialEnv).map(([key, value]) => `${key}=${value}`), ...Object.entries(bootstrapEnv).map(([key, value]) => `${key}=${value}`)],
...Object.entries(credentialEnv).map(([key, value]) => `${key}=${value}`),
...Object.entries(bootstrapEnv).map(([key, value]) => `${key}=${value}`),
],
ExposedPorts: { [`${port}/tcp`]: {} }, ExposedPorts: { [`${port}/tcp`]: {} },
HostConfig: { HostConfig: {
AutoRemove: true, AutoRemove: true,
@ -246,12 +229,12 @@ export async function startDockerSandbox(opts: DockerSandboxOptions): Promise<Do
await container.start(); await container.start();
const logChunks: string[] = []; const logChunks: string[] = [];
const startupLogs = await container.logs({ const startupLogs = (await container.logs({
follow: true, follow: true,
stdout: true, stdout: true,
stderr: true, stderr: true,
since: 0, since: 0,
}) as NodeJS.ReadableStream; })) as NodeJS.ReadableStream;
const stdoutStream = new PassThrough(); const stdoutStream = new PassThrough();
const stderrStream = new PassThrough(); const stderrStream = new PassThrough();
stdoutStream.on("data", (chunk) => { stdoutStream.on("data", (chunk) => {
@ -263,7 +246,9 @@ export async function startDockerSandbox(opts: DockerSandboxOptions): Promise<Do
docker.modem.demuxStream(startupLogs, stdoutStream, stderrStream); docker.modem.demuxStream(startupLogs, stdoutStream, stderrStream);
const stopStartupLogs = () => { const stopStartupLogs = () => {
const stream = startupLogs as NodeJS.ReadableStream & { destroy?: () => void }; const stream = startupLogs as NodeJS.ReadableStream & { destroy?: () => void };
try { stream.destroy?.(); } catch {} try {
stream.destroy?.();
} catch {}
}; };
const inspect = await container.inspect(); const inspect = await container.inspect();
@ -279,8 +264,12 @@ export async function startDockerSandbox(opts: DockerSandboxOptions): Promise<Do
const cleanup = async () => { const cleanup = async () => {
stopStartupLogs(); stopStartupLogs();
try { await container.stop({ t: 5 }); } catch {} try {
try { await container.remove({ force: true }); } catch {} await container.stop({ t: 5 });
} catch {}
try {
await container.remove({ force: true });
} catch {}
process.exit(0); process.exit(0);
}; };
process.once("SIGINT", cleanup); process.once("SIGINT", cleanup);

View file

@ -41,15 +41,7 @@ export function buildInspectorUrl({
return `${normalized}/ui/${sessionPath}${queryString ? `?${queryString}` : ""}`; return `${normalized}/ui/${sessionPath}${queryString ? `?${queryString}` : ""}`;
} }
export function logInspectorUrl({ export function logInspectorUrl({ baseUrl, token, headers }: { baseUrl: string; token?: string; headers?: Record<string, string> }): void {
baseUrl,
token,
headers,
}: {
baseUrl: string;
token?: string;
headers?: Record<string, string>;
}): void {
console.log(`Inspector: ${buildInspectorUrl({ baseUrl, token, headers })}`); console.log(`Inspector: ${buildInspectorUrl({ baseUrl, token, headers })}`);
} }
@ -84,10 +76,7 @@ export function generateSessionId(): string {
export function detectAgent(): string { export function detectAgent(): string {
if (process.env.SANDBOX_AGENT) return process.env.SANDBOX_AGENT; if (process.env.SANDBOX_AGENT) return process.env.SANDBOX_AGENT;
const hasClaude = Boolean( const hasClaude = Boolean(
process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY || process.env.CLAUDE_API_KEY || process.env.CLAUDE_CODE_OAUTH_TOKEN || process.env.ANTHROPIC_AUTH_TOKEN,
process.env.CLAUDE_API_KEY ||
process.env.CLAUDE_CODE_OAUTH_TOKEN ||
process.env.ANTHROPIC_AUTH_TOKEN,
); );
const openAiLikeKey = process.env.OPENAI_API_KEY || process.env.CODEX_API_KEY || ""; const openAiLikeKey = process.env.OPENAI_API_KEY || process.env.CODEX_API_KEY || "";
const hasCodexApiKey = openAiLikeKey.startsWith("sk-"); const hasCodexApiKey = openAiLikeKey.startsWith("sk-");

View file

@ -23,25 +23,16 @@ console.log("Uploading script and skill file...");
const client = await SandboxAgent.connect({ baseUrl }); const client = await SandboxAgent.connect({ baseUrl });
const script = await fs.promises.readFile(scriptFile); const script = await fs.promises.readFile(scriptFile);
const scriptResult = await client.writeFsFile( const scriptResult = await client.writeFsFile({ path: "/opt/skills/random-number/random-number.cjs" }, script);
{ path: "/opt/skills/random-number/random-number.cjs" },
script,
);
console.log(` Script: ${scriptResult.path} (${scriptResult.bytesWritten} bytes)`); console.log(` Script: ${scriptResult.path} (${scriptResult.bytesWritten} bytes)`);
const skillMd = await fs.promises.readFile(path.resolve(__dirname, "../SKILL.md")); const skillMd = await fs.promises.readFile(path.resolve(__dirname, "../SKILL.md"));
const skillResult = await client.writeFsFile( const skillResult = await client.writeFsFile({ path: "/opt/skills/random-number/SKILL.md" }, skillMd);
{ path: "/opt/skills/random-number/SKILL.md" },
skillMd,
);
console.log(` Skill: ${skillResult.path} (${skillResult.bytesWritten} bytes)`); console.log(` Skill: ${skillResult.path} (${skillResult.bytesWritten} bytes)`);
// Configure the uploaded skill. // Configure the uploaded skill.
console.log("Configuring custom skill..."); console.log("Configuring custom skill...");
await client.setSkillsConfig( await client.setSkillsConfig({ directory: "/", skillName: "random-number" }, { sources: [{ type: "local", source: "/opt/skills/random-number" }] });
{ directory: "/", skillName: "random-number" },
{ sources: [{ type: "local", source: "/opt/skills/random-number" }] },
);
// Create a session. // Create a session.
console.log("Creating session with custom skill..."); console.log("Creating session with custom skill...");
@ -52,4 +43,7 @@ console.log(' Try: "generate a random number between 1 and 100"');
console.log(" Press Ctrl+C to stop."); console.log(" Press Ctrl+C to stop.");
const keepAlive = setInterval(() => {}, 60_000); const keepAlive = setInterval(() => {}, 60_000);
process.on("SIGINT", () => { clearInterval(keepAlive); cleanup().then(() => process.exit(0)); }); process.on("SIGINT", () => {
clearInterval(keepAlive);
cleanup().then(() => process.exit(0));
});

View file

@ -22,4 +22,7 @@ console.log(' Try: "How do I start sandbox-agent?"');
console.log(" Press Ctrl+C to stop."); console.log(" Press Ctrl+C to stop.");
const keepAlive = setInterval(() => {}, 60_000); const keepAlive = setInterval(() => {}, 60_000);
process.on("SIGINT", () => { clearInterval(keepAlive); cleanup().then(() => process.exit(0)); }); process.on("SIGINT", () => {
clearInterval(keepAlive);
cleanup().then(() => process.exit(0));
});

View file

@ -23,6 +23,6 @@ describe("vercel example", () => {
await cleanup(); await cleanup();
} }
}, },
timeoutMs timeoutMs,
); );
}); });

View file

@ -1,10 +1,7 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2022", "target": "ES2022",
"lib": [ "lib": ["ES2022", "DOM"],
"ES2022",
"DOM"
],
"module": "ESNext", "module": "ESNext",
"moduleResolution": "Bundler", "moduleResolution": "Bundler",
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
@ -14,11 +11,6 @@
"skipLibCheck": true, "skipLibCheck": true,
"resolveJsonModule": true "resolveJsonModule": true
}, },
"include": [ "include": ["src/**/*"],
"src/**/*" "exclude": ["node_modules", "**/*.test.ts"]
],
"exclude": [
"node_modules",
"**/*.test.ts"
]
} }

View file

@ -94,7 +94,7 @@ async function generateOne(drizzleDir: string): Promise<void> {
})), })),
}, },
null, null,
2 2,
); );
const outPath = resolve(drizzleDir, "..", "migrations.ts"); const outPath = resolve(drizzleDir, "..", "migrations.ts");
@ -128,9 +128,8 @@ async function main(): Promise<void> {
} }
main().catch((error: unknown) => { 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 // eslint-disable-next-line no-console
console.error(message); console.error(message);
process.exitCode = 1; process.exitCode = 1;
}); });

View file

@ -8,12 +8,7 @@ let providerRegistry: ProviderRegistry | null = null;
let notificationService: NotificationService | null = null; let notificationService: NotificationService | null = null;
let runtimeDriver: BackendDriver | null = null; let runtimeDriver: BackendDriver | null = null;
export function initActorRuntimeContext( export function initActorRuntimeContext(config: AppConfig, providers: ProviderRegistry, notifications?: NotificationService, driver?: BackendDriver): void {
config: AppConfig,
providers: ProviderRegistry,
notifications?: NotificationService,
driver?: BackendDriver
): void {
runtimeConfig = config; runtimeConfig = config;
providerRegistry = providers; providerRegistry = providers;
notificationService = notifications ?? null; notificationService = notifications ?? null;

View file

@ -1,13 +1,4 @@
import { import { handoffKey, handoffStatusSyncKey, historyKey, projectBranchSyncKey, projectKey, projectPrSyncKey, sandboxInstanceKey, workspaceKey } from "./keys.js";
handoffKey,
handoffStatusSyncKey,
historyKey,
projectBranchSyncKey,
projectKey,
projectPrSyncKey,
sandboxInstanceKey,
workspaceKey
} from "./keys.js";
import type { ProviderId } from "@openhandoff/shared"; import type { ProviderId } from "@openhandoff/shared";
export function actorClient(c: any) { export function actorClient(c: any) {
@ -16,7 +7,7 @@ export function actorClient(c: any) {
export async function getOrCreateWorkspace(c: any, workspaceId: string) { export async function getOrCreateWorkspace(c: any, workspaceId: string) {
return await actorClient(c).workspace.getOrCreate(workspaceKey(workspaceId), { 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: { createWithInput: {
workspaceId, workspaceId,
repoId, 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)); return actorClient(c).handoff.get(handoffKey(workspaceId, repoId, handoffId));
} }
export async function getOrCreateHandoff( export async function getOrCreateHandoff(c: any, workspaceId: string, repoId: string, handoffId: string, createWithInput: Record<string, unknown>) {
c: any,
workspaceId: string,
repoId: string,
handoffId: string,
createWithInput: Record<string, unknown>
) {
return await actorClient(c).handoff.getOrCreate(handoffKey(workspaceId, repoId, handoffId), { 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), { return await actorClient(c).history.getOrCreate(historyKey(workspaceId, repoId), {
createWithInput: { createWithInput: {
workspaceId, workspaceId,
repoId repoId,
} },
}); });
} }
export async function getOrCreateProjectPrSync( export async function getOrCreateProjectPrSync(c: any, workspaceId: string, repoId: string, repoPath: string, intervalMs: number) {
c: any,
workspaceId: string,
repoId: string,
repoPath: string,
intervalMs: number
) {
return await actorClient(c).projectPrSync.getOrCreate(projectPrSyncKey(workspaceId, repoId), { return await actorClient(c).projectPrSync.getOrCreate(projectPrSyncKey(workspaceId, repoId), {
createWithInput: { createWithInput: {
workspaceId, workspaceId,
repoId, repoId,
repoPath, repoPath,
intervalMs intervalMs,
} },
}); });
} }
export async function getOrCreateProjectBranchSync( export async function getOrCreateProjectBranchSync(c: any, workspaceId: string, repoId: string, repoPath: string, intervalMs: number) {
c: any,
workspaceId: string,
repoId: string,
repoPath: string,
intervalMs: number
) {
return await actorClient(c).projectBranchSync.getOrCreate(projectBranchSyncKey(workspaceId, repoId), { return await actorClient(c).projectBranchSync.getOrCreate(projectBranchSyncKey(workspaceId, repoId), {
createWithInput: { createWithInput: {
workspaceId, workspaceId,
repoId, repoId,
repoPath, repoPath,
intervalMs intervalMs,
} },
}); });
} }
@ -102,12 +75,9 @@ export async function getOrCreateSandboxInstance(
workspaceId: string, workspaceId: string,
providerId: ProviderId, providerId: ProviderId,
sandboxId: string, sandboxId: string,
createWithInput: Record<string, unknown> createWithInput: Record<string, unknown>,
) { ) {
return await actorClient(c).sandboxInstance.getOrCreate( return await actorClient(c).sandboxInstance.getOrCreate(sandboxInstanceKey(workspaceId, providerId, sandboxId), { createWithInput });
sandboxInstanceKey(workspaceId, providerId, sandboxId),
{ createWithInput }
);
} }
export async function getOrCreateHandoffStatusSync( export async function getOrCreateHandoffStatusSync(
@ -117,14 +87,11 @@ export async function getOrCreateHandoffStatusSync(
handoffId: string, handoffId: string,
sandboxId: string, sandboxId: string,
sessionId: string, sessionId: string,
createWithInput: Record<string, unknown> createWithInput: Record<string, unknown>,
) { ) {
return await actorClient(c).handoffStatusSync.getOrCreate( return await actorClient(c).handoffStatusSync.getOrCreate(handoffStatusSyncKey(workspaceId, repoId, handoffId, sandboxId, sessionId), {
handoffStatusSyncKey(workspaceId, repoId, handoffId, sandboxId, sessionId), createWithInput,
{ });
createWithInput
}
);
} }
export function selfProjectPrSync(c: any) { export function selfProjectPrSync(c: any) {

View file

@ -32,7 +32,7 @@ const CONTROL = {
start: "handoff.status_sync.control.start", start: "handoff.status_sync.control.start",
stop: "handoff.status_sync.control.stop", stop: "handoff.status_sync.control.stop",
setInterval: "handoff.status_sync.control.set_interval", setInterval: "handoff.status_sync.control.set_interval",
force: "handoff.status_sync.control.force" force: "handoff.status_sync.control.force",
} as const; } as const;
async function pollSessionStatus(c: { state: HandoffStatusSyncState }): Promise<void> { async function pollSessionStatus(c: { state: HandoffStatusSyncState }): Promise<void> {
@ -43,7 +43,7 @@ async function pollSessionStatus(c: { state: HandoffStatusSyncState }): Promise<
await parent.syncWorkbenchSessionStatus({ await parent.syncWorkbenchSessionStatus({
sessionId: c.state.sessionId, sessionId: c.state.sessionId,
status: status.status, status: status.status,
at: Date.now() at: Date.now(),
}); });
} }
@ -56,7 +56,7 @@ export const handoffStatusSync = actor({
}, },
options: { options: {
// Polling actors rely on timer-based wakeups; sleeping would pause the timer and stop polling. // Polling actors rely on timer-based wakeups; sleeping would pause the timer and stop polling.
noSleep: true noSleep: true,
}, },
createState: (_c, input: HandoffStatusSyncInput): HandoffStatusSyncState => ({ createState: (_c, input: HandoffStatusSyncInput): HandoffStatusSyncState => ({
workspaceId: input.workspaceId, workspaceId: input.workspaceId,
@ -66,7 +66,7 @@ export const handoffStatusSync = actor({
sandboxId: input.sandboxId, sandboxId: input.sandboxId,
sessionId: input.sessionId, sessionId: input.sessionId,
intervalMs: input.intervalMs, intervalMs: input.intervalMs,
running: true running: true,
}), }),
actions: { actions: {
async start(c): Promise<void> { async start(c): Promise<void> {
@ -87,7 +87,7 @@ export const handoffStatusSync = actor({
async force(c): Promise<void> { async force(c): Promise<void> {
const self = selfHandoffStatusSync(c); const self = selfHandoffStatusSync(c);
await self.send(CONTROL.force, {}, { wait: true, timeout: 5 * 60_000 }); await self.send(CONTROL.force, {}, { wait: true, timeout: 5 * 60_000 });
} },
}, },
run: workflow(async (ctx) => { run: workflow(async (ctx) => {
await runWorkflowPollingLoop<HandoffStatusSyncState>(ctx, { await runWorkflowPollingLoop<HandoffStatusSyncState>(ctx, {
@ -99,10 +99,10 @@ export const handoffStatusSync = actor({
} catch (error) { } catch (error) {
logActorWarning("handoff-status-sync", "poll failed", { logActorWarning("handoff-status-sync", "poll failed", {
error: resolveErrorMessage(error), error: resolveErrorMessage(error),
stack: resolveErrorStack(error) stack: resolveErrorStack(error),
}); });
} }
} },
}); });
}) }),
}); });

View file

@ -4,4 +4,3 @@ export default defineConfig({
out: "./src/actors/handoff/db/drizzle", out: "./src/actors/handoff/db/drizzle",
schema: "./src/actors/handoff/db/schema.ts", schema: "./src/actors/handoff/db/schema.ts",
}); });

View file

@ -173,4 +173,4 @@
"internal": { "internal": {
"indexes": {} "indexes": {}
} }
} }

View file

@ -149,4 +149,4 @@
"internal": { "internal": {
"indexes": {} "indexes": {}
} }
} }

View file

@ -219,4 +219,4 @@
"internal": { "internal": {
"indexes": {} "indexes": {}
} }
} }

View file

@ -3,50 +3,50 @@
// Do not hand-edit this file. // Do not hand-edit this file.
const journal = { const journal = {
"entries": [ entries: [
{ {
"idx": 0, idx: 0,
"when": 1770924374665, when: 1770924374665,
"tag": "0000_condemned_maria_hill", tag: "0000_condemned_maria_hill",
"breakpoints": true breakpoints: true,
}, },
{ {
"idx": 1, idx: 1,
"when": 1770947251055, when: 1770947251055,
"tag": "0001_rapid_eddie_brock", tag: "0001_rapid_eddie_brock",
"breakpoints": true breakpoints: true,
}, },
{ {
"idx": 2, idx: 2,
"when": 1770948428907, when: 1770948428907,
"tag": "0002_lazy_moira_mactaggert", tag: "0002_lazy_moira_mactaggert",
"breakpoints": true breakpoints: true,
}, },
{ {
"idx": 3, idx: 3,
"when": 1771027535276, when: 1771027535276,
"tag": "0003_plucky_bran", tag: "0003_plucky_bran",
"breakpoints": true breakpoints: true,
}, },
{ {
"idx": 4, idx: 4,
"when": 1771097651912, when: 1771097651912,
"tag": "0004_focused_shuri", tag: "0004_focused_shuri",
"breakpoints": true breakpoints: true,
}, },
{ {
"idx": 5, idx: 5,
"when": 1771370000000, when: 1771370000000,
"tag": "0005_sandbox_actor_id", tag: "0005_sandbox_actor_id",
"breakpoints": true breakpoints: true,
}, },
{ {
"idx": 6, idx: 6,
"when": 1773020000000, when: 1773020000000,
"tag": "0006_workbench_sessions", tag: "0006_workbench_sessions",
"breakpoints": true breakpoints: true,
} },
] ],
} as const; } as const;
export default { export default {
@ -241,5 +241,5 @@ PRAGMA foreign_keys=on;
\`created_at\` integer NOT NULL, \`created_at\` integer NOT NULL,
\`updated_at\` integer NOT NULL \`updated_at\` integer NOT NULL
);`, );`,
} as const } as const,
}; };

View file

@ -9,7 +9,7 @@ import type {
HandoffWorkbenchSetSessionUnreadInput, HandoffWorkbenchSetSessionUnreadInput,
HandoffWorkbenchSendMessageInput, HandoffWorkbenchSendMessageInput,
HandoffWorkbenchUpdateDraftInput, HandoffWorkbenchUpdateDraftInput,
ProviderId ProviderId,
} from "@openhandoff/shared"; } from "@openhandoff/shared";
import { expectQueueResponse } from "../../services/queue.js"; import { expectQueueResponse } from "../../services/queue.js";
import { selfHandoff } from "../handles.js"; import { selfHandoff } from "../handles.js";
@ -30,13 +30,9 @@ import {
syncWorkbenchSessionStatus, syncWorkbenchSessionStatus,
setWorkbenchSessionUnread, setWorkbenchSessionUnread,
stopWorkbenchSession, stopWorkbenchSession,
updateWorkbenchDraft updateWorkbenchDraft,
} from "./workbench.js"; } from "./workbench.js";
import { import { HANDOFF_QUEUE_NAMES, handoffWorkflowQueueName, runHandoffWorkflow } from "./workflow/index.js";
HANDOFF_QUEUE_NAMES,
handoffWorkflowQueueName,
runHandoffWorkflow
} from "./workflow/index.js";
export interface HandoffInput { export interface HandoffInput {
workspaceId: string; workspaceId: string;
@ -114,7 +110,7 @@ export const handoff = actor({
db: handoffDb, db: handoffDb,
queues: Object.fromEntries(HANDOFF_QUEUE_NAMES.map((name) => [name, queue()])), queues: Object.fromEntries(HANDOFF_QUEUE_NAMES.map((name) => [name, queue()])),
options: { options: {
actionTimeout: 5 * 60_000 actionTimeout: 5 * 60_000,
}, },
createState: (_c, input: HandoffInput) => ({ createState: (_c, input: HandoffInput) => ({
workspaceId: input.workspaceId, workspaceId: input.workspaceId,
@ -155,17 +151,21 @@ export const handoff = actor({
const self = selfHandoff(c); const self = selfHandoff(c);
const result = await self.send(handoffWorkflowQueueName("handoff.command.attach"), cmd ?? {}, { const result = await self.send(handoffWorkflowQueueName("handoff.command.attach"), cmd ?? {}, {
wait: true, wait: true,
timeout: 20_000 timeout: 20_000,
}); });
return expectQueueResponse<{ target: string; sessionId: string | null }>(result); return expectQueueResponse<{ target: string; sessionId: string | null }>(result);
}, },
async switch(c): Promise<{ switchTarget: string }> { async switch(c): Promise<{ switchTarget: string }> {
const self = selfHandoff(c); const self = selfHandoff(c);
const result = await self.send(handoffWorkflowQueueName("handoff.command.switch"), {}, { const result = await self.send(
wait: true, handoffWorkflowQueueName("handoff.command.switch"),
timeout: 20_000 {},
}); {
wait: true,
timeout: 20_000,
},
);
return expectQueueResponse<{ switchTarget: string }>(result); return expectQueueResponse<{ switchTarget: string }>(result);
}, },
@ -173,7 +173,7 @@ export const handoff = actor({
const self = selfHandoff(c); const self = selfHandoff(c);
await self.send(handoffWorkflowQueueName("handoff.command.push"), cmd ?? {}, { await self.send(handoffWorkflowQueueName("handoff.command.push"), cmd ?? {}, {
wait: true, wait: true,
timeout: 180_000 timeout: 180_000,
}); });
}, },
@ -181,7 +181,7 @@ export const handoff = actor({
const self = selfHandoff(c); const self = selfHandoff(c);
await self.send(handoffWorkflowQueueName("handoff.command.sync"), cmd ?? {}, { await self.send(handoffWorkflowQueueName("handoff.command.sync"), cmd ?? {}, {
wait: true, wait: true,
timeout: 30_000 timeout: 30_000,
}); });
}, },
@ -189,7 +189,7 @@ export const handoff = actor({
const self = selfHandoff(c); const self = selfHandoff(c);
await self.send(handoffWorkflowQueueName("handoff.command.merge"), cmd ?? {}, { await self.send(handoffWorkflowQueueName("handoff.command.merge"), cmd ?? {}, {
wait: true, wait: true,
timeout: 30_000 timeout: 30_000,
}); });
}, },
@ -212,7 +212,7 @@ export const handoff = actor({
const self = selfHandoff(c); const self = selfHandoff(c);
await self.send(handoffWorkflowQueueName("handoff.command.kill"), cmd ?? {}, { await self.send(handoffWorkflowQueueName("handoff.command.kill"), cmd ?? {}, {
wait: true, wait: true,
timeout: 60_000 timeout: 60_000,
}); });
}, },
@ -225,18 +225,10 @@ export const handoff = actor({
}, },
async markWorkbenchUnread(c): Promise<void> { 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); const self = selfHandoff(c);
await self.send( await self.send(
handoffWorkflowQueueName("handoff.command.workbench.rename_handoff"), handoffWorkflowQueueName("handoff.command.workbench.mark_unread"),
{ value: input.value } satisfies HandoffWorkbenchValueCommand, {},
{ {
wait: true, wait: true,
timeout: 20_000, 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> { async renameWorkbenchBranch(c, input: HandoffWorkbenchRenameInput): Promise<void> {
const self = selfHandoff(c); const self = selfHandoff(c);
await self.send( await self.send(handoffWorkflowQueueName("handoff.command.workbench.rename_branch"), { value: input.value } satisfies HandoffWorkbenchValueCommand, {
handoffWorkflowQueueName("handoff.command.workbench.rename_branch"), wait: true,
{ value: input.value } satisfies HandoffWorkbenchValueCommand, timeout: 5 * 60_000,
{ });
wait: true,
timeout: 5 * 60_000,
},
);
}, },
async createWorkbenchSession(c, input?: { model?: string }): Promise<{ tabId: string }> { async createWorkbenchSession(c, input?: { model?: string }): Promise<{ tabId: string }> {
@ -339,26 +335,18 @@ export const handoff = actor({
async stopWorkbenchSession(c, input: HandoffTabCommand): Promise<void> { async stopWorkbenchSession(c, input: HandoffTabCommand): Promise<void> {
const self = selfHandoff(c); const self = selfHandoff(c);
await self.send( await self.send(handoffWorkflowQueueName("handoff.command.workbench.stop_session"), { sessionId: input.tabId } satisfies HandoffWorkbenchSessionCommand, {
handoffWorkflowQueueName("handoff.command.workbench.stop_session"), wait: true,
{ sessionId: input.tabId } satisfies HandoffWorkbenchSessionCommand, timeout: 5 * 60_000,
{ });
wait: true,
timeout: 5 * 60_000,
},
);
}, },
async syncWorkbenchSessionStatus(c, input: HandoffStatusSyncCommand): Promise<void> { async syncWorkbenchSessionStatus(c, input: HandoffStatusSyncCommand): Promise<void> {
const self = selfHandoff(c); const self = selfHandoff(c);
await self.send( await self.send(handoffWorkflowQueueName("handoff.command.workbench.sync_session_status"), input, {
handoffWorkflowQueueName("handoff.command.workbench.sync_session_status"), wait: true,
input, timeout: 20_000,
{ });
wait: true,
timeout: 20_000,
},
);
}, },
async closeWorkbenchSession(c, input: HandoffTabCommand): Promise<void> { async closeWorkbenchSession(c, input: HandoffTabCommand): Promise<void> {
@ -375,25 +363,25 @@ export const handoff = actor({
async publishWorkbenchPr(c): Promise<void> { async publishWorkbenchPr(c): Promise<void> {
const self = selfHandoff(c); const self = selfHandoff(c);
await self.send(handoffWorkflowQueueName("handoff.command.workbench.publish_pr"), {}, { await self.send(
wait: true, handoffWorkflowQueueName("handoff.command.workbench.publish_pr"),
timeout: 10 * 60_000, {},
}); {
wait: true,
timeout: 10 * 60_000,
},
);
}, },
async revertWorkbenchFile(c, input: { path: string }): Promise<void> { async revertWorkbenchFile(c, input: { path: string }): Promise<void> {
const self = selfHandoff(c); const self = selfHandoff(c);
await self.send( await self.send(handoffWorkflowQueueName("handoff.command.workbench.revert_file"), input, {
handoffWorkflowQueueName("handoff.command.workbench.revert_file"), wait: true,
input, timeout: 5 * 60_000,
{ });
wait: true, },
timeout: 5 * 60_000,
},
);
}
}, },
run: workflow(runHandoffWorkflow) run: workflow(runHandoffWorkflow),
}); });
export { HANDOFF_QUEUE_NAMES }; export { HANDOFF_QUEUE_NAMES };

View file

@ -2,12 +2,7 @@
import { basename } from "node:path"; import { basename } from "node:path";
import { asc, eq } from "drizzle-orm"; import { asc, eq } from "drizzle-orm";
import { getActorRuntimeContext } from "../context.js"; import { getActorRuntimeContext } from "../context.js";
import { import { getOrCreateHandoffStatusSync, getOrCreateProject, getOrCreateWorkspace, getSandboxInstance } from "../handles.js";
getOrCreateHandoffStatusSync,
getOrCreateProject,
getOrCreateWorkspace,
getSandboxInstance,
} from "../handles.js";
import { handoff as handoffTable, handoffRuntime, handoffWorkbenchSessions } from "./db/schema.js"; import { handoff as handoffTable, handoffRuntime, handoffWorkbenchSessions } from "./db/schema.js";
import { getCurrentRecord } from "./workflow/common.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>> { async function listSessionMetaRows(c: any, options?: { includeClosed?: boolean }): Promise<Array<any>> {
await ensureWorkbenchSessionTable(c); await ensureWorkbenchSessionTable(c);
const rows = await c.db const rows = await c.db.select().from(handoffWorkbenchSessions).orderBy(asc(handoffWorkbenchSessions.createdAt)).all();
.select()
.from(handoffWorkbenchSessions)
.orderBy(asc(handoffWorkbenchSessions.createdAt))
.all();
const mapped = rows.map((row: any) => ({ const mapped = rows.map((row: any) => ({
...row, ...row,
id: row.sessionId, id: row.sessionId,
@ -120,11 +111,7 @@ async function nextSessionName(c: any): Promise<string> {
async function readSessionMeta(c: any, sessionId: string): Promise<any | null> { async function readSessionMeta(c: any, sessionId: string): Promise<any | null> {
await ensureWorkbenchSessionTable(c); await ensureWorkbenchSessionTable(c);
const row = await c.db const row = await c.db.select().from(handoffWorkbenchSessions).where(eq(handoffWorkbenchSessions.sessionId, sessionId)).get();
.select()
.from(handoffWorkbenchSessions)
.where(eq(handoffWorkbenchSessions.sessionId, sessionId))
.get();
if (!row) { if (!row) {
return null; return null;
@ -142,12 +129,15 @@ async function readSessionMeta(c: any, sessionId: string): Promise<any | null> {
}; };
} }
async function ensureSessionMeta(c: any, params: { async function ensureSessionMeta(
sessionId: string; c: any,
model?: string; params: {
sessionName?: string; sessionId: string;
unread?: boolean; model?: string;
}): Promise<any> { sessionName?: string;
unread?: boolean;
},
): Promise<any> {
await ensureWorkbenchSessionTable(c); await ensureWorkbenchSessionTable(c);
const existing = await readSessionMeta(c, params.sessionId); const existing = await readSessionMeta(c, params.sessionId);
if (existing) { if (existing) {
@ -202,12 +192,15 @@ function shellFragment(parts: string[]): string {
return parts.join(" && "); return parts.join(" && ");
} }
async function executeInSandbox(c: any, params: { async function executeInSandbox(
sandboxId: string; c: any,
cwd: string; params: {
command: string; sandboxId: string;
label: string; cwd: string;
}): Promise<{ exitCode: number; result: string }> { command: string;
label: string;
},
): Promise<{ exitCode: number; result: string }> {
const { providers } = getActorRuntimeContext(); const { providers } = getActorRuntimeContext();
const provider = providers.get(c.state.providerId); const provider = providers.get(c.state.providerId);
return await provider.executeCommand({ return await provider.executeCommand({
@ -226,13 +219,8 @@ function parseGitStatus(output: string): Array<{ path: string; type: "M" | "A" |
.map((line) => { .map((line) => {
const status = line.slice(0, 2).trim(); const status = line.slice(0, 2).trim();
const rawPath = line.slice(3).trim(); const rawPath = line.slice(3).trim();
const path = rawPath.includes(" -> ") ? rawPath.split(" -> ").pop() ?? rawPath : rawPath; const path = rawPath.includes(" -> ") ? (rawPath.split(" -> ").pop() ?? rawPath) : rawPath;
const type = const type = status.includes("D") ? "D" : status.includes("A") || status === "??" ? "A" : "M";
status.includes("D")
? "D"
: status.includes("A") || status === "??"
? "A"
: "M";
return { path, type }; return { path, type };
}); });
} }
@ -312,10 +300,7 @@ function buildFileTree(paths: string[]): Array<any> {
async function collectWorkbenchGitState(c: any, record: any) { async function collectWorkbenchGitState(c: any, record: any) {
const activeSandboxId = record.activeSandboxId; const activeSandboxId = record.activeSandboxId;
const activeSandbox = const activeSandbox = activeSandboxId != null ? ((record.sandboxes ?? []).find((candidate: any) => candidate.sandboxId === activeSandboxId) ?? null) : null;
activeSandboxId != null
? (record.sandboxes ?? []).find((candidate: any) => candidate.sandboxId === activeSandboxId) ?? null
: null;
const cwd = activeSandbox?.cwd ?? record.sandboxes?.[0]?.cwd ?? null; const cwd = activeSandbox?.cwd ?? record.sandboxes?.[0]?.cwd ?? null;
if (!activeSandboxId || !cwd) { if (!activeSandboxId || !cwd) {
return { return {
@ -423,12 +408,7 @@ async function readPullRequestSummary(c: any, branchName: string | null) {
} }
try { try {
const project = await getOrCreateProject( const project = await getOrCreateProject(c, c.state.workspaceId, c.state.repoId, c.state.repoRemote);
c,
c.state.workspaceId,
c.state.repoId,
c.state.repoRemote,
);
return await project.getPullRequestForBranch({ branchName }); return await project.getPullRequestForBranch({ branchName });
} catch { } catch {
return null; return null;
@ -528,8 +508,7 @@ export async function renameWorkbenchBranch(c: any, value: string): Promise<void
if (!record.activeSandboxId) { if (!record.activeSandboxId) {
throw new Error("cannot rename branch without an active sandbox"); throw new Error("cannot rename branch without an active sandbox");
} }
const activeSandbox = const activeSandbox = (record.sandboxes ?? []).find((candidate: any) => candidate.sandboxId === record.activeSandboxId) ?? null;
(record.sandboxes ?? []).find((candidate: any) => candidate.sandboxId === record.activeSandboxId) ?? null;
if (!activeSandbox?.cwd) { if (!activeSandbox?.cwd) {
throw new Error("cannot rename branch without a sandbox 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) { if (!record.activeSandboxId) {
throw new Error("cannot create session without an active sandbox"); throw new Error("cannot create session without an active sandbox");
} }
const activeSandbox = const activeSandbox = (record.sandboxes ?? []).find((candidate: any) => candidate.sandboxId === record.activeSandboxId) ?? null;
(record.sandboxes ?? []).find((candidate: any) => candidate.sandboxId === record.activeSandboxId) ?? null;
const cwd = activeSandbox?.cwd ?? record.sandboxes?.[0]?.cwd ?? null; const cwd = activeSandbox?.cwd ?? record.sandboxes?.[0]?.cwd ?? null;
if (!cwd) { if (!cwd) {
throw new Error("cannot create session without a sandbox 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 }); await ensureSessionMeta(c, { sessionId });
const sandbox = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, record.activeSandboxId); const sandbox = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, record.activeSandboxId);
const prompt = [ const prompt = [text.trim(), ...attachments.map((attachment: any) => `@ ${attachment.filePath}:${attachment.lineNumber}\n${attachment.lineContent}`)]
text.trim(),
...attachments.map((attachment: any) => `@ ${attachment.filePath}:${attachment.lineNumber}\n${attachment.lineContent}`),
]
.filter(Boolean) .filter(Boolean)
.join("\n\n"); .join("\n\n");
if (!prompt) { if (!prompt) {
@ -673,23 +648,15 @@ export async function sendWorkbenchMessage(c: any, sessionId: string, text: stri
.where(eq(handoffRuntime.id, 1)) .where(eq(handoffRuntime.id, 1))
.run(); .run();
const sync = await getOrCreateHandoffStatusSync( const sync = await getOrCreateHandoffStatusSync(c, c.state.workspaceId, c.state.repoId, c.state.handoffId, record.activeSandboxId, sessionId, {
c, workspaceId: c.state.workspaceId,
c.state.workspaceId, repoId: c.state.repoId,
c.state.repoId, handoffId: c.state.handoffId,
c.state.handoffId, providerId: c.state.providerId,
record.activeSandboxId, sandboxId: record.activeSandboxId,
sessionId, sessionId,
{ intervalMs: STATUS_SYNC_INTERVAL_MS,
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,
},
);
await sync.setIntervalMs({ intervalMs: STATUS_SYNC_INTERVAL_MS }); await sync.setIntervalMs({ intervalMs: STATUS_SYNC_INTERVAL_MS });
await sync.start(); await sync.start();
await sync.force(); await sync.force();
@ -709,12 +676,7 @@ export async function stopWorkbenchSession(c: any, sessionId: string): Promise<v
await notifyWorkbenchUpdated(c); await notifyWorkbenchUpdated(c);
} }
export async function syncWorkbenchSessionStatus( export async function syncWorkbenchSessionStatus(c: any, sessionId: string, status: "running" | "idle" | "error", at: number): Promise<void> {
c: any,
sessionId: string,
status: "running" | "idle" | "error",
at: number,
): Promise<void> {
const record = await ensureWorkbenchSeeded(c); const record = await ensureWorkbenchSeeded(c);
const meta = await ensureSessionMeta(c, { sessionId }); const meta = await ensureSessionMeta(c, { sessionId });
let changed = false; let changed = false;
@ -821,11 +783,7 @@ export async function publishWorkbenchPr(c: any): Promise<void> {
throw new Error("cannot publish PR without a branch"); throw new Error("cannot publish PR without a branch");
} }
const { driver } = getActorRuntimeContext(); const { driver } = getActorRuntimeContext();
const created = await driver.github.createPr( const created = await driver.github.createPr(c.state.repoLocalPath, record.branchName, record.title ?? c.state.task);
c.state.repoLocalPath,
record.branchName,
record.title ?? c.state.task,
);
await c.db await c.db
.update(handoffTable) .update(handoffTable)
.set({ .set({
@ -842,8 +800,7 @@ export async function revertWorkbenchFile(c: any, path: string): Promise<void> {
if (!record.activeSandboxId) { if (!record.activeSandboxId) {
throw new Error("cannot revert file without an active sandbox"); throw new Error("cannot revert file without an active sandbox");
} }
const activeSandbox = const activeSandbox = (record.sandboxes ?? []).find((candidate: any) => candidate.sandboxId === record.activeSandboxId) ?? null;
(record.sandboxes ?? []).find((candidate: any) => candidate.sandboxId === record.activeSandboxId) ?? null;
if (!activeSandbox?.cwd) { if (!activeSandbox?.cwd) {
throw new Error("cannot revert file without a sandbox cwd"); throw new Error("cannot revert file without a sandbox cwd");
} }

View file

@ -14,7 +14,7 @@ async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: str
promise, promise,
new Promise<T>((_resolve, reject) => { new Promise<T>((_resolve, reject) => {
timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs); timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs);
}) }),
]); ]);
} finally { } finally {
if (timer) { 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> { export async function handleAttachActivity(loopCtx: any, msg: any): Promise<void> {
const record = await getCurrentRecord(loopCtx); const record = await getCurrentRecord(loopCtx);
const { providers } = getActorRuntimeContext(); const { providers } = getActorRuntimeContext();
const activeSandbox = const activeSandbox = record.activeSandboxId ? (record.sandboxes.find((sb: any) => sb.sandboxId === record.activeSandboxId) ?? null) : null;
record.activeSandboxId
? record.sandboxes.find((sb: any) => sb.sandboxId === record.activeSandboxId) ?? null
: null;
const provider = providers.get(activeSandbox?.providerId ?? record.providerId); const provider = providers.get(activeSandbox?.providerId ?? record.providerId);
const target = await provider.attachTarget({ const target = await provider.attachTarget({
workspaceId: loopCtx.state.workspaceId, workspaceId: loopCtx.state.workspaceId,
sandboxId: record.activeSandboxId ?? "" sandboxId: record.activeSandboxId ?? "",
}); });
await appendHistory(loopCtx, "handoff.attach", { await appendHistory(loopCtx, "handoff.attach", {
target: target.target, target: target.target,
sessionId: record.activeSessionId sessionId: record.activeSessionId,
}); });
await msg.complete({ await msg.complete({
target: target.target, target: target.target,
sessionId: record.activeSessionId sessionId: record.activeSessionId,
}); });
} }
export async function handleSwitchActivity(loopCtx: any, msg: any): Promise<void> { export async function handleSwitchActivity(loopCtx: any, msg: any): Promise<void> {
const db = loopCtx.db; const db = loopCtx.db;
const runtime = await db const runtime = await db.select({ switchTarget: handoffRuntime.activeSwitchTarget }).from(handoffRuntime).where(eq(handoffRuntime.id, HANDOFF_ROW_ID)).get();
.select({ switchTarget: handoffRuntime.activeSwitchTarget })
.from(handoffRuntime)
.where(eq(handoffRuntime.id, HANDOFF_ROW_ID))
.get();
await msg.complete({ switchTarget: runtime?.switchTarget ?? "" }); 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> { export async function handlePushActivity(loopCtx: any, msg: any): Promise<void> {
await pushActiveBranchActivity(loopCtx, { await pushActiveBranchActivity(loopCtx, {
reason: msg.body?.reason ?? null, reason: msg.body?.reason ?? null,
historyKind: "handoff.push" historyKind: "handoff.push",
}); });
await msg.complete({ ok: true }); await msg.complete({ ok: true });
} }
export async function handleSimpleCommandActivity( export async function handleSimpleCommandActivity(loopCtx: any, msg: any, statusMessage: string, historyKind: string): Promise<void> {
loopCtx: any,
msg: any,
statusMessage: string,
historyKind: string
): Promise<void> {
const db = loopCtx.db; const db = loopCtx.db;
await db await db.update(handoffRuntime).set({ statusMessage, updatedAt: Date.now() }).where(eq(handoffRuntime.id, HANDOFF_ROW_ID)).run();
.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 appendHistory(loopCtx, historyKind, { reason: msg.body?.reason ?? null });
await msg.complete({ ok: true }); await msg.complete({ ok: true });
@ -103,8 +87,8 @@ export async function handleArchiveActivity(loopCtx: any, msg: any): Promise<voi
providerId: record.providerId, providerId: record.providerId,
sandboxId: record.activeSandboxId, sandboxId: record.activeSandboxId,
sessionId: record.activeSessionId, sessionId: record.activeSessionId,
intervalMs: 2_000 intervalMs: 2_000,
} },
); );
await withTimeout(sync.stop(), 15_000, "handoff status sync stop"); await withTimeout(sync.stop(), 15_000, "handoff status sync stop");
} catch (error) { } catch (error) {
@ -114,7 +98,7 @@ export async function handleArchiveActivity(loopCtx: any, msg: any): Promise<voi
handoffId: loopCtx.state.handoffId, handoffId: loopCtx.state.handoffId,
sandboxId: record.activeSandboxId, sandboxId: record.activeSandboxId,
sessionId: record.activeSessionId, 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) { if (record.activeSandboxId) {
await setHandoffState(loopCtx, "archive_release_sandbox", "releasing sandbox"); await setHandoffState(loopCtx, "archive_release_sandbox", "releasing sandbox");
const { providers } = getActorRuntimeContext(); const { providers } = getActorRuntimeContext();
const activeSandbox = const activeSandbox = record.sandboxes.find((sb: any) => sb.sandboxId === record.activeSandboxId) ?? null;
record.sandboxes.find((sb: any) => sb.sandboxId === record.activeSandboxId) ?? null;
const provider = providers.get(activeSandbox?.providerId ?? record.providerId); const provider = providers.get(activeSandbox?.providerId ?? record.providerId);
const workspaceId = loopCtx.state.workspaceId; const workspaceId = loopCtx.state.workspaceId;
const repoId = loopCtx.state.repoId; const repoId = loopCtx.state.repoId;
@ -135,28 +118,24 @@ export async function handleArchiveActivity(loopCtx: any, msg: any): Promise<voi
void withTimeout( void withTimeout(
provider.releaseSandbox({ provider.releaseSandbox({
workspaceId, workspaceId,
sandboxId sandboxId,
}), }),
45_000, 45_000,
"provider releaseSandbox" "provider releaseSandbox",
).catch((error) => { ).catch((error) => {
logActorWarning("handoff.commands", "failed to release sandbox during archive", { logActorWarning("handoff.commands", "failed to release sandbox during archive", {
workspaceId, workspaceId,
repoId, repoId,
handoffId, handoffId,
sandboxId, sandboxId,
error: resolveErrorMessage(error) error: resolveErrorMessage(error),
}); });
}); });
} }
const db = loopCtx.db; const db = loopCtx.db;
await setHandoffState(loopCtx, "archive_finalize", "finalizing archive"); await setHandoffState(loopCtx, "archive_finalize", "finalizing archive");
await db await db.update(handoffTable).set({ status: "archived", updatedAt: Date.now() }).where(eq(handoffTable.id, HANDOFF_ROW_ID)).run();
.update(handoffTable)
.set({ status: "archived", updatedAt: Date.now() })
.where(eq(handoffTable.id, HANDOFF_ROW_ID))
.run();
await db await db
.update(handoffRuntime) .update(handoffRuntime)
@ -176,29 +155,20 @@ export async function killDestroySandboxActivity(loopCtx: any): Promise<void> {
} }
const { providers } = getActorRuntimeContext(); const { providers } = getActorRuntimeContext();
const activeSandbox = const activeSandbox = record.sandboxes.find((sb: any) => sb.sandboxId === record.activeSandboxId) ?? null;
record.sandboxes.find((sb: any) => sb.sandboxId === record.activeSandboxId) ?? null;
const provider = providers.get(activeSandbox?.providerId ?? record.providerId); const provider = providers.get(activeSandbox?.providerId ?? record.providerId);
await provider.destroySandbox({ await provider.destroySandbox({
workspaceId: loopCtx.state.workspaceId, workspaceId: loopCtx.state.workspaceId,
sandboxId: record.activeSandboxId sandboxId: record.activeSandboxId,
}); });
} }
export async function killWriteDbActivity(loopCtx: any, msg: any): Promise<void> { export async function killWriteDbActivity(loopCtx: any, msg: any): Promise<void> {
await setHandoffState(loopCtx, "kill_finalize", "finalizing kill"); await setHandoffState(loopCtx, "kill_finalize", "finalizing kill");
const db = loopCtx.db; const db = loopCtx.db;
await db await db.update(handoffTable).set({ status: "killed", updatedAt: Date.now() }).where(eq(handoffTable.id, HANDOFF_ROW_ID)).run();
.update(handoffTable)
.set({ status: "killed", updatedAt: Date.now() })
.where(eq(handoffTable.id, HANDOFF_ROW_ID))
.run();
await db await db.update(handoffRuntime).set({ statusMessage: "killed", updatedAt: Date.now() }).where(eq(handoffRuntime.id, HANDOFF_ROW_ID)).run();
.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 appendHistory(loopCtx, "handoff.kill", { reason: msg.body?.reason ?? null });
await msg.complete({ ok: true }); await msg.complete({ ok: true });

View file

@ -48,9 +48,7 @@ export function resolveErrorDetail(error: unknown): string {
return String(error); return String(error);
} }
const nonWorkflowWrapper = messages.find( const nonWorkflowWrapper = messages.find((msg) => !/^Step\s+"[^"]+"\s+failed\b/i.test(msg));
(msg) => !/^Step\s+"[^"]+"\s+failed\b/i.test(msg)
);
return nonWorkflowWrapper ?? messages[0]!; return nonWorkflowWrapper ?? messages[0]!;
} }
@ -58,18 +56,10 @@ export function buildAgentPrompt(task: string): string {
return task.trim(); return task.trim();
} }
export async function setHandoffState( export async function setHandoffState(ctx: any, status: HandoffStatus, statusMessage?: string): Promise<void> {
ctx: any,
status: HandoffStatus,
statusMessage?: string
): Promise<void> {
const now = Date.now(); const now = Date.now();
const db = ctx.db; const db = ctx.db;
await db await db.update(handoffTable).set({ status, updatedAt: now }).where(eq(handoffTable.id, HANDOFF_ROW_ID)).run();
.update(handoffTable)
.set({ status, updatedAt: now })
.where(eq(handoffTable.id, HANDOFF_ROW_ID))
.run();
if (statusMessage != null) { if (statusMessage != null) {
await db await db
@ -81,14 +71,14 @@ export async function setHandoffState(
activeSwitchTarget: null, activeSwitchTarget: null,
activeCwd: null, activeCwd: null,
statusMessage, statusMessage,
updatedAt: now updatedAt: now,
}) })
.onConflictDoUpdate({ .onConflictDoUpdate({
target: handoffRuntime.id, target: handoffRuntime.id,
set: { set: {
statusMessage, statusMessage,
updatedAt: now updatedAt: now,
} },
}) })
.run(); .run();
} }
@ -112,7 +102,7 @@ export async function getCurrentRecord(ctx: any): Promise<HandoffRecord> {
agentType: handoffTable.agentType, agentType: handoffTable.agentType,
prSubmitted: handoffTable.prSubmitted, prSubmitted: handoffTable.prSubmitted,
createdAt: handoffTable.createdAt, createdAt: handoffTable.createdAt,
updatedAt: handoffTable.updatedAt updatedAt: handoffTable.updatedAt,
}) })
.from(handoffTable) .from(handoffTable)
.leftJoin(handoffRuntime, eq(handoffTable.id, handoffRuntime.id)) .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> { export async function appendHistory(ctx: any, kind: string, payload: Record<string, unknown>): Promise<void> {
const client = ctx.client(); const client = ctx.client();
const history = await client.history.getOrCreate( const history = await client.history.getOrCreate(historyKey(ctx.state.workspaceId, ctx.state.repoId), {
historyKey(ctx.state.workspaceId, ctx.state.repoId), createWithInput: { workspaceId: ctx.state.workspaceId, repoId: ctx.state.repoId },
{ createWithInput: { workspaceId: ctx.state.workspaceId, repoId: ctx.state.repoId } } });
);
await history.append({ await history.append({
kind, kind,
handoffId: ctx.state.handoffId, handoffId: ctx.state.handoffId,
branchName: ctx.state.branchName, branchName: ctx.state.branchName,
payload payload,
}); });
const workspace = await getOrCreateWorkspace(ctx, ctx.state.workspaceId); const workspace = await getOrCreateWorkspace(ctx, ctx.state.workspaceId);

View file

@ -13,7 +13,7 @@ import {
initFailedActivity, initFailedActivity,
initStartSandboxInstanceActivity, initStartSandboxInstanceActivity,
initStartStatusSyncActivity, initStartStatusSyncActivity,
initWriteDbActivity initWriteDbActivity,
} from "./init.js"; } from "./init.js";
import { import {
handleArchiveActivity, handleArchiveActivity,
@ -23,7 +23,7 @@ import {
handleSimpleCommandActivity, handleSimpleCommandActivity,
handleSwitchActivity, handleSwitchActivity,
killDestroySandboxActivity, killDestroySandboxActivity,
killWriteDbActivity killWriteDbActivity,
} from "./commands.js"; } from "./commands.js";
import { idleNotifyActivity, idleSubmitPrActivity, statusUpdateActivity } from "./status-sync.js"; import { idleNotifyActivity, idleSubmitPrActivity, statusUpdateActivity } from "./status-sync.js";
import { HANDOFF_QUEUE_NAMES } from "./queue.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.step("init-bootstrap-db", async () => initBootstrapDbActivity(loopCtx, body));
await loopCtx.removed("init-enqueue-provision", "step"); await loopCtx.removed("init-enqueue-provision", "step");
await loopCtx.removed("init-dispatch-provision-v2", "step"); await loopCtx.removed("init-dispatch-provision-v2", "step");
const currentRecord = await loopCtx.step( const currentRecord = await loopCtx.step("init-read-current-record", async () => getCurrentRecord(loopCtx));
"init-read-current-record",
async () => getCurrentRecord(loopCtx)
);
try { try {
await msg.complete(currentRecord); await msg.complete(currentRecord);
} catch (error) { } catch (error) {
logActorWarning("handoff.workflow", "initialize completion failed", { 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), run: async () => initCreateSessionActivity(loopCtx, body, sandbox, sandboxInstanceReady),
}); });
await loopCtx.step( await loopCtx.step("init-write-db", async () => initWriteDbActivity(loopCtx, body, sandbox, session, sandboxInstanceReady));
"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-start-status-sync", async () => initStartStatusSyncActivity(loopCtx, body, sandbox, session));
await loopCtx.step("init-complete", async () => initCompleteActivity(loopCtx, body, sandbox, session)); await loopCtx.step("init-complete", async () => initCompleteActivity(loopCtx, body, sandbox, session));
await msg.complete({ ok: true }); await msg.complete({ ok: true });
@ -125,17 +119,11 @@ const commandHandlers: Record<HandoffQueueName, WorkflowHandler> = {
}, },
"handoff.command.sync": async (loopCtx, msg) => { "handoff.command.sync": async (loopCtx, msg) => {
await loopCtx.step( await loopCtx.step("handle-sync", async () => handleSimpleCommandActivity(loopCtx, msg, "sync requested", "handoff.sync"));
"handle-sync",
async () => handleSimpleCommandActivity(loopCtx, msg, "sync requested", "handoff.sync")
);
}, },
"handoff.command.merge": async (loopCtx, msg) => { "handoff.command.merge": async (loopCtx, msg) => {
await loopCtx.step( await loopCtx.step("handle-merge", async () => handleSimpleCommandActivity(loopCtx, msg, "merge requested", "handoff.merge"));
"handle-merge",
async () => handleSimpleCommandActivity(loopCtx, msg, "merge requested", "handoff.merge")
);
}, },
"handoff.command.archive": async (loopCtx, msg) => { "handoff.command.archive": async (loopCtx, msg) => {
@ -180,30 +168,22 @@ const commandHandlers: Record<HandoffQueueName, WorkflowHandler> = {
}, },
"handoff.command.workbench.rename_session": async (loopCtx, msg) => { "handoff.command.workbench.rename_session": async (loopCtx, msg) => {
await loopCtx.step("workbench-rename-session", async () => await loopCtx.step("workbench-rename-session", async () => renameWorkbenchSession(loopCtx, msg.body.sessionId, msg.body.title));
renameWorkbenchSession(loopCtx, msg.body.sessionId, msg.body.title),
);
await msg.complete({ ok: true }); await msg.complete({ ok: true });
}, },
"handoff.command.workbench.set_session_unread": async (loopCtx, msg) => { "handoff.command.workbench.set_session_unread": async (loopCtx, msg) => {
await loopCtx.step("workbench-set-session-unread", async () => await loopCtx.step("workbench-set-session-unread", async () => setWorkbenchSessionUnread(loopCtx, msg.body.sessionId, msg.body.unread));
setWorkbenchSessionUnread(loopCtx, msg.body.sessionId, msg.body.unread),
);
await msg.complete({ ok: true }); await msg.complete({ ok: true });
}, },
"handoff.command.workbench.update_draft": async (loopCtx, msg) => { "handoff.command.workbench.update_draft": async (loopCtx, msg) => {
await loopCtx.step("workbench-update-draft", async () => await loopCtx.step("workbench-update-draft", async () => updateWorkbenchDraft(loopCtx, msg.body.sessionId, msg.body.text, msg.body.attachments));
updateWorkbenchDraft(loopCtx, msg.body.sessionId, msg.body.text, msg.body.attachments),
);
await msg.complete({ ok: true }); await msg.complete({ ok: true });
}, },
"handoff.command.workbench.change_model": async (loopCtx, msg) => { "handoff.command.workbench.change_model": async (loopCtx, msg) => {
await loopCtx.step("workbench-change-model", async () => await loopCtx.step("workbench-change-model", async () => changeWorkbenchModel(loopCtx, msg.body.sessionId, msg.body.model));
changeWorkbenchModel(loopCtx, msg.body.sessionId, msg.body.model),
);
await msg.complete({ ok: true }); await msg.complete({ ok: true });
}, },
@ -226,9 +206,7 @@ const commandHandlers: Record<HandoffQueueName, WorkflowHandler> = {
}, },
"handoff.command.workbench.sync_session_status": async (loopCtx, msg) => { "handoff.command.workbench.sync_session_status": async (loopCtx, msg) => {
await loopCtx.step("workbench-sync-session-status", async () => await loopCtx.step("workbench-sync-session-status", async () => syncWorkbenchSessionStatus(loopCtx, msg.body.sessionId, msg.body.status, msg.body.at));
syncWorkbenchSessionStatus(loopCtx, msg.body.sessionId, msg.body.status, msg.body.at),
);
await msg.complete({ ok: true }); await msg.complete({ ok: true });
}, },
@ -269,14 +247,14 @@ const commandHandlers: Record<HandoffQueueName, WorkflowHandler> = {
} }
await loopCtx.step("idle-notify", async () => idleNotifyActivity(loopCtx)); await loopCtx.step("idle-notify", async () => idleNotifyActivity(loopCtx));
} }
} },
}; };
export async function runHandoffWorkflow(ctx: any): Promise<void> { export async function runHandoffWorkflow(ctx: any): Promise<void> {
await ctx.loop("handoff-command-loop", async (loopCtx: any) => { await ctx.loop("handoff-command-loop", async (loopCtx: any) => {
const msg = await loopCtx.queue.next("next-command", { const msg = await loopCtx.queue.next("next-command", {
names: [...HANDOFF_QUEUE_NAMES], names: [...HANDOFF_QUEUE_NAMES],
completable: true completable: true,
}); });
if (!msg) { if (!msg) {
return Loop.continue(undefined); return Loop.continue(undefined);

View file

@ -8,18 +8,11 @@ import {
getOrCreateProject, getOrCreateProject,
getOrCreateSandboxInstance, getOrCreateSandboxInstance,
getSandboxInstance, getSandboxInstance,
selfHandoff selfHandoff,
} from "../../handles.js"; } from "../../handles.js";
import { logActorWarning, resolveErrorMessage } from "../../logging.js"; import { logActorWarning, resolveErrorMessage } from "../../logging.js";
import { handoff as handoffTable, handoffRuntime, handoffSandboxes } from "../db/schema.js"; import { handoff as handoffTable, handoffRuntime, handoffSandboxes } from "../db/schema.js";
import { import { HANDOFF_ROW_ID, appendHistory, buildAgentPrompt, collectErrorMessages, resolveErrorDetail, setHandoffState } from "./common.js";
HANDOFF_ROW_ID,
appendHistory,
buildAgentPrompt,
collectErrorMessages,
resolveErrorDetail,
setHandoffState
} from "./common.js";
import { handoffWorkflowQueueName } from "./queue.js"; import { handoffWorkflowQueueName } from "./queue.js";
const DEFAULT_INIT_CREATE_SANDBOX_ACTIVITY_TIMEOUT_MS = 180_000; 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, workspaceId: loopCtx.state.workspaceId,
repoId: loopCtx.state.repoId, repoId: loopCtx.state.repoId,
handoffId: loopCtx.state.handoffId, handoffId: loopCtx.state.handoffId,
...(context ?? {}) ...(context ?? {}),
}); });
} }
async function withActivityTimeout<T>( async function withActivityTimeout<T>(timeoutMs: number, label: string, run: () => Promise<T>): Promise<T> {
timeoutMs: number,
label: string,
run: () => Promise<T>
): Promise<T> {
let timer: ReturnType<typeof setTimeout> | null = null; let timer: ReturnType<typeof setTimeout> | null = null;
try { try {
return await Promise.race([ return await Promise.race([
@ -60,7 +49,7 @@ async function withActivityTimeout<T>(
timer = setTimeout(() => { timer = setTimeout(() => {
reject(new Error(`${label} timed out after ${timeoutMs}ms`)); reject(new Error(`${label} timed out after ${timeoutMs}ms`));
}, timeoutMs); }, timeoutMs);
}) }),
]); ]);
} finally { } finally {
if (timer) { if (timer) {
@ -88,7 +77,7 @@ export async function initBootstrapDbActivity(loopCtx: any, body: any): Promise<
status: "init_bootstrap_db", status: "init_bootstrap_db",
agentType: loopCtx.state.agentType ?? config.default_agent, agentType: loopCtx.state.agentType ?? config.default_agent,
createdAt: now, createdAt: now,
updatedAt: now updatedAt: now,
}) })
.onConflictDoUpdate({ .onConflictDoUpdate({
target: handoffTable.id, target: handoffTable.id,
@ -99,8 +88,8 @@ export async function initBootstrapDbActivity(loopCtx: any, body: any): Promise<
providerId, providerId,
status: "init_bootstrap_db", status: "init_bootstrap_db",
agentType: loopCtx.state.agentType ?? config.default_agent, agentType: loopCtx.state.agentType ?? config.default_agent,
updatedAt: now updatedAt: now,
} },
}) })
.run(); .run();
@ -113,7 +102,7 @@ export async function initBootstrapDbActivity(loopCtx: any, body: any): Promise<
activeSwitchTarget: null, activeSwitchTarget: null,
activeCwd: null, activeCwd: null,
statusMessage: initialStatusMessage, statusMessage: initialStatusMessage,
updatedAt: now updatedAt: now,
}) })
.onConflictDoUpdate({ .onConflictDoUpdate({
target: handoffRuntime.id, target: handoffRuntime.id,
@ -123,8 +112,8 @@ export async function initBootstrapDbActivity(loopCtx: any, body: any): Promise<
activeSwitchTarget: null, activeSwitchTarget: null,
activeCwd: null, activeCwd: null,
statusMessage: initialStatusMessage, statusMessage: initialStatusMessage,
updatedAt: now updatedAt: now,
} },
}) })
.run(); .run();
} catch (error) { } catch (error) {
@ -155,7 +144,7 @@ export async function initEnsureNameActivity(loopCtx: any): Promise<void> {
const existing = await loopCtx.db const existing = await loopCtx.db
.select({ .select({
branchName: handoffTable.branchName, branchName: handoffTable.branchName,
title: handoffTable.title title: handoffTable.title,
}) })
.from(handoffTable) .from(handoffTable)
.where(eq(handoffTable.id, HANDOFF_ROW_ID)) .where(eq(handoffTable.id, HANDOFF_ROW_ID))
@ -175,19 +164,12 @@ export async function initEnsureNameActivity(loopCtx: any): Promise<void> {
workspaceId: loopCtx.state.workspaceId, workspaceId: loopCtx.state.workspaceId,
repoId: loopCtx.state.repoId, repoId: loopCtx.state.repoId,
handoffId: loopCtx.state.handoffId, handoffId: loopCtx.state.handoffId,
error: resolveErrorMessage(error) error: resolveErrorMessage(error),
}); });
} }
const remoteBranches = (await driver.git.listRemoteBranches(loopCtx.state.repoLocalPath)).map( const remoteBranches = (await driver.git.listRemoteBranches(loopCtx.state.repoLocalPath)).map((branch: any) => branch.branchName);
(branch: any) => branch.branchName
);
const project = await getOrCreateProject( const project = await getOrCreateProject(loopCtx, loopCtx.state.workspaceId, loopCtx.state.repoId, loopCtx.state.repoRemote);
loopCtx,
loopCtx.state.workspaceId,
loopCtx.state.repoId,
loopCtx.state.repoRemote
);
const reservedBranches = await project.listReservedBranches({}); const reservedBranches = await project.listReservedBranches({});
const resolved = resolveCreateFlowDecision({ const resolved = resolveCreateFlowDecision({
@ -195,7 +177,7 @@ export async function initEnsureNameActivity(loopCtx: any): Promise<void> {
explicitTitle: loopCtx.state.explicitTitle ?? undefined, explicitTitle: loopCtx.state.explicitTitle ?? undefined,
explicitBranchName: loopCtx.state.explicitBranchName ?? undefined, explicitBranchName: loopCtx.state.explicitBranchName ?? undefined,
localBranches: remoteBranches, localBranches: remoteBranches,
handoffBranches: reservedBranches handoffBranches: reservedBranches,
}); });
const now = Date.now(); const now = Date.now();
@ -204,7 +186,7 @@ export async function initEnsureNameActivity(loopCtx: any): Promise<void> {
.set({ .set({
branchName: resolved.branchName, branchName: resolved.branchName,
title: resolved.title, title: resolved.title,
updatedAt: now updatedAt: now,
}) })
.where(eq(handoffTable.id, HANDOFF_ROW_ID)) .where(eq(handoffTable.id, HANDOFF_ROW_ID))
.run(); .run();
@ -218,19 +200,19 @@ export async function initEnsureNameActivity(loopCtx: any): Promise<void> {
.update(handoffRuntime) .update(handoffRuntime)
.set({ .set({
statusMessage: "provisioning", statusMessage: "provisioning",
updatedAt: now updatedAt: now,
}) })
.where(eq(handoffRuntime.id, HANDOFF_ROW_ID)) .where(eq(handoffRuntime.id, HANDOFF_ROW_ID))
.run(); .run();
await project.registerHandoffBranch({ await project.registerHandoffBranch({
handoffId: loopCtx.state.handoffId, handoffId: loopCtx.state.handoffId,
branchName: resolved.branchName branchName: resolved.branchName,
}); });
await appendHistory(loopCtx, "handoff.named", { await appendHistory(loopCtx, "handoff.named", {
title: resolved.title, 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", { debugInit(loopCtx, "init_create_sandbox started", {
providerId, providerId,
timeoutMs, timeoutMs,
supportsSessionReuse: provider.capabilities().supportsSessionReuse supportsSessionReuse: provider.capabilities().supportsSessionReuse,
}); });
if (provider.capabilities().supportsSessionReuse) { if (provider.capabilities().supportsSessionReuse) {
@ -274,18 +256,16 @@ export async function initCreateSandboxActivity(loopCtx: any, body: any): Promis
if (sandboxId) { if (sandboxId) {
debugInit(loopCtx, "init_create_sandbox attempting resume", { sandboxId }); debugInit(loopCtx, "init_create_sandbox attempting resume", { sandboxId });
try { try {
const resumed = await withActivityTimeout( const resumed = await withActivityTimeout(timeoutMs, "resumeSandbox", async () =>
timeoutMs, provider.resumeSandbox({
"resumeSandbox",
async () => provider.resumeSandbox({
workspaceId: loopCtx.state.workspaceId, workspaceId: loopCtx.state.workspaceId,
sandboxId sandboxId,
}) }),
); );
debugInit(loopCtx, "init_create_sandbox resume succeeded", { debugInit(loopCtx, "init_create_sandbox resume succeeded", {
sandboxId: resumed.sandboxId, sandboxId: resumed.sandboxId,
durationMs: Date.now() - startedAt durationMs: Date.now() - startedAt,
}); });
return resumed; return resumed;
} catch (error) { } catch (error) {
@ -294,39 +274,37 @@ export async function initCreateSandboxActivity(loopCtx: any, body: any): Promis
repoId: loopCtx.state.repoId, repoId: loopCtx.state.repoId,
handoffId: loopCtx.state.handoffId, handoffId: loopCtx.state.handoffId,
sandboxId, sandboxId,
error: resolveErrorMessage(error) error: resolveErrorMessage(error),
}); });
} }
} }
} }
debugInit(loopCtx, "init_create_sandbox creating fresh sandbox", { debugInit(loopCtx, "init_create_sandbox creating fresh sandbox", {
branchName: loopCtx.state.branchName branchName: loopCtx.state.branchName,
}); });
try { try {
const sandbox = await withActivityTimeout( const sandbox = await withActivityTimeout(timeoutMs, "createSandbox", async () =>
timeoutMs, provider.createSandbox({
"createSandbox",
async () => provider.createSandbox({
workspaceId: loopCtx.state.workspaceId, workspaceId: loopCtx.state.workspaceId,
repoId: loopCtx.state.repoId, repoId: loopCtx.state.repoId,
repoRemote: loopCtx.state.repoRemote, repoRemote: loopCtx.state.repoRemote,
branchName: loopCtx.state.branchName, branchName: loopCtx.state.branchName,
handoffId: loopCtx.state.handoffId, 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", { debugInit(loopCtx, "init_create_sandbox create succeeded", {
sandboxId: sandbox.sandboxId, sandboxId: sandbox.sandboxId,
durationMs: Date.now() - startedAt durationMs: Date.now() - startedAt,
}); });
return sandbox; return sandbox;
} catch (error) { } catch (error) {
debugInit(loopCtx, "init_create_sandbox failed", { debugInit(loopCtx, "init_create_sandbox failed", {
durationMs: Date.now() - startedAt, durationMs: Date.now() - startedAt,
error: resolveErrorMessage(error) error: resolveErrorMessage(error),
}); });
throw error; throw error;
} }
@ -339,67 +317,49 @@ export async function initEnsureAgentActivity(loopCtx: any, body: any, sandbox:
const provider = providers.get(providerId); const provider = providers.get(providerId);
return await provider.ensureSandboxAgent({ return await provider.ensureSandboxAgent({
workspaceId: loopCtx.state.workspaceId, workspaceId: loopCtx.state.workspaceId,
sandboxId: sandbox.sandboxId sandboxId: sandbox.sandboxId,
}); });
} }
export async function initStartSandboxInstanceActivity( export async function initStartSandboxInstanceActivity(loopCtx: any, body: any, sandbox: any, agent: any): Promise<any> {
loopCtx: any,
body: any,
sandbox: any,
agent: any
): Promise<any> {
await setHandoffState(loopCtx, "init_start_sandbox_instance", "starting sandbox runtime"); await setHandoffState(loopCtx, "init_start_sandbox_instance", "starting sandbox runtime");
try { try {
const providerId = body?.providerId ?? loopCtx.state.providerId; const providerId = body?.providerId ?? loopCtx.state.providerId;
const sandboxInstance = await getOrCreateSandboxInstance( const sandboxInstance = await getOrCreateSandboxInstance(loopCtx, loopCtx.state.workspaceId, providerId, sandbox.sandboxId, {
loopCtx, workspaceId: loopCtx.state.workspaceId,
loopCtx.state.workspaceId,
providerId, providerId,
sandbox.sandboxId, sandboxId: sandbox.sandboxId,
{ });
workspaceId: loopCtx.state.workspaceId,
providerId,
sandboxId: sandbox.sandboxId
}
);
await sandboxInstance.ensure({ await sandboxInstance.ensure({
metadata: sandbox.metadata, metadata: sandbox.metadata,
status: "ready", status: "ready",
agentEndpoint: agent.endpoint, agentEndpoint: agent.endpoint,
agentToken: agent.token agentToken: agent.token,
}); });
const actorId = typeof (sandboxInstance as any).resolve === "function" const actorId = typeof (sandboxInstance as any).resolve === "function" ? await (sandboxInstance as any).resolve() : null;
? await (sandboxInstance as any).resolve()
: null;
return { return {
ok: true as const, ok: true as const,
actorId: typeof actorId === "string" ? actorId : null actorId: typeof actorId === "string" ? actorId : null,
}; };
} catch (error) { } catch (error) {
const detail = error instanceof Error ? error.message : String(error); const detail = error instanceof Error ? error.message : String(error);
return { return {
ok: false as const, ok: false as const,
error: `sandbox-instance ensure failed: ${detail}` error: `sandbox-instance ensure failed: ${detail}`,
}; };
} }
} }
export async function initCreateSessionActivity( export async function initCreateSessionActivity(loopCtx: any, body: any, sandbox: any, sandboxInstanceReady: any): Promise<any> {
loopCtx: any,
body: any,
sandbox: any,
sandboxInstanceReady: any
): Promise<any> {
await setHandoffState(loopCtx, "init_create_session", "creating agent session"); await setHandoffState(loopCtx, "init_create_session", "creating agent session");
if (!sandboxInstanceReady.ok) { if (!sandboxInstanceReady.ok) {
return { return {
id: null, id: null,
status: "error", status: "error",
error: sandboxInstanceReady.error ?? "sandbox instance is not ready" error: sandboxInstanceReady.error ?? "sandbox instance is not ready",
} as const; } as const;
} }
@ -407,15 +367,12 @@ export async function initCreateSessionActivity(
const providerId = body?.providerId ?? loopCtx.state.providerId; const providerId = body?.providerId ?? loopCtx.state.providerId;
const sandboxInstance = getSandboxInstance(loopCtx, loopCtx.state.workspaceId, providerId, sandbox.sandboxId); const sandboxInstance = getSandboxInstance(loopCtx, loopCtx.state.workspaceId, providerId, sandbox.sandboxId);
const cwd = const cwd = sandbox.metadata && typeof (sandbox.metadata as any).cwd === "string" ? ((sandbox.metadata as any).cwd as string) : undefined;
sandbox.metadata && typeof (sandbox.metadata as any).cwd === "string"
? ((sandbox.metadata as any).cwd as string)
: undefined;
return await sandboxInstance.createSession({ return await sandboxInstance.createSession({
prompt: buildAgentPrompt(loopCtx.state.task), prompt: buildAgentPrompt(loopCtx.state.task),
cwd, 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, body: any,
sandbox: any, sandbox: any,
session: any, session: any,
sandboxInstanceReady?: { actorId?: string | null } sandboxInstanceReady?: { actorId?: string | null },
): Promise<void> { ): Promise<void> {
await setHandoffState(loopCtx, "init_write_db", "persisting handoff runtime"); await setHandoffState(loopCtx, "init_write_db", "persisting handoff runtime");
const providerId = body?.providerId ?? loopCtx.state.providerId; const providerId = body?.providerId ?? loopCtx.state.providerId;
@ -434,21 +391,10 @@ export async function initWriteDbActivity(
const sessionId = session?.id ?? null; const sessionId = session?.id ?? null;
const sessionHealthy = Boolean(sessionId) && session?.status !== "error"; const sessionHealthy = Boolean(sessionId) && session?.status !== "error";
const activeSessionId = sessionHealthy ? sessionId : null; const activeSessionId = sessionHealthy ? sessionId : null;
const statusMessage = const statusMessage = sessionHealthy ? "session created" : session?.status === "error" ? (session.error ?? "session create failed") : "session unavailable";
sessionHealthy
? "session created"
: session?.status === "error"
? (session.error ?? "session create failed")
: "session unavailable";
const activeCwd = const activeCwd = sandbox.metadata && typeof (sandbox.metadata as any).cwd === "string" ? ((sandbox.metadata as any).cwd as string) : null;
sandbox.metadata && typeof (sandbox.metadata as any).cwd === "string" const sandboxActorId = typeof sandboxInstanceReady?.actorId === "string" && sandboxInstanceReady.actorId.length > 0 ? sandboxInstanceReady.actorId : null;
? ((sandbox.metadata as any).cwd as string)
: null;
const sandboxActorId =
typeof sandboxInstanceReady?.actorId === "string" && sandboxInstanceReady.actorId.length > 0
? sandboxInstanceReady.actorId
: null;
await db await db
.update(handoffTable) .update(handoffTable)
@ -456,7 +402,7 @@ export async function initWriteDbActivity(
providerId, providerId,
status: sessionHealthy ? "running" : "error", status: sessionHealthy ? "running" : "error",
agentType: loopCtx.state.agentType ?? config.default_agent, agentType: loopCtx.state.agentType ?? config.default_agent,
updatedAt: now updatedAt: now,
}) })
.where(eq(handoffTable.id, HANDOFF_ROW_ID)) .where(eq(handoffTable.id, HANDOFF_ROW_ID))
.run(); .run();
@ -471,7 +417,7 @@ export async function initWriteDbActivity(
cwd: activeCwd, cwd: activeCwd,
statusMessage, statusMessage,
createdAt: now, createdAt: now,
updatedAt: now updatedAt: now,
}) })
.onConflictDoUpdate({ .onConflictDoUpdate({
target: handoffSandboxes.sandboxId, target: handoffSandboxes.sandboxId,
@ -481,8 +427,8 @@ export async function initWriteDbActivity(
switchTarget: sandbox.switchTarget, switchTarget: sandbox.switchTarget,
cwd: activeCwd, cwd: activeCwd,
statusMessage, statusMessage,
updatedAt: now updatedAt: now,
} },
}) })
.run(); .run();
@ -495,7 +441,7 @@ export async function initWriteDbActivity(
activeSwitchTarget: sandbox.switchTarget, activeSwitchTarget: sandbox.switchTarget,
activeCwd, activeCwd,
statusMessage, statusMessage,
updatedAt: now updatedAt: now,
}) })
.onConflictDoUpdate({ .onConflictDoUpdate({
target: handoffRuntime.id, target: handoffRuntime.id,
@ -505,18 +451,13 @@ export async function initWriteDbActivity(
activeSwitchTarget: sandbox.switchTarget, activeSwitchTarget: sandbox.switchTarget,
activeCwd, activeCwd,
statusMessage, statusMessage,
updatedAt: now updatedAt: now,
} },
}) })
.run(); .run();
} }
export async function initStartStatusSyncActivity( export async function initStartStatusSyncActivity(loopCtx: any, body: any, sandbox: any, session: any): Promise<void> {
loopCtx: any,
body: any,
sandbox: any,
session: any
): Promise<void> {
const sessionId = session?.id ?? null; const sessionId = session?.id ?? null;
if (!sessionId || session?.status === "error") { if (!sessionId || session?.status === "error") {
return; return;
@ -538,8 +479,8 @@ export async function initStartStatusSyncActivity(
providerId, providerId,
sandboxId: sandbox.sandboxId, sandboxId: sandbox.sandboxId,
sessionId, sessionId,
intervalMs: 2_000 intervalMs: 2_000,
} },
); );
await sync.start(); await sync.start();
@ -558,21 +499,18 @@ export async function initCompleteActivity(loopCtx: any, body: any, sandbox: any
kind: "handoff.initialized", kind: "handoff.initialized",
handoffId: loopCtx.state.handoffId, handoffId: loopCtx.state.handoffId,
branchName: loopCtx.state.branchName, branchName: loopCtx.state.branchName,
payload: { providerId, sandboxId: sandbox.sandboxId, sessionId } payload: { providerId, sandboxId: sandbox.sandboxId, sessionId },
}); });
loopCtx.state.initialized = true; loopCtx.state.initialized = true;
return; return;
} }
const detail = const detail = session?.status === "error" ? (session.error ?? "session create failed") : "session unavailable";
session?.status === "error"
? (session.error ?? "session create failed")
: "session unavailable";
await setHandoffState(loopCtx, "error", detail); await setHandoffState(loopCtx, "error", detail);
await appendHistory(loopCtx, "handoff.error", { await appendHistory(loopCtx, "handoff.error", {
detail, detail,
messages: [detail] messages: [detail],
}); });
loopCtx.state.initialized = false; loopCtx.state.initialized = false;
} }
@ -596,7 +534,7 @@ export async function initFailedActivity(loopCtx: any, error: unknown): Promise<
status: "error", status: "error",
agentType: loopCtx.state.agentType ?? config.default_agent, agentType: loopCtx.state.agentType ?? config.default_agent,
createdAt: now, createdAt: now,
updatedAt: now updatedAt: now,
}) })
.onConflictDoUpdate({ .onConflictDoUpdate({
target: handoffTable.id, target: handoffTable.id,
@ -607,8 +545,8 @@ export async function initFailedActivity(loopCtx: any, error: unknown): Promise<
providerId, providerId,
status: "error", status: "error",
agentType: loopCtx.state.agentType ?? config.default_agent, agentType: loopCtx.state.agentType ?? config.default_agent,
updatedAt: now updatedAt: now,
} },
}) })
.run(); .run();
@ -621,7 +559,7 @@ export async function initFailedActivity(loopCtx: any, error: unknown): Promise<
activeSwitchTarget: null, activeSwitchTarget: null,
activeCwd: null, activeCwd: null,
statusMessage: detail, statusMessage: detail,
updatedAt: now updatedAt: now,
}) })
.onConflictDoUpdate({ .onConflictDoUpdate({
target: handoffRuntime.id, target: handoffRuntime.id,
@ -631,13 +569,13 @@ export async function initFailedActivity(loopCtx: any, error: unknown): Promise<
activeSwitchTarget: null, activeSwitchTarget: null,
activeCwd: null, activeCwd: null,
statusMessage: detail, statusMessage: detail,
updatedAt: now updatedAt: now,
} },
}) })
.run(); .run();
await appendHistory(loopCtx, "handoff.error", { await appendHistory(loopCtx, "handoff.error", {
detail, detail,
messages messages,
}); });
} }

View file

@ -9,10 +9,7 @@ export interface PushActiveBranchOptions {
historyKind?: string; historyKind?: string;
} }
export async function pushActiveBranchActivity( export async function pushActiveBranchActivity(loopCtx: any, options: PushActiveBranchOptions = {}): Promise<void> {
loopCtx: any,
options: PushActiveBranchOptions = {}
): Promise<void> {
const record = await getCurrentRecord(loopCtx); const record = await getCurrentRecord(loopCtx);
const activeSandboxId = record.activeSandboxId; const activeSandboxId = record.activeSandboxId;
const branchName = loopCtx.state.branchName ?? record.branchName; 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"); throw new Error("cannot push: handoff branch is not set");
} }
const activeSandbox = const activeSandbox = record.sandboxes.find((sandbox: any) => sandbox.sandboxId === activeSandboxId) ?? null;
record.sandboxes.find((sandbox: any) => sandbox.sandboxId === activeSandboxId) ?? null;
const providerId = activeSandbox?.providerId ?? record.providerId; const providerId = activeSandbox?.providerId ?? record.providerId;
const cwd = activeSandbox?.cwd ?? null; const cwd = activeSandbox?.cwd ?? null;
if (!cwd) { if (!cwd) {
@ -53,14 +49,14 @@ export async function pushActiveBranchActivity(
`cd ${JSON.stringify(cwd)}`, `cd ${JSON.stringify(cwd)}`,
"git rev-parse --verify HEAD >/dev/null", "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 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("; "); ].join("; ");
const result = await provider.executeCommand({ const result = await provider.executeCommand({
workspaceId: loopCtx.state.workspaceId, workspaceId: loopCtx.state.workspaceId,
sandboxId: activeSandboxId, sandboxId: activeSandboxId,
command: ["bash", "-lc", JSON.stringify(script)].join(" "), command: ["bash", "-lc", JSON.stringify(script)].join(" "),
label: `git push ${branchName}` label: `git push ${branchName}`,
}); });
if (result.exitCode !== 0) { if (result.exitCode !== 0) {
@ -83,6 +79,6 @@ export async function pushActiveBranchActivity(
await appendHistory(loopCtx, options.historyKind ?? "handoff.push", { await appendHistory(loopCtx, options.historyKind ?? "handoff.push", {
reason: options.reason ?? null, reason: options.reason ?? null,
branchName, branchName,
sandboxId: activeSandboxId sandboxId: activeSandboxId,
}); });
} }

View file

@ -23,7 +23,7 @@ export const HANDOFF_QUEUE_NAMES = [
"handoff.command.workbench.close_session", "handoff.command.workbench.close_session",
"handoff.command.workbench.publish_pr", "handoff.command.workbench.publish_pr",
"handoff.command.workbench.revert_file", "handoff.command.workbench.revert_file",
"handoff.status_sync.result" "handoff.status_sync.result",
] as const; ] as const;
export function handoffWorkflowQueueName(name: string): string { export function handoffWorkflowQueueName(name: string): string {

View file

@ -26,21 +26,16 @@ export async function statusUpdateActivity(loopCtx: any, body: any): Promise<boo
const runtime = await db const runtime = await db
.select({ .select({
activeSandboxId: handoffRuntime.activeSandboxId, activeSandboxId: handoffRuntime.activeSandboxId,
activeSessionId: handoffRuntime.activeSessionId activeSessionId: handoffRuntime.activeSessionId,
}) })
.from(handoffRuntime) .from(handoffRuntime)
.where(eq(handoffRuntime.id, HANDOFF_ROW_ID)) .where(eq(handoffRuntime.id, HANDOFF_ROW_ID))
.get(); .get();
const isActive = const isActive = runtime?.activeSandboxId === body.sandboxId && runtime?.activeSessionId === body.sessionId;
runtime?.activeSandboxId === body.sandboxId && runtime?.activeSessionId === body.sessionId;
if (isActive) { if (isActive) {
await db await db.update(handoffTable).set({ status: newStatus, updatedAt: body.at }).where(eq(handoffTable.id, HANDOFF_ROW_ID)).run();
.update(handoffTable)
.set({ status: newStatus, updatedAt: body.at })
.where(eq(handoffTable.id, HANDOFF_ROW_ID))
.run();
await db await db
.update(handoffRuntime) .update(handoffRuntime)
@ -58,7 +53,7 @@ export async function statusUpdateActivity(loopCtx: any, body: any): Promise<boo
await appendHistory(loopCtx, "handoff.status", { await appendHistory(loopCtx, "handoff.status", {
status: body.status, status: body.status,
sessionId: body.sessionId, sessionId: body.sessionId,
sandboxId: body.sandboxId sandboxId: body.sandboxId,
}); });
if (isActive) { if (isActive) {
@ -78,11 +73,7 @@ export async function idleSubmitPrActivity(loopCtx: any): Promise<void> {
const { driver } = getActorRuntimeContext(); const { driver } = getActorRuntimeContext();
const db = loopCtx.db; const db = loopCtx.db;
const self = await db const self = await db.select({ prSubmitted: handoffTable.prSubmitted }).from(handoffTable).where(eq(handoffTable.id, HANDOFF_ROW_ID)).get();
.select({ prSubmitted: handoffTable.prSubmitted })
.from(handoffTable)
.where(eq(handoffTable.id, HANDOFF_ROW_ID))
.get();
if (self && self.prSubmitted) return; if (self && self.prSubmitted) return;
@ -93,7 +84,7 @@ export async function idleSubmitPrActivity(loopCtx: any): Promise<void> {
workspaceId: loopCtx.state.workspaceId, workspaceId: loopCtx.state.workspaceId,
repoId: loopCtx.state.repoId, repoId: loopCtx.state.repoId,
handoffId: loopCtx.state.handoffId, handoffId: loopCtx.state.handoffId,
error: resolveErrorMessage(error) error: resolveErrorMessage(error),
}); });
} }
@ -104,34 +95,26 @@ export async function idleSubmitPrActivity(loopCtx: any): Promise<void> {
try { try {
await pushActiveBranchActivity(loopCtx, { await pushActiveBranchActivity(loopCtx, {
reason: "auto_submit_idle", reason: "auto_submit_idle",
historyKind: "handoff.push.auto" historyKind: "handoff.push.auto",
}); });
const pr = await driver.github.createPr( const pr = await driver.github.createPr(loopCtx.state.repoLocalPath, loopCtx.state.branchName, loopCtx.state.title);
loopCtx.state.repoLocalPath,
loopCtx.state.branchName,
loopCtx.state.title
);
await db await db.update(handoffTable).set({ prSubmitted: 1, updatedAt: Date.now() }).where(eq(handoffTable.id, HANDOFF_ROW_ID)).run();
.update(handoffTable)
.set({ prSubmitted: 1, updatedAt: Date.now() })
.where(eq(handoffTable.id, HANDOFF_ROW_ID))
.run();
await appendHistory(loopCtx, "handoff.step", { await appendHistory(loopCtx, "handoff.step", {
step: "pr_submit", step: "pr_submit",
handoffId: loopCtx.state.handoffId, handoffId: loopCtx.state.handoffId,
branchName: loopCtx.state.branchName, branchName: loopCtx.state.branchName,
prUrl: pr.url, prUrl: pr.url,
prNumber: pr.number prNumber: pr.number,
}); });
await appendHistory(loopCtx, "handoff.pr_created", { await appendHistory(loopCtx, "handoff.pr_created", {
handoffId: loopCtx.state.handoffId, handoffId: loopCtx.state.handoffId,
branchName: loopCtx.state.branchName, branchName: loopCtx.state.branchName,
prUrl: pr.url, prUrl: pr.url,
prNumber: pr.number prNumber: pr.number,
}); });
} catch (error) { } catch (error) {
const detail = resolveErrorDetail(error); const detail = resolveErrorDetail(error);
@ -139,7 +122,7 @@ export async function idleSubmitPrActivity(loopCtx: any): Promise<void> {
.update(handoffRuntime) .update(handoffRuntime)
.set({ .set({
statusMessage: `pr submit failed: ${detail}`, statusMessage: `pr submit failed: ${detail}`,
updatedAt: Date.now() updatedAt: Date.now(),
}) })
.where(eq(handoffRuntime.id, HANDOFF_ROW_ID)) .where(eq(handoffRuntime.id, HANDOFF_ROW_ID))
.run(); .run();
@ -147,7 +130,7 @@ export async function idleSubmitPrActivity(loopCtx: any): Promise<void> {
await appendHistory(loopCtx, "handoff.pr_create_failed", { await appendHistory(loopCtx, "handoff.pr_create_failed", {
handoffId: loopCtx.state.handoffId, handoffId: loopCtx.state.handoffId,
branchName: loopCtx.state.branchName, branchName: loopCtx.state.branchName,
error: detail error: detail,
}); });
} }
} }

View file

@ -4,4 +4,3 @@ export default defineConfig({
out: "./src/actors/history/db/drizzle", out: "./src/actors/history/db/drizzle",
schema: "./src/actors/history/db/schema.ts", schema: "./src/actors/history/db/schema.ts",
}); });

View file

@ -67,4 +67,4 @@
"internal": { "internal": {
"indexes": {} "indexes": {}
} }
} }

View file

@ -10,4 +10,4 @@
"breakpoints": true "breakpoints": true
} }
] ]
} }

View file

@ -3,14 +3,14 @@
// Do not hand-edit this file. // Do not hand-edit this file.
const journal = { const journal = {
"entries": [ entries: [
{ {
"idx": 0, idx: 0,
"when": 1770924375133, when: 1770924375133,
"tag": "0000_watery_bushwacker", tag: "0000_watery_bushwacker",
"breakpoints": true breakpoints: true,
} },
] ],
} as const; } as const;
export default { export default {
@ -25,5 +25,5 @@ export default {
\`created_at\` integer NOT NULL \`created_at\` integer NOT NULL
); );
`, `,
} as const } as const,
}; };

View file

@ -36,7 +36,7 @@ async function appendHistoryRow(loopCtx: any, body: AppendHistoryCommand): Promi
branchName: body.branchName ?? null, branchName: body.branchName ?? null,
kind: body.kind, kind: body.kind,
payloadJson: JSON.stringify(body.payload), payloadJson: JSON.stringify(body.payload),
createdAt: now createdAt: now,
}) })
.run(); .run();
} }
@ -45,7 +45,7 @@ async function runHistoryWorkflow(ctx: any): Promise<void> {
await ctx.loop("history-command-loop", async (loopCtx: any) => { await ctx.loop("history-command-loop", async (loopCtx: any) => {
const msg = await loopCtx.queue.next("next-history-command", { const msg = await loopCtx.queue.next("next-history-command", {
names: [...HISTORY_QUEUE_NAMES], names: [...HISTORY_QUEUE_NAMES],
completable: true completable: true,
}); });
if (!msg) { if (!msg) {
return Loop.continue(undefined); return Loop.continue(undefined);
@ -63,11 +63,11 @@ async function runHistoryWorkflow(ctx: any): Promise<void> {
export const history = actor({ export const history = actor({
db: historyDb, db: historyDb,
queues: { queues: {
"history.command.append": queue() "history.command.append": queue(),
}, },
createState: (_c, input: HistoryInput) => ({ createState: (_c, input: HistoryInput) => ({
workspaceId: input.workspaceId, workspaceId: input.workspaceId,
repoId: input.repoId repoId: input.repoId,
}), }),
actions: { actions: {
async append(c, command: AppendHistoryCommand): Promise<void> { async append(c, command: AppendHistoryCommand): Promise<void> {
@ -91,7 +91,7 @@ export const history = actor({
branchName: events.branchName, branchName: events.branchName,
kind: events.kind, kind: events.kind,
payloadJson: events.payloadJson, payloadJson: events.payloadJson,
createdAt: events.createdAt createdAt: events.createdAt,
}) })
.from(events); .from(events);
@ -103,9 +103,9 @@ export const history = actor({
return rows.map((row) => ({ return rows.map((row) => ({
...row, ...row,
workspaceId: c.state.workspaceId, workspaceId: c.state.workspaceId,
repoId: c.state.repoId repoId: c.state.repoId,
})); }));
} },
}, },
run: workflow(runHistoryWorkflow) run: workflow(runHistoryWorkflow),
}); });

View file

@ -35,10 +35,10 @@ export const registry = setup({
history, history,
projectPrSync, projectPrSync,
projectBranchSync, projectBranchSync,
handoffStatusSync handoffStatusSync,
}, },
managerPort: resolveManagerPort(), managerPort: resolveManagerPort(),
managerHost: resolveManagerHost() managerHost: resolveManagerHost(),
}); });
export * from "./context.js"; export * from "./context.js";

View file

@ -12,11 +12,7 @@ export function handoffKey(workspaceId: string, repoId: string, handoffId: strin
return ["ws", workspaceId, "project", repoId, "handoff", handoffId]; return ["ws", workspaceId, "project", repoId, "handoff", handoffId];
} }
export function sandboxInstanceKey( export function sandboxInstanceKey(workspaceId: string, providerId: string, sandboxId: string): ActorKey {
workspaceId: string,
providerId: string,
sandboxId: string
): ActorKey {
return ["ws", workspaceId, "provider", providerId, "sandbox", sandboxId]; 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"]; return ["ws", workspaceId, "project", repoId, "branch-sync"];
} }
export function handoffStatusSyncKey( export function handoffStatusSyncKey(workspaceId: string, repoId: string, handoffId: string, sandboxId: string, sessionId: string): ActorKey {
workspaceId: string,
repoId: string,
handoffId: string,
sandboxId: string,
sessionId: string
): ActorKey {
// Include sandbox + session so multiple sandboxes/sessions can be tracked per handoff. // Include sandbox + session so multiple sandboxes/sessions can be tracked per handoff.
return ["ws", workspaceId, "project", repoId, "handoff", handoffId, "status-sync", sandboxId, sessionId]; return ["ws", workspaceId, "project", repoId, "handoff", handoffId, "status-sync", sandboxId, sessionId];
} }

View file

@ -16,15 +16,11 @@ export function resolveErrorStack(error: unknown): string | undefined {
return undefined; return undefined;
} }
export function logActorWarning( export function logActorWarning(scope: string, message: string, context?: Record<string, unknown>): void {
scope: string,
message: string,
context?: Record<string, unknown>
): void {
const payload = { const payload = {
scope, scope,
message, message,
...(context ?? {}) ...(context ?? {}),
}; };
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.warn("[openhandoff][actor:warn]", payload); console.warn("[openhandoff][actor:warn]", payload);

View file

@ -23,12 +23,7 @@ interface PollingActorContext<TState extends PollingControlState> {
state: TState; state: TState;
abortSignal: AbortSignal; abortSignal: AbortSignal;
queue: { queue: {
nextBatch(options: { nextBatch(options: { names: readonly string[]; timeout: number; count: number; completable: true }): Promise<PollingQueueMessage[]>;
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>( export async function runPollingControlLoop<TState extends PollingControlState>(
c: PollingActorContext<TState>, c: PollingActorContext<TState>,
options: RunPollingOptions<TState> options: RunPollingOptions<TState>,
): Promise<void> { ): Promise<void> {
while (!c.abortSignal.aborted) { while (!c.abortSignal.aborted) {
const messages = normalizeMessages( const messages = normalizeMessages(
await c.queue.nextBatch({ await c.queue.nextBatch({
names: [ names: [options.control.start, options.control.stop, options.control.setInterval, options.control.force],
options.control.start,
options.control.stop,
options.control.setInterval,
options.control.force
],
timeout: Math.max(500, c.state.intervalMs), timeout: Math.max(500, c.state.intervalMs),
count: 16, count: 16,
completable: true completable: true,
}) }),
) as PollingQueueMessage[]; ) as PollingQueueMessage[];
if (messages.length === 0) { if (messages.length === 0) {
@ -94,12 +84,7 @@ export async function runPollingControlLoop<TState extends PollingControlState>(
interface WorkflowPollingActorContext<TState extends PollingControlState> { interface WorkflowPollingActorContext<TState extends PollingControlState> {
state: TState; state: TState;
loop(config: { loop(config: { name: string; historyEvery: number; historyKeep: number; run(ctx: WorkflowPollingActorContext<TState>): Promise<unknown> }): Promise<void>;
name: string;
historyEvery: number;
historyKeep: number;
run(ctx: WorkflowPollingActorContext<TState>): Promise<unknown>;
}): Promise<void>;
} }
interface WorkflowPollingQueueMessage extends PollingQueueMessage {} interface WorkflowPollingQueueMessage extends PollingQueueMessage {}
@ -107,12 +92,15 @@ interface WorkflowPollingQueueMessage extends PollingQueueMessage {}
interface WorkflowPollingLoopContext<TState extends PollingControlState> { interface WorkflowPollingLoopContext<TState extends PollingControlState> {
state: TState; state: TState;
queue: { queue: {
nextBatch(name: string, options: { nextBatch(
names: readonly string[]; name: string,
timeout: number; options: {
count: number; names: readonly string[];
completable: true; timeout: number;
}): Promise<WorkflowPollingQueueMessage[]>; count: number;
completable: true;
},
): Promise<WorkflowPollingQueueMessage[]>;
}; };
step<T>( step<T>(
nameOrConfig: nameOrConfig:
@ -138,12 +126,7 @@ export async function runWorkflowPollingLoop<TState extends PollingControlState>
const messages = normalizeMessages( const messages = normalizeMessages(
await loopCtx.queue.nextBatch("next-polling-control-batch", { await loopCtx.queue.nextBatch("next-polling-control-batch", {
names: [ names: [options.control.start, options.control.stop, options.control.setInterval, options.control.force],
options.control.start,
options.control.stop,
options.control.setInterval,
options.control.force,
],
timeout: control.running ? control.intervalMs : 60_000, timeout: control.running ? control.intervalMs : 60_000,
count: 16, count: 16,
completable: true, completable: true,
@ -172,37 +155,35 @@ export async function runWorkflowPollingLoop<TState extends PollingControlState>
continue; continue;
} }
if (msg.name === options.control.stop) { if (msg.name === options.control.stop) {
await loopCtx.step("control-stop", async () => { await loopCtx.step("control-stop", async () => {
loopCtx.state.running = false; loopCtx.state.running = false;
}); });
await msg.complete({ ok: true }); await msg.complete({ ok: true });
continue; 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.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); return Loop.continue(undefined);
}); });
} }

View file

@ -39,15 +39,10 @@ const CONTROL = {
start: "project.branch_sync.control.start", start: "project.branch_sync.control.start",
stop: "project.branch_sync.control.stop", stop: "project.branch_sync.control.stop",
setInterval: "project.branch_sync.control.set_interval", setInterval: "project.branch_sync.control.set_interval",
force: "project.branch_sync.control.force" force: "project.branch_sync.control.force",
} as const; } as const;
async function enrichBranches( async function enrichBranches(workspaceId: string, repoId: string, repoPath: string, git: GitDriver): Promise<EnrichedBranchSnapshot[]> {
workspaceId: string,
repoId: string,
repoPath: string,
git: GitDriver
): Promise<EnrichedBranchSnapshot[]> {
return await withRepoGitLock(repoPath, async () => { return await withRepoGitLock(repoPath, async () => {
await git.fetch(repoPath); await git.fetch(repoPath);
const branches = await git.listRemoteBranches(repoPath); const branches = await git.listRemoteBranches(repoPath);
@ -71,7 +66,7 @@ async function enrichBranches(
workspaceId, workspaceId,
repoId, repoId,
branchName: branch.branchName, branchName: branch.branchName,
error: resolveErrorMessage(error) error: resolveErrorMessage(error),
}); });
branchDiffStat = null; branchDiffStat = null;
} }
@ -84,7 +79,7 @@ async function enrichBranches(
workspaceId, workspaceId,
repoId, repoId,
branchName: branch.branchName, branchName: branch.branchName,
error: resolveErrorMessage(error) error: resolveErrorMessage(error),
}); });
branchHasUnpushed = false; branchHasUnpushed = false;
} }
@ -96,7 +91,7 @@ async function enrichBranches(
workspaceId, workspaceId,
repoId, repoId,
branchName: branch.branchName, branchName: branch.branchName,
error: resolveErrorMessage(error) error: resolveErrorMessage(error),
}); });
branchConflicts = false; branchConflicts = false;
} }
@ -108,7 +103,7 @@ async function enrichBranches(
trackedInStack: parentByBranch.has(branch.branchName), trackedInStack: parentByBranch.has(branch.branchName),
diffStat: branchDiffStat, diffStat: branchDiffStat,
hasUnpushed: branchHasUnpushed, hasUnpushed: branchHasUnpushed,
conflictsWithMain: branchConflicts conflictsWithMain: branchConflicts,
}); });
} }
@ -132,14 +127,14 @@ export const projectBranchSync = actor({
}, },
options: { options: {
// Polling actors rely on timer-based wakeups; sleeping would pause the timer and stop polling. // Polling actors rely on timer-based wakeups; sleeping would pause the timer and stop polling.
noSleep: true noSleep: true,
}, },
createState: (_c, input: ProjectBranchSyncInput): ProjectBranchSyncState => ({ createState: (_c, input: ProjectBranchSyncInput): ProjectBranchSyncState => ({
workspaceId: input.workspaceId, workspaceId: input.workspaceId,
repoId: input.repoId, repoId: input.repoId,
repoPath: input.repoPath, repoPath: input.repoPath,
intervalMs: input.intervalMs, intervalMs: input.intervalMs,
running: true running: true,
}), }),
actions: { actions: {
async start(c): Promise<void> { async start(c): Promise<void> {
@ -160,7 +155,7 @@ export const projectBranchSync = actor({
async force(c): Promise<void> { async force(c): Promise<void> {
const self = selfProjectBranchSync(c); const self = selfProjectBranchSync(c);
await self.send(CONTROL.force, {}, { wait: true, timeout: 5 * 60_000 }); await self.send(CONTROL.force, {}, { wait: true, timeout: 5 * 60_000 });
} },
}, },
run: workflow(async (ctx) => { run: workflow(async (ctx) => {
await runWorkflowPollingLoop<ProjectBranchSyncState>(ctx, { await runWorkflowPollingLoop<ProjectBranchSyncState>(ctx, {
@ -172,10 +167,10 @@ export const projectBranchSync = actor({
} catch (error) { } catch (error) {
logActorWarning("project-branch-sync", "poll failed", { logActorWarning("project-branch-sync", "poll failed", {
error: resolveErrorMessage(error), error: resolveErrorMessage(error),
stack: resolveErrorStack(error) stack: resolveErrorStack(error),
}); });
} }
} },
}); });
}) }),
}); });

View file

@ -26,7 +26,7 @@ const CONTROL = {
start: "project.pr_sync.control.start", start: "project.pr_sync.control.start",
stop: "project.pr_sync.control.stop", stop: "project.pr_sync.control.stop",
setInterval: "project.pr_sync.control.set_interval", setInterval: "project.pr_sync.control.set_interval",
force: "project.pr_sync.control.force" force: "project.pr_sync.control.force",
} as const; } as const;
async function pollPrs(c: { state: ProjectPrSyncState }): Promise<void> { async function pollPrs(c: { state: ProjectPrSyncState }): Promise<void> {
@ -45,14 +45,14 @@ export const projectPrSync = actor({
}, },
options: { options: {
// Polling actors rely on timer-based wakeups; sleeping would pause the timer and stop polling. // Polling actors rely on timer-based wakeups; sleeping would pause the timer and stop polling.
noSleep: true noSleep: true,
}, },
createState: (_c, input: ProjectPrSyncInput): ProjectPrSyncState => ({ createState: (_c, input: ProjectPrSyncInput): ProjectPrSyncState => ({
workspaceId: input.workspaceId, workspaceId: input.workspaceId,
repoId: input.repoId, repoId: input.repoId,
repoPath: input.repoPath, repoPath: input.repoPath,
intervalMs: input.intervalMs, intervalMs: input.intervalMs,
running: true running: true,
}), }),
actions: { actions: {
async start(c): Promise<void> { async start(c): Promise<void> {
@ -73,7 +73,7 @@ export const projectPrSync = actor({
async force(c): Promise<void> { async force(c): Promise<void> {
const self = selfProjectPrSync(c); const self = selfProjectPrSync(c);
await self.send(CONTROL.force, {}, { wait: true, timeout: 5 * 60_000 }); await self.send(CONTROL.force, {}, { wait: true, timeout: 5 * 60_000 });
} },
}, },
run: workflow(async (ctx) => { run: workflow(async (ctx) => {
await runWorkflowPollingLoop<ProjectPrSyncState>(ctx, { await runWorkflowPollingLoop<ProjectPrSyncState>(ctx, {
@ -85,10 +85,10 @@ export const projectPrSync = actor({
} catch (error) { } catch (error) {
logActorWarning("project-pr-sync", "poll failed", { logActorWarning("project-pr-sync", "poll failed", {
error: resolveErrorMessage(error), error: resolveErrorMessage(error),
stack: resolveErrorStack(error) stack: resolveErrorStack(error),
}); });
} }
} },
}); });
}) }),
}); });

View file

@ -2,24 +2,9 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { and, desc, eq, isNotNull, ne } from "drizzle-orm"; import { and, desc, eq, isNotNull, ne } from "drizzle-orm";
import { Loop } from "rivetkit/workflow"; import { Loop } from "rivetkit/workflow";
import type { import type { AgentType, HandoffRecord, HandoffSummary, ProviderId, RepoOverview, RepoStackAction, RepoStackActionResult } from "@openhandoff/shared";
AgentType,
HandoffRecord,
HandoffSummary,
ProviderId,
RepoOverview,
RepoStackAction,
RepoStackActionResult
} from "@openhandoff/shared";
import { getActorRuntimeContext } from "../context.js"; import { getActorRuntimeContext } from "../context.js";
import { import { getHandoff, getOrCreateHandoff, getOrCreateHistory, getOrCreateProjectBranchSync, getOrCreateProjectPrSync, selfProject } from "../handles.js";
getHandoff,
getOrCreateHandoff,
getOrCreateHistory,
getOrCreateProjectBranchSync,
getOrCreateProjectPrSync,
selfProject
} from "../handles.js";
import { isActorNotFoundError, logActorWarning, resolveErrorMessage } from "../logging.js"; import { isActorNotFoundError, logActorWarning, resolveErrorMessage } from "../logging.js";
import { openhandoffRepoClonePath } from "../../services/openhandoff-paths.js"; import { openhandoffRepoClonePath } from "../../services/openhandoff-paths.js";
import { expectQueueResponse } from "../../services/queue.js"; import { expectQueueResponse } from "../../services/queue.js";
@ -163,11 +148,7 @@ async function ensureHandoffIndexHydrated(c: any): Promise<void> {
return; return;
} }
const existing = await c.db const existing = await c.db.select({ handoffId: handoffIndex.handoffId }).from(handoffIndex).limit(1).get();
.select({ handoffId: handoffIndex.handoffId })
.from(handoffIndex)
.limit(1)
.get();
if (existing) { if (existing) {
c.state.handoffIndexHydrated = true; c.state.handoffIndexHydrated = true;
@ -204,7 +185,7 @@ async function ensureHandoffIndexHydrated(c: any): Promise<void> {
handoffId: row.handoffId, handoffId: row.handoffId,
branchName: row.branchName, branchName: row.branchName,
createdAt: row.createdAt, createdAt: row.createdAt,
updatedAt: row.createdAt updatedAt: row.createdAt,
}) })
.onConflictDoNothing() .onConflictDoNothing()
.run(); .run();
@ -214,14 +195,14 @@ async function ensureHandoffIndexHydrated(c: any): Promise<void> {
logActorWarning("project", "skipped missing handoffs while hydrating index", { logActorWarning("project", "skipped missing handoffs while hydrating index", {
workspaceId: c.state.workspaceId, workspaceId: c.state.workspaceId,
repoId: c.state.repoId, repoId: c.state.repoId,
skippedMissingHandoffActors skippedMissingHandoffActors,
}); });
} }
} catch (error) { } catch (error) {
logActorWarning("project", "handoff index hydration from history failed", { logActorWarning("project", "handoff index hydration from history failed", {
workspaceId: c.state.workspaceId, workspaceId: c.state.workspaceId,
repoId: c.state.repoId, 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, diffStat: branches.diffStat,
hasUnpushed: branches.hasUnpushed, hasUnpushed: branches.hasUnpushed,
conflictsWithMain: branches.conflictsWithMain, conflictsWithMain: branches.conflictsWithMain,
parentBranch: branches.parentBranch parentBranch: branches.parentBranch,
}) })
.from(branches) .from(branches)
.where(eq(branches.branchName, branchName)) .where(eq(branches.branchName, branchName))
@ -298,7 +279,7 @@ async function enrichHandoffRecord(c: any, record: HandoffRecord): Promise<Hando
prAuthor: prCache.prAuthor, prAuthor: prCache.prAuthor,
ciStatus: prCache.ciStatus, ciStatus: prCache.ciStatus,
reviewStatus: prCache.reviewStatus, reviewStatus: prCache.reviewStatus,
reviewer: prCache.reviewer reviewer: prCache.reviewer,
}) })
.from(prCache) .from(prCache)
.where(eq(prCache.branchName, branchName)) .where(eq(prCache.branchName, branchName))
@ -315,7 +296,7 @@ async function enrichHandoffRecord(c: any, record: HandoffRecord): Promise<Hando
prAuthor: pr?.prAuthor ?? null, prAuthor: pr?.prAuthor ?? null,
ciStatus: pr?.ciStatus ?? null, ciStatus: pr?.ciStatus ?? null,
reviewStatus: pr?.reviewStatus ?? 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({ .values({
id: 1, id: 1,
remoteUrl: cmd.remoteUrl, remoteUrl: cmd.remoteUrl,
updatedAt: Date.now() updatedAt: Date.now(),
}) })
.onConflictDoUpdate({ .onConflictDoUpdate({
target: repoMeta.id, target: repoMeta.id,
set: { set: {
remoteUrl: cmd.remoteUrl, remoteUrl: cmd.remoteUrl,
updatedAt: Date.now() updatedAt: Date.now(),
} },
}) })
.run(); .run();
@ -357,11 +338,7 @@ async function createHandoffMutation(c: any, cmd: CreateHandoffCommand): Promise
if (onBranch) { if (onBranch) {
await forceProjectSync(c, localPath); await forceProjectSync(c, localPath);
const branchRow = await c.db const branchRow = await c.db.select({ branchName: branches.branchName }).from(branches).where(eq(branches.branchName, onBranch)).get();
.select({ branchName: branches.branchName })
.from(branches)
.where(eq(branches.branchName, onBranch))
.get();
if (!branchRow) { if (!branchRow) {
throw new Error(`Branch not found in repo snapshot: ${onBranch}`); 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, { await registerHandoffBranchMutation(c, {
handoffId, handoffId,
branchName: onBranch, branchName: onBranch,
requireExistingRemote: true requireExistingRemote: true,
}); });
} }
@ -387,11 +364,15 @@ async function createHandoffMutation(c: any, cmd: CreateHandoffCommand): Promise
providerId: cmd.providerId, providerId: cmd.providerId,
agentType: cmd.agentType, agentType: cmd.agentType,
explicitTitle: onBranch ? null : cmd.explicitTitle, explicitTitle: onBranch ? null : cmd.explicitTitle,
explicitBranchName: onBranch ? null : cmd.explicitBranchName explicitBranchName: onBranch ? null : cmd.explicitBranchName,
}); });
} catch (error) { } catch (error) {
if (onBranch) { 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; throw error;
} }
@ -404,7 +385,7 @@ async function createHandoffMutation(c: any, cmd: CreateHandoffCommand): Promise
handoffId, handoffId,
branchName: initialBranchName, branchName: initialBranchName,
createdAt: now, createdAt: now,
updatedAt: now updatedAt: now,
}) })
.onConflictDoNothing() .onConflictDoNothing()
.run(); .run();
@ -418,17 +399,14 @@ async function createHandoffMutation(c: any, cmd: CreateHandoffCommand): Promise
handoffId, handoffId,
payload: { payload: {
repoId: c.state.repoId, repoId: c.state.repoId,
providerId: cmd.providerId providerId: cmd.providerId,
} },
}); });
return created; return created;
} }
async function registerHandoffBranchMutation( async function registerHandoffBranchMutation(c: any, cmd: RegisterHandoffBranchCommand): Promise<{ branchName: string; headSha: string }> {
c: any,
cmd: RegisterHandoffBranchCommand,
): Promise<{ branchName: string; headSha: string }> {
const localPath = await ensureProjectReady(c); const localPath = await ensureProjectReady(c);
const branchName = cmd.branchName.trim(); const branchName = cmd.branchName.trim();
@ -458,7 +436,7 @@ async function registerHandoffBranchMutation(
workspaceId: c.state.workspaceId, workspaceId: c.state.workspaceId,
repoId: c.state.repoId, repoId: c.state.repoId,
handoffId: existingOwner.handoffId, handoffId: existingOwner.handoffId,
branchName branchName,
}); });
} else { } else {
throw error; throw error;
@ -508,7 +486,7 @@ async function registerHandoffBranchMutation(
workspaceId: c.state.workspaceId, workspaceId: c.state.workspaceId,
repoId: c.state.repoId, repoId: c.state.repoId,
branchName, branchName,
error: resolveErrorMessage(error) error: resolveErrorMessage(error),
}); });
} }
stackRows = await driver.stack.listStack(localPath).catch(() => []); stackRows = await driver.stack.listStack(localPath).catch(() => []);
@ -530,7 +508,7 @@ async function registerHandoffBranchMutation(
trackedInStack: trackedInStack ? 1 : 0, trackedInStack: trackedInStack ? 1 : 0,
firstSeenAt: now, firstSeenAt: now,
lastSeenAt: now, lastSeenAt: now,
updatedAt: now updatedAt: now,
}) })
.onConflictDoUpdate({ .onConflictDoUpdate({
target: branches.branchName, target: branches.branchName,
@ -539,8 +517,8 @@ async function registerHandoffBranchMutation(
parentBranch, parentBranch,
trackedInStack: trackedInStack ? 1 : 0, trackedInStack: trackedInStack ? 1 : 0,
lastSeenAt: now, lastSeenAt: now,
updatedAt: now updatedAt: now,
} },
}) })
.run(); .run();
@ -550,14 +528,14 @@ async function registerHandoffBranchMutation(
handoffId: cmd.handoffId, handoffId: cmd.handoffId,
branchName, branchName,
createdAt: now, createdAt: now,
updatedAt: now updatedAt: now,
}) })
.onConflictDoUpdate({ .onConflictDoUpdate({
target: handoffIndex.handoffId, target: handoffIndex.handoffId,
set: { set: {
branchName, branchName,
updatedAt: now updatedAt: now,
} },
}) })
.run(); .run();
@ -579,7 +557,7 @@ async function runRepoStackActionMutation(c: any, cmd: RunRepoStackActionCommand
action, action,
executed: false, executed: false,
message: "git-spice is not available for this repo", 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); await forceProjectSync(c, localPath);
if (branchName) { if (branchName) {
const row = await c.db const row = await c.db.select({ branchName: branches.branchName }).from(branches).where(eq(branches.branchName, branchName)).get();
.select({ branchName: branches.branchName })
.from(branches)
.where(eq(branches.branchName, branchName))
.get();
if (!row) { if (!row) {
throw new Error(`Branch not found in repo snapshot: ${branchName}`); throw new Error(`Branch not found in repo snapshot: ${branchName}`);
} }
@ -610,11 +584,7 @@ async function runRepoStackActionMutation(c: any, cmd: RunRepoStackActionCommand
if (parentBranch === branchName) { if (parentBranch === branchName) {
throw new Error("parentBranch must be different from branchName"); throw new Error("parentBranch must be different from branchName");
} }
const parentRow = await c.db const parentRow = await c.db.select({ branchName: branches.branchName }).from(branches).where(eq(branches.branchName, parentBranch)).get();
.select({ branchName: branches.branchName })
.from(branches)
.where(eq(branches.branchName, parentBranch))
.get();
if (!parentRow) { if (!parentRow) {
throw new Error(`Parent branch not found in repo snapshot: ${parentBranch}`); throw new Error(`Parent branch not found in repo snapshot: ${parentBranch}`);
} }
@ -646,15 +616,15 @@ async function runRepoStackActionMutation(c: any, cmd: RunRepoStackActionCommand
payload: { payload: {
action, action,
branchName: branchName ?? null, branchName: branchName ?? null,
parentBranch: parentBranch ?? null parentBranch: parentBranch ?? null,
} },
}); });
} catch (error) { } catch (error) {
logActorWarning("project", "failed appending repo stack history event", { logActorWarning("project", "failed appending repo stack history event", {
workspaceId: c.state.workspaceId, workspaceId: c.state.workspaceId,
repoId: c.state.repoId, repoId: c.state.repoId,
action, action,
error: resolveErrorMessage(error) error: resolveErrorMessage(error),
}); });
} }
@ -662,7 +632,7 @@ async function runRepoStackActionMutation(c: any, cmd: RunRepoStackActionCommand
action, action,
executed: true, executed: true,
message: `stack action executed: ${action}`, message: `stack action executed: ${action}`,
at at,
}; };
} }
@ -684,7 +654,7 @@ async function applyPrSyncResultMutation(c: any, body: PrSyncResult): Promise<vo
reviewStatus: item.reviewStatus ?? null, reviewStatus: item.reviewStatus ?? null,
reviewer: item.reviewer ?? null, reviewer: item.reviewer ?? null,
fetchedAt: body.at, fetchedAt: body.at,
updatedAt: body.at updatedAt: body.at,
}) })
.onConflictDoUpdate({ .onConflictDoUpdate({
target: prCache.branchName, target: prCache.branchName,
@ -699,8 +669,8 @@ async function applyPrSyncResultMutation(c: any, body: PrSyncResult): Promise<vo
reviewStatus: item.reviewStatus ?? null, reviewStatus: item.reviewStatus ?? null,
reviewer: item.reviewer ?? null, reviewer: item.reviewer ?? null,
fetchedAt: body.at, fetchedAt: body.at,
updatedAt: body.at updatedAt: body.at,
} },
}) })
.run(); .run();
} }
@ -710,11 +680,7 @@ async function applyPrSyncResultMutation(c: any, body: PrSyncResult): Promise<vo
continue; continue;
} }
const row = await c.db const row = await c.db.select({ handoffId: handoffIndex.handoffId }).from(handoffIndex).where(eq(handoffIndex.branchName, item.headRefName)).get();
.select({ handoffId: handoffIndex.handoffId })
.from(handoffIndex)
.where(eq(handoffIndex.branchName, item.headRefName))
.get();
if (!row) { if (!row) {
continue; continue;
} }
@ -730,7 +696,7 @@ async function applyPrSyncResultMutation(c: any, body: PrSyncResult): Promise<vo
repoId: c.state.repoId, repoId: c.state.repoId,
handoffId: row.handoffId, handoffId: row.handoffId,
branchName: item.headRefName, branchName: item.headRefName,
prState: item.state prState: item.state,
}); });
continue; continue;
} }
@ -740,7 +706,7 @@ async function applyPrSyncResultMutation(c: any, body: PrSyncResult): Promise<vo
handoffId: row.handoffId, handoffId: row.handoffId,
branchName: item.headRefName, branchName: item.headRefName,
prState: item.state, 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) { for (const item of body.items) {
const existing = await c.db const existing = await c.db
.select({ .select({
firstSeenAt: branches.firstSeenAt firstSeenAt: branches.firstSeenAt,
}) })
.from(branches) .from(branches)
.where(eq(branches.branchName, item.branchName)) .where(eq(branches.branchName, item.branchName))
@ -770,7 +736,7 @@ async function applyBranchSyncResultMutation(c: any, body: BranchSyncResult): Pr
conflictsWithMain: item.conflictsWithMain ? 1 : 0, conflictsWithMain: item.conflictsWithMain ? 1 : 0,
firstSeenAt: existing?.firstSeenAt ?? body.at, firstSeenAt: existing?.firstSeenAt ?? body.at,
lastSeenAt: body.at, lastSeenAt: body.at,
updatedAt: body.at updatedAt: body.at,
}) })
.onConflictDoUpdate({ .onConflictDoUpdate({
target: branches.branchName, target: branches.branchName,
@ -783,16 +749,13 @@ async function applyBranchSyncResultMutation(c: any, body: BranchSyncResult): Pr
conflictsWithMain: item.conflictsWithMain ? 1 : 0, conflictsWithMain: item.conflictsWithMain ? 1 : 0,
firstSeenAt: existing?.firstSeenAt ?? body.at, firstSeenAt: existing?.firstSeenAt ?? body.at,
lastSeenAt: body.at, lastSeenAt: body.at,
updatedAt: body.at updatedAt: body.at,
} },
}) })
.run(); .run();
} }
const existingRows = await c.db const existingRows = await c.db.select({ branchName: branches.branchName }).from(branches).all();
.select({ branchName: branches.branchName })
.from(branches)
.all();
for (const row of existingRows) { for (const row of existingRows) {
if (incoming.has(row.branchName)) { if (incoming.has(row.branchName)) {
@ -822,62 +785,60 @@ export async function runProjectWorkflow(ctx: any): Promise<void> {
return Loop.continue(undefined); return Loop.continue(undefined);
} }
if (msg.name === "project.command.hydrateHandoffIndex") { if (msg.name === "project.command.hydrateHandoffIndex") {
await loopCtx.step("project-hydrate-handoff-index", async () => await loopCtx.step("project-hydrate-handoff-index", async () => hydrateHandoffIndexMutation(loopCtx, msg.body as HydrateHandoffIndexCommand));
hydrateHandoffIndexMutation(loopCtx, msg.body as HydrateHandoffIndexCommand), await msg.complete({ ok: true });
); return Loop.continue(undefined);
await msg.complete({ ok: true }); }
return Loop.continue(undefined);
}
if (msg.name === "project.command.createHandoff") { if (msg.name === "project.command.createHandoff") {
const result = await loopCtx.step({ const result = await loopCtx.step({
name: "project-create-handoff", name: "project-create-handoff",
timeout: 12 * 60_000, timeout: 12 * 60_000,
run: async () => createHandoffMutation(loopCtx, msg.body as CreateHandoffCommand), run: async () => createHandoffMutation(loopCtx, msg.body as CreateHandoffCommand),
}); });
await msg.complete(result); await msg.complete(result);
return Loop.continue(undefined); return Loop.continue(undefined);
} }
if (msg.name === "project.command.registerHandoffBranch") { if (msg.name === "project.command.registerHandoffBranch") {
const result = await loopCtx.step({ const result = await loopCtx.step({
name: "project-register-handoff-branch", name: "project-register-handoff-branch",
timeout: 5 * 60_000, timeout: 5 * 60_000,
run: async () => registerHandoffBranchMutation(loopCtx, msg.body as RegisterHandoffBranchCommand), run: async () => registerHandoffBranchMutation(loopCtx, msg.body as RegisterHandoffBranchCommand),
}); });
await msg.complete(result); await msg.complete(result);
return Loop.continue(undefined); return Loop.continue(undefined);
} }
if (msg.name === "project.command.runRepoStackAction") { if (msg.name === "project.command.runRepoStackAction") {
const result = await loopCtx.step({ const result = await loopCtx.step({
name: "project-run-repo-stack-action", name: "project-run-repo-stack-action",
timeout: 12 * 60_000, timeout: 12 * 60_000,
run: async () => runRepoStackActionMutation(loopCtx, msg.body as RunRepoStackActionCommand), run: async () => runRepoStackActionMutation(loopCtx, msg.body as RunRepoStackActionCommand),
}); });
await msg.complete(result); await msg.complete(result);
return Loop.continue(undefined); return Loop.continue(undefined);
} }
if (msg.name === "project.command.applyPrSyncResult") { if (msg.name === "project.command.applyPrSyncResult") {
await loopCtx.step({ await loopCtx.step({
name: "project-apply-pr-sync-result", name: "project-apply-pr-sync-result",
timeout: 60_000, timeout: 60_000,
run: async () => applyPrSyncResultMutation(loopCtx, msg.body as PrSyncResult), run: async () => applyPrSyncResultMutation(loopCtx, msg.body as PrSyncResult),
}); });
await msg.complete({ ok: true }); await msg.complete({ ok: true });
return Loop.continue(undefined); return Loop.continue(undefined);
} }
if (msg.name === "project.command.applyBranchSyncResult") { if (msg.name === "project.command.applyBranchSyncResult") {
await loopCtx.step({ await loopCtx.step({
name: "project-apply-branch-sync-result", name: "project-apply-branch-sync-result",
timeout: 60_000, timeout: 60_000,
run: async () => applyBranchSyncResultMutation(loopCtx, msg.body as BranchSyncResult), run: async () => applyBranchSyncResultMutation(loopCtx, msg.body as BranchSyncResult),
}); });
await msg.complete({ ok: true }); await msg.complete({ ok: true });
} }
return Loop.continue(undefined); return Loop.continue(undefined);
}); });
@ -907,15 +868,9 @@ export const projectActions = {
async listReservedBranches(c: any, _cmd?: ListReservedBranchesCommand): Promise<string[]> { async listReservedBranches(c: any, _cmd?: ListReservedBranchesCommand): Promise<string[]> {
await ensureHandoffIndexHydratedForRead(c); await ensureHandoffIndexHydratedForRead(c);
const rows = await c.db const rows = await c.db.select({ branchName: handoffIndex.branchName }).from(handoffIndex).where(isNotNull(handoffIndex.branchName)).all();
.select({ branchName: handoffIndex.branchName })
.from(handoffIndex)
.where(isNotNull(handoffIndex.branchName))
.all();
return rows return rows.map((row) => row.branchName).filter((name): name is string => typeof name === "string" && name.trim().length > 0);
.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 }> { async registerHandoffBranch(c: any, cmd: RegisterHandoffBranchCommand): Promise<{ branchName: string; headSha: string }> {
@ -942,11 +897,7 @@ export const projectActions = {
await ensureHandoffIndexHydratedForRead(c); await ensureHandoffIndexHydratedForRead(c);
const handoffRows = await c.db const handoffRows = await c.db.select({ handoffId: handoffIndex.handoffId }).from(handoffIndex).orderBy(desc(handoffIndex.updatedAt)).all();
.select({ handoffId: handoffIndex.handoffId })
.from(handoffIndex)
.orderBy(desc(handoffIndex.updatedAt))
.all();
for (const row of handoffRows) { for (const row of handoffRows) {
try { try {
@ -964,7 +915,7 @@ export const projectActions = {
branchName: record.branchName, branchName: record.branchName,
title: record.title, title: record.title,
status: record.status, status: record.status,
updatedAt: record.updatedAt updatedAt: record.updatedAt,
}); });
} catch (error) { } catch (error) {
if (isStaleHandoffReferenceError(error)) { if (isStaleHandoffReferenceError(error)) {
@ -972,7 +923,7 @@ export const projectActions = {
logActorWarning("project", "pruned stale handoff index row during summary listing", { logActorWarning("project", "pruned stale handoff index row during summary listing", {
workspaceId: c.state.workspaceId, workspaceId: c.state.workspaceId,
repoId: c.state.repoId, repoId: c.state.repoId,
handoffId: row.handoffId handoffId: row.handoffId,
}); });
continue; continue;
} }
@ -980,7 +931,7 @@ export const projectActions = {
workspaceId: c.state.workspaceId, workspaceId: c.state.workspaceId,
repoId: c.state.repoId, repoId: c.state.repoId,
handoffId: row.handoffId, handoffId: row.handoffId,
error: resolveErrorMessage(error) error: resolveErrorMessage(error),
}); });
} }
} }
@ -992,11 +943,7 @@ export const projectActions = {
async getHandoffEnriched(c: any, cmd: GetHandoffEnrichedCommand): Promise<HandoffRecord> { async getHandoffEnriched(c: any, cmd: GetHandoffEnrichedCommand): Promise<HandoffRecord> {
await ensureHandoffIndexHydratedForRead(c); await ensureHandoffIndexHydratedForRead(c);
const row = await c.db const row = await c.db.select({ handoffId: handoffIndex.handoffId }).from(handoffIndex).where(eq(handoffIndex.handoffId, cmd.handoffId)).get();
.select({ handoffId: handoffIndex.handoffId })
.from(handoffIndex)
.where(eq(handoffIndex.handoffId, cmd.handoffId))
.get();
if (!row) { if (!row) {
throw new Error(`Unknown handoff in repo ${c.state.repoId}: ${cmd.handoffId}`); throw new Error(`Unknown handoff in repo ${c.state.repoId}: ${cmd.handoffId}`);
} }
@ -1035,7 +982,7 @@ export const projectActions = {
conflictsWithMain: branches.conflictsWithMain, conflictsWithMain: branches.conflictsWithMain,
firstSeenAt: branches.firstSeenAt, firstSeenAt: branches.firstSeenAt,
lastSeenAt: branches.lastSeenAt, lastSeenAt: branches.lastSeenAt,
updatedAt: branches.updatedAt updatedAt: branches.updatedAt,
}) })
.from(branches) .from(branches)
.all(); .all();
@ -1044,15 +991,12 @@ export const projectActions = {
.select({ .select({
handoffId: handoffIndex.handoffId, handoffId: handoffIndex.handoffId,
branchName: handoffIndex.branchName, branchName: handoffIndex.branchName,
updatedAt: handoffIndex.updatedAt updatedAt: handoffIndex.updatedAt,
}) })
.from(handoffIndex) .from(handoffIndex)
.all(); .all();
const handoffMetaByBranch = new Map< const handoffMetaByBranch = new Map<string, { handoffId: string; title: string | null; status: HandoffRecord["status"] | null; updatedAt: number }>();
string,
{ handoffId: string; title: string | null; status: HandoffRecord["status"] | null; updatedAt: number }
>();
for (const row of handoffRows) { for (const row of handoffRows) {
if (!row.branchName) { if (!row.branchName) {
@ -1065,7 +1009,7 @@ export const projectActions = {
handoffId: row.handoffId, handoffId: row.handoffId,
title: record.title ?? null, title: record.title ?? null,
status: record.status, status: record.status,
updatedAt: record.updatedAt updatedAt: record.updatedAt,
}); });
} catch (error) { } catch (error) {
if (isStaleHandoffReferenceError(error)) { if (isStaleHandoffReferenceError(error)) {
@ -1074,7 +1018,7 @@ export const projectActions = {
workspaceId: c.state.workspaceId, workspaceId: c.state.workspaceId,
repoId: c.state.repoId, repoId: c.state.repoId,
handoffId: row.handoffId, handoffId: row.handoffId,
branchName: row.branchName branchName: row.branchName,
}); });
continue; continue;
} }
@ -1083,7 +1027,7 @@ export const projectActions = {
repoId: c.state.repoId, repoId: c.state.repoId,
handoffId: row.handoffId, handoffId: row.handoffId,
branchName: row.branchName, branchName: row.branchName,
error: resolveErrorMessage(error) error: resolveErrorMessage(error),
}); });
} }
} }
@ -1096,7 +1040,7 @@ export const projectActions = {
prUrl: prCache.prUrl, prUrl: prCache.prUrl,
ciStatus: prCache.ciStatus, ciStatus: prCache.ciStatus,
reviewStatus: prCache.reviewStatus, reviewStatus: prCache.reviewStatus,
reviewer: prCache.reviewer reviewer: prCache.reviewer,
}) })
.from(prCache) .from(prCache)
.all(); .all();
@ -1106,8 +1050,8 @@ export const projectActions = {
branchRowsRaw.map((row) => ({ branchRowsRaw.map((row) => ({
branchName: row.branchName, branchName: row.branchName,
parentBranch: row.parentBranch ?? null, parentBranch: row.parentBranch ?? null,
updatedAt: row.updatedAt updatedAt: row.updatedAt,
})) })),
); );
const detailByBranch = new Map(branchRowsRaw.map((row) => [row.branchName, row])); const detailByBranch = new Map(branchRowsRaw.map((row) => [row.branchName, row]));
@ -1135,7 +1079,7 @@ export const projectActions = {
reviewer: pr?.reviewer ?? null, reviewer: pr?.reviewer ?? null,
firstSeenAt: row.firstSeenAt ?? null, firstSeenAt: row.firstSeenAt ?? null,
lastSeenAt: row.lastSeenAt ?? 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, baseRef,
stackAvailable, stackAvailable,
fetchedAt: now, fetchedAt: now,
branches: branchRows branches: branchRows,
}; };
}, },
async getPullRequestForBranch( async getPullRequestForBranch(c: any, cmd: GetPullRequestForBranchCommand): Promise<{ number: number; status: "draft" | "ready" } | null> {
c: any,
cmd: GetPullRequestForBranchCommand,
): Promise<{ number: number; status: "draft" | "ready" } | null> {
const branchName = cmd.branchName?.trim(); const branchName = cmd.branchName?.trim();
if (!branchName) { if (!branchName) {
return null; return null;
@ -1202,5 +1143,5 @@ export const projectActions = {
wait: true, wait: true,
timeout: 5 * 60_000, timeout: 5 * 60_000,
}); });
} },
}; };

View file

@ -4,4 +4,3 @@ export default defineConfig({
out: "./src/actors/project/db/drizzle", out: "./src/actors/project/db/drizzle",
schema: "./src/actors/project/db/schema.ts", schema: "./src/actors/project/db/schema.ts",
}); });

View file

@ -189,4 +189,4 @@
"internal": { "internal": {
"indexes": {} "indexes": {}
} }
} }

View file

@ -213,4 +213,4 @@
"internal": { "internal": {
"indexes": {} "indexes": {}
} }
} }

View file

@ -251,4 +251,4 @@
"internal": { "internal": {
"indexes": {} "indexes": {}
} }
} }

View file

@ -3,32 +3,32 @@
// Do not hand-edit this file. // Do not hand-edit this file.
const journal = { const journal = {
"entries": [ entries: [
{ {
"idx": 0, idx: 0,
"when": 1770924376062, when: 1770924376062,
"tag": "0000_stormy_the_hunter", tag: "0000_stormy_the_hunter",
"breakpoints": true breakpoints: true,
}, },
{ {
"idx": 1, idx: 1,
"when": 1770947252449, when: 1770947252449,
"tag": "0001_wild_carlie_cooper", tag: "0001_wild_carlie_cooper",
"breakpoints": true breakpoints: true,
}, },
{ {
"idx": 2, idx: 2,
"when": 1771276338465, when: 1771276338465,
"tag": "0002_far_war_machine", tag: "0002_far_war_machine",
"breakpoints": true breakpoints: true,
}, },
{ {
"idx": 3, idx: 3,
"when": 1771369000000, when: 1771369000000,
"tag": "0003_busy_legacy", tag: "0003_busy_legacy",
"breakpoints": true breakpoints: true,
} },
] ],
} as const; } as const;
export default { export default {
@ -77,5 +77,5 @@ ALTER TABLE \`branches\` DROP COLUMN \`worktree_path\`;`,
); );
`, `,
m0003: `ALTER TABLE \`branches\` ADD \`tracked_in_stack\` integer;`, m0003: `ALTER TABLE \`branches\` ADD \`tracked_in_stack\` integer;`,
} as const } as const,
}; };

View file

@ -40,5 +40,5 @@ export const handoffIndex = sqliteTable("handoff_index", {
handoffId: text("handoff_id").notNull().primaryKey(), handoffId: text("handoff_id").notNull().primaryKey(),
branchName: text("branch_name"), branchName: text("branch_name"),
createdAt: integer("created_at").notNull(), createdAt: integer("created_at").notNull(),
updatedAt: integer("updated_at").notNull() updatedAt: integer("updated_at").notNull(),
}); });

View file

@ -21,7 +21,7 @@ export const project = actor({
remoteUrl: input.remoteUrl, remoteUrl: input.remoteUrl,
localPath: null as string | null, localPath: null as string | null,
syncActorsStarted: false, syncActorsStarted: false,
handoffIndexHydrated: false handoffIndexHydrated: false,
}), }),
actions: projectActions, actions: projectActions,
run: workflow(runProjectWorkflow), run: workflow(runProjectWorkflow),

View file

@ -4,4 +4,3 @@ export default defineConfig({
out: "./src/actors/sandbox-instance/db/drizzle", out: "./src/actors/sandbox-instance/db/drizzle",
schema: "./src/actors/sandbox-instance/db/schema.ts", schema: "./src/actors/sandbox-instance/db/schema.ts",
}); });

View file

@ -53,4 +53,4 @@
"internal": { "internal": {
"indexes": {} "indexes": {}
} }
} }

View file

@ -3,20 +3,20 @@
// Do not hand-edit this file. // Do not hand-edit this file.
const journal = { const journal = {
"entries": [ entries: [
{ {
"idx": 0, idx: 0,
"when": 1770924375604, when: 1770924375604,
"tag": "0000_broad_tyrannus", tag: "0000_broad_tyrannus",
"breakpoints": true breakpoints: true,
}, },
{ {
"idx": 1, idx: 1,
"when": 1776482400000, when: 1776482400000,
"tag": "0001_sandbox_sessions", tag: "0001_sandbox_sessions",
"breakpoints": true breakpoints: true,
} },
] ],
} as const; } as const;
export default { export default {
@ -57,5 +57,5 @@ CREATE INDEX \`sandbox_session_events_session_id_event_index_idx\` ON \`sandbox_
--> statement-breakpoint --> statement-breakpoint
CREATE INDEX \`sandbox_session_events_session_id_created_at_idx\` ON \`sandbox_session_events\` (\`session_id\`,\`created_at\`); CREATE INDEX \`sandbox_session_events_session_id_created_at_idx\` ON \`sandbox_session_events\` (\`session_id\`,\`created_at\`);
`, `,
} as const } as const,
}; };

View file

@ -23,9 +23,7 @@ const CREATE_SESSION_MAX_ATTEMPTS = 3;
const CREATE_SESSION_RETRY_BASE_MS = 1_000; const CREATE_SESSION_RETRY_BASE_MS = 1_000;
const CREATE_SESSION_STEP_TIMEOUT_MS = 10 * 60_000; const CREATE_SESSION_STEP_TIMEOUT_MS = 10 * 60_000;
function normalizeStatusFromEventPayload( function normalizeStatusFromEventPayload(payload: unknown): "running" | "idle" | "error" | null {
payload: unknown,
): "running" | "idle" | "error" | null {
if (payload && typeof payload === "object") { if (payload && typeof payload === "object") {
const envelope = payload as { const envelope = payload as {
error?: unknown; error?: unknown;
@ -49,11 +47,7 @@ function normalizeStatusFromEventPayload(
if (lowered.includes("error") || lowered.includes("failed")) { if (lowered.includes("error") || lowered.includes("failed")) {
return "error"; return "error";
} }
if ( if (lowered.includes("ended") || lowered.includes("complete") || lowered.includes("stopped")) {
lowered.includes("ended") ||
lowered.includes("complete") ||
lowered.includes("stopped")
) {
return "idle"; return "idle";
} }
} }
@ -183,12 +177,7 @@ async function derivePersistedSessionStatus(
function isTransientSessionCreateError(detail: string): boolean { function isTransientSessionCreateError(detail: string): boolean {
const lowered = detail.toLowerCase(); const lowered = detail.toLowerCase();
if ( if (lowered.includes("timed out") || lowered.includes("timeout") || lowered.includes("504") || lowered.includes("gateway timeout")) {
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 // ACP timeout errors are expensive and usually deterministic for the same
// request; immediate retries spawn additional sessions/processes and make // request; immediate retries spawn additional sessions/processes and make
// recovery harder. // recovery harder.
@ -196,11 +185,7 @@ function isTransientSessionCreateError(detail: string): boolean {
} }
return ( return (
lowered.includes("502") || lowered.includes("502") || lowered.includes("503") || lowered.includes("bad gateway") || lowered.includes("econnreset") || lowered.includes("econnrefused")
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]; type SandboxInstanceQueueName = (typeof SANDBOX_INSTANCE_QUEUE_NAMES)[number];
function sandboxInstanceWorkflowQueueName( function sandboxInstanceWorkflowQueueName(name: SandboxInstanceQueueName): SandboxInstanceQueueName {
name: SandboxInstanceQueueName,
): SandboxInstanceQueueName {
return name; return name;
} }
@ -297,15 +280,15 @@ async function ensureSandboxMutation(c: any, command: EnsureSandboxCommand): Pro
id: SANDBOX_ROW_ID, id: SANDBOX_ROW_ID,
metadataJson, metadataJson,
status: command.status, status: command.status,
updatedAt: now updatedAt: now,
}) })
.onConflictDoUpdate({ .onConflictDoUpdate({
target: sandboxInstanceTable.id, target: sandboxInstanceTable.id,
set: { set: {
metadataJson, metadataJson,
status: command.status, status: command.status,
updatedAt: now updatedAt: now,
} },
}) })
.run(); .run();
} }
@ -315,17 +298,14 @@ async function updateHealthMutation(c: any, command: HealthSandboxCommand): Prom
.update(sandboxInstanceTable) .update(sandboxInstanceTable)
.set({ .set({
status: `${command.status}:${command.message}`, status: `${command.status}:${command.message}`,
updatedAt: Date.now() updatedAt: Date.now(),
}) })
.where(eq(sandboxInstanceTable.id, SANDBOX_ROW_ID)) .where(eq(sandboxInstanceTable.id, SANDBOX_ROW_ID))
.run(); .run();
} }
async function destroySandboxMutation(c: any): Promise<void> { async function destroySandboxMutation(c: any): Promise<void> {
await c.db await c.db.delete(sandboxInstanceTable).where(eq(sandboxInstanceTable.id, SANDBOX_ROW_ID)).run();
.delete(sandboxInstanceTable)
.where(eq(sandboxInstanceTable.id, SANDBOX_ROW_ID))
.run();
} }
async function createSessionMutation(c: any, command: CreateSessionCommand): Promise<CreateSessionResult> { async function createSessionMutation(c: any, command: CreateSessionCommand): Promise<CreateSessionResult> {
@ -362,7 +342,7 @@ async function createSessionMutation(c: any, command: CreateSessionCommand): Pro
attempt, attempt,
maxAttempts: CREATE_SESSION_MAX_ATTEMPTS, maxAttempts: CREATE_SESSION_MAX_ATTEMPTS,
waitMs, waitMs,
error: detail error: detail,
}); });
await delay(waitMs); await delay(waitMs);
} }
@ -372,7 +352,7 @@ async function createSessionMutation(c: any, command: CreateSessionCommand): Pro
return { return {
id: null, id: null,
status: "error", 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); return Loop.continue(undefined);
} }
if (msg.name === "sandboxInstance.command.ensure") { if (msg.name === "sandboxInstance.command.ensure") {
await loopCtx.step("sandbox-instance-ensure", async () => await loopCtx.step("sandbox-instance-ensure", async () => ensureSandboxMutation(loopCtx, msg.body as EnsureSandboxCommand));
ensureSandboxMutation(loopCtx, msg.body as EnsureSandboxCommand), await msg.complete({ ok: true });
); return Loop.continue(undefined);
await msg.complete({ ok: true }); }
return Loop.continue(undefined);
}
if (msg.name === "sandboxInstance.command.updateHealth") { if (msg.name === "sandboxInstance.command.updateHealth") {
await loopCtx.step("sandbox-instance-update-health", async () => await loopCtx.step("sandbox-instance-update-health", async () => updateHealthMutation(loopCtx, msg.body as HealthSandboxCommand));
updateHealthMutation(loopCtx, msg.body as HealthSandboxCommand), await msg.complete({ ok: true });
); return Loop.continue(undefined);
await msg.complete({ ok: true }); }
return Loop.continue(undefined);
}
if (msg.name === "sandboxInstance.command.destroy") { if (msg.name === "sandboxInstance.command.destroy") {
await loopCtx.step("sandbox-instance-destroy", async () => await loopCtx.step("sandbox-instance-destroy", async () => destroySandboxMutation(loopCtx));
destroySandboxMutation(loopCtx), await msg.complete({ ok: true });
); return Loop.continue(undefined);
await msg.complete({ ok: true }); }
return Loop.continue(undefined);
}
if (msg.name === "sandboxInstance.command.createSession") { if (msg.name === "sandboxInstance.command.createSession") {
const result = await loopCtx.step({ const result = await loopCtx.step({
name: "sandbox-instance-create-session", name: "sandbox-instance-create-session",
timeout: CREATE_SESSION_STEP_TIMEOUT_MS, timeout: CREATE_SESSION_STEP_TIMEOUT_MS,
run: async () => createSessionMutation(loopCtx, msg.body as CreateSessionCommand), run: async () => createSessionMutation(loopCtx, msg.body as CreateSessionCommand),
}); });
await msg.complete(result); await msg.complete(result);
return Loop.continue(undefined); return Loop.continue(undefined);
} }
if (msg.name === "sandboxInstance.command.sendPrompt") { if (msg.name === "sandboxInstance.command.sendPrompt") {
await loopCtx.step("sandbox-instance-send-prompt", async () => await loopCtx.step("sandbox-instance-send-prompt", async () => sendPromptMutation(loopCtx, msg.body as SendPromptCommand));
sendPromptMutation(loopCtx, msg.body as SendPromptCommand), await msg.complete({ ok: true });
); return Loop.continue(undefined);
await msg.complete({ ok: true }); }
return Loop.continue(undefined);
}
if (msg.name === "sandboxInstance.command.cancelSession") { if (msg.name === "sandboxInstance.command.cancelSession") {
await loopCtx.step("sandbox-instance-cancel-session", async () => await loopCtx.step("sandbox-instance-cancel-session", async () => cancelSessionMutation(loopCtx, msg.body as SessionControlCommand));
cancelSessionMutation(loopCtx, msg.body as SessionControlCommand), await msg.complete({ ok: true });
); return Loop.continue(undefined);
await msg.complete({ ok: true }); }
return Loop.continue(undefined);
}
if (msg.name === "sandboxInstance.command.destroySession") { if (msg.name === "sandboxInstance.command.destroySession") {
await loopCtx.step("sandbox-instance-destroy-session", async () => await loopCtx.step("sandbox-instance-destroy-session", async () => destroySessionMutation(loopCtx, msg.body as SessionControlCommand));
destroySessionMutation(loopCtx, msg.body as SessionControlCommand), await msg.complete({ ok: true });
); }
await msg.complete({ ok: true });
}
return Loop.continue(undefined); return Loop.continue(undefined);
}); });
@ -518,10 +486,14 @@ export const sandboxInstance = actor({
async destroy(c): Promise<void> { async destroy(c): Promise<void> {
const self = selfSandboxInstance(c); const self = selfSandboxInstance(c);
await self.send(sandboxInstanceWorkflowQueueName("sandboxInstance.command.destroy"), {}, { await self.send(
wait: true, sandboxInstanceWorkflowQueueName("sandboxInstance.command.destroy"),
timeout: 60_000, {},
}); {
wait: true,
timeout: 60_000,
},
);
}, },
async createSession(c: any, command: CreateSessionCommand): Promise<CreateSessionResult> { async createSession(c: any, command: CreateSessionCommand): Promise<CreateSessionResult> {
@ -534,10 +506,7 @@ export const sandboxInstance = actor({
); );
}, },
async listSessions( async listSessions(c: any, command?: ListSessionsCommand): Promise<{ items: SessionRecord[]; nextCursor?: string }> {
c: any,
command?: ListSessionsCommand
): Promise<{ items: SessionRecord[]; nextCursor?: string }> {
const persist = new SandboxInstancePersistDriver(c.db); const persist = new SandboxInstancePersistDriver(c.db);
try { try {
const client = await getSandboxAgentClient(c); const client = await getSandboxAgentClient(c);
@ -556,7 +525,7 @@ export const sandboxInstance = actor({
workspaceId: c.state.workspaceId, workspaceId: c.state.workspaceId,
providerId: c.state.providerId, providerId: c.state.providerId,
sandboxId: c.state.sandboxId, sandboxId: c.state.sandboxId,
error: resolveErrorMessage(error) error: resolveErrorMessage(error),
}); });
return await persist.listSessions({ return await persist.listSessions({
cursor: command?.cursor, cursor: command?.cursor,
@ -565,10 +534,7 @@ export const sandboxInstance = actor({
} }
}, },
async listSessionEvents( async listSessionEvents(c: any, command: ListSessionEventsCommand): Promise<{ items: SessionEvent[]; nextCursor?: string }> {
c: any,
command: ListSessionEventsCommand
): Promise<{ items: SessionEvent[]; nextCursor?: string }> {
const persist = new SandboxInstancePersistDriver(c.db); const persist = new SandboxInstancePersistDriver(c.db);
return await persist.listEvents({ return await persist.listEvents({
sessionId: command.sessionId, sessionId: command.sessionId,
@ -601,15 +567,9 @@ export const sandboxInstance = actor({
}); });
}, },
async sessionStatus( async sessionStatus(c, command: SessionStatusCommand): Promise<{ id: string; status: "running" | "idle" | "error" }> {
c, return await derivePersistedSessionStatus(new SandboxInstancePersistDriver(c.db), command.sessionId);
command: SessionStatusCommand },
): Promise<{ id: string; status: "running" | "idle" | "error" }> {
return await derivePersistedSessionStatus(
new SandboxInstancePersistDriver(c.db),
command.sessionId,
);
}
}, },
run: workflow(runSandboxInstanceWorkflow), run: workflow(runSandboxInstanceWorkflow),
}); });

View file

@ -1,12 +1,5 @@
import { and, asc, count, eq } from "drizzle-orm"; import { and, asc, count, eq } from "drizzle-orm";
import type { import type { ListEventsRequest, ListPage, ListPageRequest, SessionEvent, SessionPersistDriver, SessionRecord } from "sandbox-agent";
ListEventsRequest,
ListPage,
ListPageRequest,
SessionEvent,
SessionPersistDriver,
SessionRecord
} from "sandbox-agent";
import { sandboxSessionEvents, sandboxSessions } from "./db/schema.js"; import { sandboxSessionEvents, sandboxSessions } from "./db/schema.js";
const DEFAULT_MAX_SESSIONS = 1024; const DEFAULT_MAX_SESSIONS = 1024;
@ -27,11 +20,7 @@ function parseCursor(cursor: string | undefined): number {
return parsed; return parsed;
} }
export function resolveEventListOffset(params: { export function resolveEventListOffset(params: { cursor?: string; total: number; limit: number }): number {
cursor?: string;
total: number;
limit: number;
}): number {
if (params.cursor != null) { if (params.cursor != null) {
return parseCursor(params.cursor); return parseCursor(params.cursor);
} }
@ -65,13 +54,10 @@ export class SandboxInstancePersistDriver implements SessionPersistDriver {
constructor( constructor(
private readonly db: any, private readonly db: any,
options: SandboxInstancePersistDriverOptions = {} options: SandboxInstancePersistDriverOptions = {},
) { ) {
this.maxSessions = normalizeCap(options.maxSessions, DEFAULT_MAX_SESSIONS); this.maxSessions = normalizeCap(options.maxSessions, DEFAULT_MAX_SESSIONS);
this.maxEventsPerSession = normalizeCap( this.maxEventsPerSession = normalizeCap(options.maxEventsPerSession, DEFAULT_MAX_EVENTS_PER_SESSION);
options.maxEventsPerSession,
DEFAULT_MAX_EVENTS_PER_SESSION
);
} }
async getSession(id: string): Promise<SessionRecord | null> { async getSession(id: string): Promise<SessionRecord | null> {
@ -132,10 +118,7 @@ export class SandboxInstancePersistDriver implements SessionPersistDriver {
sessionInit: safeParseJson(row.sessionInitJson, undefined), sessionInit: safeParseJson(row.sessionInitJson, undefined),
})); }));
const totalRow = await this.db const totalRow = await this.db.select({ c: count() }).from(sandboxSessions).get();
.select({ c: count() })
.from(sandboxSessions)
.get();
const total = Number(totalRow?.c ?? 0); const total = Number(totalRow?.c ?? 0);
const nextOffset = offset + items.length; const nextOffset = offset + items.length;
@ -172,10 +155,7 @@ export class SandboxInstancePersistDriver implements SessionPersistDriver {
.run(); .run();
// Evict oldest sessions beyond cap. // Evict oldest sessions beyond cap.
const totalRow = await this.db const totalRow = await this.db.select({ c: count() }).from(sandboxSessions).get();
.select({ c: count() })
.from(sandboxSessions)
.get();
const total = Number(totalRow?.c ?? 0); const total = Number(totalRow?.c ?? 0);
const overflow = total - this.maxSessions; const overflow = total - this.maxSessions;
if (overflow <= 0) return; if (overflow <= 0) return;
@ -195,11 +175,7 @@ export class SandboxInstancePersistDriver implements SessionPersistDriver {
async listEvents(request: ListEventsRequest): Promise<ListPage<SessionEvent>> { async listEvents(request: ListEventsRequest): Promise<ListPage<SessionEvent>> {
const limit = normalizeCap(request.limit, DEFAULT_LIST_LIMIT); const limit = normalizeCap(request.limit, DEFAULT_LIST_LIMIT);
const totalRow = await this.db const totalRow = await this.db.select({ c: count() }).from(sandboxSessionEvents).where(eq(sandboxSessionEvents.sessionId, request.sessionId)).get();
.select({ c: count() })
.from(sandboxSessionEvents)
.where(eq(sandboxSessionEvents.sessionId, request.sessionId))
.get();
const total = Number(totalRow?.c ?? 0); const total = Number(totalRow?.c ?? 0);
const offset = resolveEventListOffset({ const offset = resolveEventListOffset({
cursor: request.cursor, cursor: request.cursor,
@ -267,11 +243,7 @@ export class SandboxInstancePersistDriver implements SessionPersistDriver {
.run(); .run();
// Trim oldest events beyond cap. // Trim oldest events beyond cap.
const totalRow = await this.db const totalRow = await this.db.select({ c: count() }).from(sandboxSessionEvents).where(eq(sandboxSessionEvents.sessionId, event.sessionId)).get();
.select({ c: count() })
.from(sandboxSessionEvents)
.where(eq(sandboxSessionEvents.sessionId, event.sessionId))
.get();
const total = Number(totalRow?.c ?? 0); const total = Number(totalRow?.c ?? 0);
const overflow = total - this.maxEventsPerSession; const overflow = total - this.maxEventsPerSession;
if (overflow <= 0) return; if (overflow <= 0) return;

View file

@ -26,7 +26,7 @@ import type {
RepoStackActionResult, RepoStackActionResult,
RepoRecord, RepoRecord,
SwitchResult, SwitchResult,
WorkspaceUseInput WorkspaceUseInput,
} from "@openhandoff/shared"; } from "@openhandoff/shared";
import { getActorRuntimeContext } from "../context.js"; import { getActorRuntimeContext } from "../context.js";
import { getHandoff, getOrCreateHistory, getOrCreateProject, selfWorkspace } from "../handles.js"; import { getHandoff, getOrCreateHistory, getOrCreateProject, selfWorkspace } from "../handles.js";
@ -58,11 +58,7 @@ interface RepoOverviewInput {
repoId: string; repoId: string;
} }
const WORKSPACE_QUEUE_NAMES = [ const WORKSPACE_QUEUE_NAMES = ["workspace.command.addRepo", "workspace.command.createHandoff", "workspace.command.refreshProviderProfiles"] as const;
"workspace.command.addRepo",
"workspace.command.createHandoff",
"workspace.command.refreshProviderProfiles",
] as const;
type WorkspaceQueueName = (typeof WORKSPACE_QUEUE_NAMES)[number]; 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> { async function resolveRepoId(c: any, handoffId: string): Promise<string> {
const row = await c.db const row = await c.db.select({ repoId: handoffLookup.repoId }).from(handoffLookup).where(eq(handoffLookup.handoffId, handoffId)).get();
.select({ repoId: handoffLookup.repoId })
.from(handoffLookup)
.where(eq(handoffLookup.handoffId, handoffId))
.get();
if (!row) { if (!row) {
throw new Error(`Unknown handoff: ${handoffId} (not in lookup)`); 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[]> { async function collectAllHandoffSummaries(c: any): Promise<HandoffSummary[]> {
const repoRows = await c.db const repoRows = await c.db.select({ repoId: repos.repoId, remoteUrl: repos.remoteUrl }).from(repos).orderBy(desc(repos.updatedAt)).all();
.select({ repoId: repos.repoId, remoteUrl: repos.remoteUrl })
.from(repos)
.orderBy(desc(repos.updatedAt))
.all();
const all: HandoffSummary[] = []; const all: HandoffSummary[] = [];
for (const row of repoRows) { for (const row of repoRows) {
@ -123,7 +111,7 @@ async function collectAllHandoffSummaries(c: any): Promise<HandoffSummary[]> {
logActorWarning("workspace", "failed collecting handoffs for repo", { logActorWarning("workspace", "failed collecting handoffs for repo", {
workspaceId: c.state.workspaceId, workspaceId: c.state.workspaceId,
repoId: row.repoId, repoId: row.repoId,
error: resolveErrorMessage(error) error: resolveErrorMessage(error),
}); });
} }
} }
@ -172,7 +160,7 @@ async function buildWorkbenchSnapshot(c: any): Promise<HandoffWorkbenchSnapshot>
workspaceId: c.state.workspaceId, workspaceId: c.state.workspaceId,
repoId: row.repoId, repoId: row.repoId,
handoffId: summary.handoffId, 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", { logActorWarning("workspace", "failed collecting workbench repo snapshot", {
workspaceId: c.state.workspaceId, workspaceId: c.state.workspaceId,
repoId: row.repoId, repoId: row.repoId,
error: resolveErrorMessage(error) error: resolveErrorMessage(error),
}); });
} }
} }
@ -200,7 +188,7 @@ async function buildWorkbenchSnapshot(c: any): Promise<HandoffWorkbenchSnapshot>
workspaceId: c.state.workspaceId, workspaceId: c.state.workspaceId,
repos: repoRows.map((row) => ({ repos: repoRows.map((row) => ({
id: row.repoId, id: row.repoId,
label: repoLabelFromRemote(row.remoteUrl) label: repoLabelFromRemote(row.remoteUrl),
})), })),
projects, projects,
handoffs, handoffs,
@ -232,14 +220,14 @@ async function addRepoMutation(c: any, input: AddRepoInput): Promise<RepoRecord>
repoId, repoId,
remoteUrl, remoteUrl,
createdAt: now, createdAt: now,
updatedAt: now updatedAt: now,
}) })
.onConflictDoUpdate({ .onConflictDoUpdate({
target: repos.repoId, target: repos.repoId,
set: { set: {
remoteUrl, remoteUrl,
updatedAt: now updatedAt: now,
} },
}) })
.run(); .run();
@ -249,7 +237,7 @@ async function addRepoMutation(c: any, input: AddRepoInput): Promise<RepoRecord>
repoId, repoId,
remoteUrl, remoteUrl,
createdAt: now, 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 providerId = input.providerId ?? providers.defaultProviderId();
const repoId = input.repoId; const repoId = input.repoId;
const repoRow = await c.db const repoRow = await c.db.select({ remoteUrl: repos.remoteUrl }).from(repos).where(eq(repos.repoId, repoId)).get();
.select({ remoteUrl: repos.remoteUrl })
.from(repos)
.where(eq(repos.repoId, repoId))
.get();
if (!repoRow) { if (!repoRow) {
throw new Error(`Unknown repo: ${repoId}`); throw new Error(`Unknown repo: ${repoId}`);
} }
@ -275,14 +259,14 @@ async function createHandoffMutation(c: any, input: CreateHandoffInput): Promise
.values({ .values({
providerId, providerId,
profileJson: JSON.stringify({ providerId }), profileJson: JSON.stringify({ providerId }),
updatedAt: Date.now() updatedAt: Date.now(),
}) })
.onConflictDoUpdate({ .onConflictDoUpdate({
target: providerProfiles.providerId, target: providerProfiles.providerId,
set: { set: {
profileJson: JSON.stringify({ providerId }), profileJson: JSON.stringify({ providerId }),
updatedAt: Date.now() updatedAt: Date.now(),
} },
}) })
.run(); .run();
@ -295,18 +279,18 @@ async function createHandoffMutation(c: any, input: CreateHandoffInput): Promise
agentType: input.agentType ?? null, agentType: input.agentType ?? null,
explicitTitle: input.explicitTitle ?? null, explicitTitle: input.explicitTitle ?? null,
explicitBranchName: input.explicitBranchName ?? null, explicitBranchName: input.explicitBranchName ?? null,
onBranch: input.onBranch ?? null onBranch: input.onBranch ?? null,
}); });
await c.db await c.db
.insert(handoffLookup) .insert(handoffLookup)
.values({ .values({
handoffId: created.handoffId, handoffId: created.handoffId,
repoId repoId,
}) })
.onConflictDoUpdate({ .onConflictDoUpdate({
target: handoffLookup.handoffId, target: handoffLookup.handoffId,
set: { repoId } set: { repoId },
}) })
.run(); .run();
@ -328,14 +312,14 @@ async function refreshProviderProfilesMutation(c: any, command?: RefreshProvider
.values({ .values({
providerId, providerId,
profileJson: JSON.stringify({ providerId }), profileJson: JSON.stringify({ providerId }),
updatedAt: Date.now() updatedAt: Date.now(),
}) })
.onConflictDoUpdate({ .onConflictDoUpdate({
target: providerProfiles.providerId, target: providerProfiles.providerId,
set: { set: {
profileJson: JSON.stringify({ providerId }), profileJson: JSON.stringify({ providerId }),
updatedAt: Date.now() updatedAt: Date.now(),
} },
}) })
.run(); .run();
} }
@ -406,7 +390,7 @@ export const workspaceActions = {
repoId: repos.repoId, repoId: repos.repoId,
remoteUrl: repos.remoteUrl, remoteUrl: repos.remoteUrl,
createdAt: repos.createdAt, createdAt: repos.createdAt,
updatedAt: repos.updatedAt updatedAt: repos.updatedAt,
}) })
.from(repos) .from(repos)
.orderBy(desc(repos.updatedAt)) .orderBy(desc(repos.updatedAt))
@ -417,7 +401,7 @@ export const workspaceActions = {
repoId: row.repoId, repoId: row.repoId,
remoteUrl: row.remoteUrl, remoteUrl: row.remoteUrl,
createdAt: row.createdAt, createdAt: row.createdAt,
updatedAt: row.updatedAt updatedAt: row.updatedAt,
})); }));
}, },
@ -447,7 +431,7 @@ export const workspaceActions = {
task: input.task, task: input.task,
...(input.title ? { explicitTitle: input.title } : {}), ...(input.title ? { explicitTitle: input.title } : {}),
...(input.branch ? { explicitBranchName: input.branch } : {}), ...(input.branch ? { explicitBranchName: input.branch } : {}),
...(input.model ? { agentType: agentTypeForModel(input.model) } : {}) ...(input.model ? { agentType: agentTypeForModel(input.model) } : {}),
}); });
return { handoffId: created.handoffId }; return { handoffId: created.handoffId };
}, },
@ -521,11 +505,7 @@ export const workspaceActions = {
assertWorkspace(c, input.workspaceId); assertWorkspace(c, input.workspaceId);
if (input.repoId) { if (input.repoId) {
const repoRow = await c.db const repoRow = await c.db.select({ remoteUrl: repos.remoteUrl }).from(repos).where(eq(repos.repoId, input.repoId)).get();
.select({ remoteUrl: repos.remoteUrl })
.from(repos)
.where(eq(repos.repoId, input.repoId))
.get();
if (!repoRow) { if (!repoRow) {
throw new Error(`Unknown repo: ${input.repoId}`); throw new Error(`Unknown repo: ${input.repoId}`);
} }
@ -540,11 +520,7 @@ export const workspaceActions = {
async getRepoOverview(c: any, input: RepoOverviewInput): Promise<RepoOverview> { async getRepoOverview(c: any, input: RepoOverviewInput): Promise<RepoOverview> {
assertWorkspace(c, input.workspaceId); assertWorkspace(c, input.workspaceId);
const repoRow = await c.db const repoRow = await c.db.select({ remoteUrl: repos.remoteUrl }).from(repos).where(eq(repos.repoId, input.repoId)).get();
.select({ remoteUrl: repos.remoteUrl })
.from(repos)
.where(eq(repos.repoId, input.repoId))
.get();
if (!repoRow) { if (!repoRow) {
throw new Error(`Unknown repo: ${input.repoId}`); throw new Error(`Unknown repo: ${input.repoId}`);
} }
@ -557,11 +533,7 @@ export const workspaceActions = {
async runRepoStackAction(c: any, input: RepoStackActionInput): Promise<RepoStackActionResult> { async runRepoStackAction(c: any, input: RepoStackActionInput): Promise<RepoStackActionResult> {
assertWorkspace(c, input.workspaceId); assertWorkspace(c, input.workspaceId);
const repoRow = await c.db const repoRow = await c.db.select({ remoteUrl: repos.remoteUrl }).from(repos).where(eq(repos.repoId, input.repoId)).get();
.select({ remoteUrl: repos.remoteUrl })
.from(repos)
.where(eq(repos.repoId, input.repoId))
.get();
if (!repoRow) { if (!repoRow) {
throw new Error(`Unknown repo: ${input.repoId}`); throw new Error(`Unknown repo: ${input.repoId}`);
} }
@ -571,7 +543,7 @@ export const workspaceActions = {
return await project.runRepoStackAction({ return await project.runRepoStackAction({
action: input.action, action: input.action,
branchName: input.branchName, branchName: input.branchName,
parentBranch: input.parentBranch parentBranch: input.parentBranch,
}); });
}, },
@ -585,7 +557,7 @@ export const workspaceActions = {
workspaceId: c.state.workspaceId, workspaceId: c.state.workspaceId,
handoffId, handoffId,
providerId: record.providerId, providerId: record.providerId,
switchTarget: switched.switchTarget switchTarget: switched.switchTarget,
}; };
}, },
@ -611,14 +583,14 @@ export const workspaceActions = {
const items = await hist.list({ const items = await hist.list({
branch: input.branch, branch: input.branch,
handoffId: input.handoffId, handoffId: input.handoffId,
limit limit,
}); });
allEvents.push(...items); allEvents.push(...items);
} catch (error) { } catch (error) {
logActorWarning("workspace", "history lookup failed for repo", { logActorWarning("workspace", "history lookup failed for repo", {
workspaceId: c.state.workspaceId, workspaceId: c.state.workspaceId,
repoId: row.repoId, repoId: row.repoId,
error: resolveErrorMessage(error) error: resolveErrorMessage(error),
}); });
} }
} }
@ -632,11 +604,7 @@ export const workspaceActions = {
const repoId = await resolveRepoId(c, input.handoffId); const repoId = await resolveRepoId(c, input.handoffId);
const repoRow = await c.db const repoRow = await c.db.select({ remoteUrl: repos.remoteUrl }).from(repos).where(eq(repos.repoId, repoId)).get();
.select({ remoteUrl: repos.remoteUrl })
.from(repos)
.where(eq(repos.repoId, repoId))
.get();
if (!repoRow) { if (!repoRow) {
throw new Error(`Unknown repo: ${repoId}`); throw new Error(`Unknown repo: ${repoId}`);
} }
@ -685,5 +653,5 @@ export const workspaceActions = {
const repoId = await resolveRepoId(c, input.handoffId); const repoId = await resolveRepoId(c, input.handoffId);
const h = getHandoff(c, c.state.workspaceId, repoId, input.handoffId); const h = getHandoff(c, c.state.workspaceId, repoId, input.handoffId);
await h.kill({ reason: input.reason }); await h.kill({ reason: input.reason });
} },
}; };

View file

@ -4,4 +4,3 @@ export default defineConfig({
out: "./src/actors/workspace/db/drizzle", out: "./src/actors/workspace/db/drizzle",
schema: "./src/actors/workspace/db/schema.ts", schema: "./src/actors/workspace/db/schema.ts",
}); });

View file

@ -46,4 +46,4 @@
"internal": { "internal": {
"indexes": {} "indexes": {}
} }
} }

View file

@ -84,4 +84,4 @@
"internal": { "internal": {
"indexes": {} "indexes": {}
} }
} }

View file

@ -3,26 +3,26 @@
// Do not hand-edit this file. // Do not hand-edit this file.
const journal = { const journal = {
"entries": [ entries: [
{ {
"idx": 0, idx: 0,
"when": 1770924376525, when: 1770924376525,
"tag": "0000_rare_iron_man", tag: "0000_rare_iron_man",
"breakpoints": true breakpoints: true,
}, },
{ {
"idx": 1, idx: 1,
"when": 1770947252912, when: 1770947252912,
"tag": "0001_sleepy_lady_deathstrike", tag: "0001_sleepy_lady_deathstrike",
"breakpoints": true breakpoints: true,
}, },
{ {
"idx": 2, idx: 2,
"when": 1772668800000, when: 1772668800000,
"tag": "0002_tiny_silver_surfer", tag: "0002_tiny_silver_surfer",
"breakpoints": true breakpoints: true,
} },
] ],
} as const; } as const;
export default { export default {
@ -46,5 +46,5 @@ export default {
\`repo_id\` text NOT NULL \`repo_id\` text NOT NULL
); );
`, `,
} as const } as const,
}; };

View file

@ -10,7 +10,7 @@ export const workspace = actor({
actionTimeout: 5 * 60_000, actionTimeout: 5 * 60_000,
}, },
createState: (_c, workspaceId: string) => ({ createState: (_c, workspaceId: string) => ({
workspaceId workspaceId,
}), }),
actions: workspaceActions, actions: workspaceActions,
run: workflow(runWorkspaceWorkflow), run: workflow(runWorkspaceWorkflow),

View file

@ -34,11 +34,8 @@ export interface ActorSqliteDbOptions<TSchema extends Record<string, unknown>> {
baseDir?: string; baseDir?: string;
} }
export function actorSqliteDb<TSchema extends Record<string, unknown>>( export function actorSqliteDb<TSchema extends Record<string, unknown>>(options: ActorSqliteDbOptions<TSchema>): DatabaseProvider<any & RawAccess> {
options: ActorSqliteDbOptions<TSchema> const isBunRuntime = typeof (globalThis as any).Bun !== "undefined" && typeof (process as any)?.versions?.bun === "string";
): 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 // Backend tests run in a Node-ish Vitest environment where `bun:sqlite` and
// Bun's sqlite-backed Drizzle driver are not supported. // Bun's sqlite-backed Drizzle driver are not supported.

View file

@ -1,17 +1,8 @@
import type { BranchSnapshot } from "./integrations/git/index.js"; import type { BranchSnapshot } from "./integrations/git/index.js";
import type { PullRequestSnapshot } from "./integrations/github/index.js"; import type { PullRequestSnapshot } from "./integrations/github/index.js";
import type { import type { SandboxSession, SandboxAgentClientOptions, SandboxSessionCreateRequest } from "./integrations/sandbox-agent/client.js";
SandboxSession,
SandboxAgentClientOptions,
SandboxSessionCreateRequest
} from "./integrations/sandbox-agent/client.js";
import type { ListEventsRequest, ListPage, ListPageRequest, SessionEvent, SessionRecord } from "sandbox-agent"; import type { ListEventsRequest, ListPage, ListPageRequest, SessionEvent, SessionRecord } from "sandbox-agent";
import type { import type { DaytonaClientOptions, DaytonaCreateSandboxOptions, DaytonaPreviewEndpoint, DaytonaSandbox } from "./integrations/daytona/client.js";
DaytonaClientOptions,
DaytonaCreateSandboxOptions,
DaytonaPreviewEndpoint,
DaytonaSandbox,
} from "./integrations/daytona/client.js";
import { import {
validateRemote, validateRemote,
ensureCloned, ensureCloned,
@ -67,12 +58,7 @@ export interface StackDriver {
export interface GithubDriver { export interface GithubDriver {
listPullRequests(repoPath: string): Promise<PullRequestSnapshot[]>; listPullRequests(repoPath: string): Promise<PullRequestSnapshot[]>;
createPr( createPr(repoPath: string, headBranch: string, title: string, body?: string): Promise<{ number: number; url: string }>;
repoPath: string,
headBranch: string,
title: string,
body?: string
): Promise<{ number: number; url: string }>;
} }
export interface SandboxAgentClientLike { export interface SandboxAgentClientLike {

View file

@ -33,10 +33,8 @@ export async function startBackend(options: BackendStartOptions = {}): Promise<v
return undefined; return undefined;
}; };
config.providers.daytona.endpoint = config.providers.daytona.endpoint = envFirst("HF_DAYTONA_ENDPOINT", "DAYTONA_ENDPOINT") ?? 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.apiKey =
envFirst("HF_DAYTONA_API_KEY", "DAYTONA_API_KEY") ?? config.providers.daytona.apiKey;
const driver = createDefaultDriver(); const driver = createDefaultDriver();
const providers = createProviderRegistry(config, driver); const providers = createProviderRegistry(config, driver);
@ -58,7 +56,7 @@ export async function startBackend(options: BackendStartOptions = {}): Promise<v
allowHeaders: ["Content-Type", "Authorization", "x-rivet-token"], allowHeaders: ["Content-Type", "Authorization", "x-rivet-token"],
allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
exposeHeaders: ["Content-Type"], exposeHeaders: ["Content-Type"],
}) }),
); );
app.use( app.use(
"/api/rivet", "/api/rivet",
@ -67,7 +65,7 @@ export async function startBackend(options: BackendStartOptions = {}): Promise<v
allowHeaders: ["Content-Type", "Authorization", "x-rivet-token"], allowHeaders: ["Content-Type", "Authorization", "x-rivet-token"],
allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
exposeHeaders: ["Content-Type"], exposeHeaders: ["Content-Type"],
}) }),
); );
const forward = async (c: any) => { const forward = async (c: any) => {
try { try {
@ -86,7 +84,7 @@ export async function startBackend(options: BackendStartOptions = {}): Promise<v
const server = Bun.serve({ const server = Bun.serve({
fetch: app.fetch, fetch: app.fetch,
hostname: config.backend.host, hostname: config.backend.host,
port: config.backend.port port: config.backend.port,
}); });
process.on("SIGINT", async () => { 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; const port = parseArg("--port") ?? process.env.PORT ?? process.env.HF_BACKEND_PORT;
await startBackend({ await startBackend({
host, host,
port: parseEnvPort(port) port: parseEnvPort(port),
}); });
} }
if (import.meta.url === `file://${process.argv[1]}`) { if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err: unknown) => { 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); console.error(message);
process.exit(1); process.exit(1);
}); });

View file

@ -52,9 +52,7 @@ export class DaytonaClient {
image: options.image, image: options.image,
envVars: options.envVars, envVars: options.envVars,
labels: options.labels, labels: options.labels,
...(options.autoStopInterval !== undefined ...(options.autoStopInterval !== undefined ? { autoStopInterval: options.autoStopInterval } : {}),
? { autoStopInterval: options.autoStopInterval }
: {}),
}); });
return { return {

View file

@ -32,18 +32,10 @@ function commandLabel(cmd: SpiceCommand): string {
function looksMissing(error: unknown): boolean { function looksMissing(error: unknown): boolean {
const detail = error instanceof Error ? error.message : String(error); const detail = error instanceof Error ? error.message : String(error);
return ( return detail.includes("ENOENT") || detail.includes("not a git command") || detail.includes("command not found");
detail.includes("ENOENT") ||
detail.includes("not a git command") ||
detail.includes("command not found")
);
} }
async function tryRun( async function tryRun(repoPath: string, cmd: SpiceCommand, args: string[]): Promise<{ stdout: string; stderr: string }> {
repoPath: string,
cmd: SpiceCommand,
args: string[]
): Promise<{ stdout: string; stderr: string }> {
return await execFileAsync(cmd.command, [...cmd.prefix, ...args], { return await execFileAsync(cmd.command, [...cmd.prefix, ...args], {
cwd: repoPath, cwd: repoPath,
timeout: DEFAULT_TIMEOUT_MS, timeout: DEFAULT_TIMEOUT_MS,
@ -51,8 +43,8 @@ async function tryRun(
env: { env: {
...process.env, ...process.env,
NO_COLOR: "1", 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[]> { export async function gitSpiceListStack(repoPath: string): Promise<SpiceStackEntry[]> {
try { try {
const { stdout } = await runSpice(repoPath, [ const { stdout } = await runSpice(repoPath, ["log", "short", "--all", "--json", "--no-cr-status", "--no-prompt"]);
"log",
"short",
"--all",
"--json",
"--no-cr-status",
"--no-prompt"
]);
return parseLogJson(stdout); return parseLogJson(stdout);
} catch { } catch {
return []; return [];
@ -160,9 +145,9 @@ export async function gitSpiceSyncRepo(repoPath: string): Promise<void> {
[ [
["repo", "sync", "--restack", "--no-prompt"], ["repo", "sync", "--restack", "--no-prompt"],
["repo", "sync", "--restack"], ["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, repoPath,
[ [
["repo", "restack", "--no-prompt"], ["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, "--no-prompt"],
["upstack", "restack", "--branch", branchName], ["upstack", "restack", "--branch", branchName],
["branch", "restack", "--branch", branchName, "--no-prompt"], ["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, repoPath,
[ [
["branch", "restack", "--branch", branchName, "--no-prompt"], ["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( export async function gitSpiceReparentBranch(repoPath: string, branchName: string, parentBranch: string): Promise<void> {
repoPath: string,
branchName: string,
parentBranch: string
): Promise<void> {
await runFallbacks( await runFallbacks(
repoPath, repoPath,
[ [
["upstack", "onto", "--branch", branchName, parentBranch, "--no-prompt"], ["upstack", "onto", "--branch", branchName, parentBranch, "--no-prompt"],
["upstack", "onto", "--branch", branchName, parentBranch], ["upstack", "onto", "--branch", branchName, parentBranch],
["branch", "onto", "--branch", branchName, parentBranch, "--no-prompt"], ["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( export async function gitSpiceTrackBranch(repoPath: string, branchName: string, parentBranch: string): Promise<void> {
repoPath: string,
branchName: string,
parentBranch: string
): Promise<void> {
await runFallbacks( await runFallbacks(
repoPath, repoPath,
[ [
["branch", "track", branchName, "--base", parentBranch, "--no-prompt"], ["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}`,
); );
} }

View file

@ -11,12 +11,7 @@ const DEFAULT_GIT_FETCH_TIMEOUT_MS = 2 * 60_000;
const DEFAULT_GIT_CLONE_TIMEOUT_MS = 5 * 60_000; const DEFAULT_GIT_CLONE_TIMEOUT_MS = 5 * 60_000;
function resolveGithubToken(): string | null { function resolveGithubToken(): string | null {
const token = const token = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN ?? process.env.HF_GITHUB_TOKEN ?? process.env.HF_GH_TOKEN ?? null;
process.env.GH_TOKEN ??
process.env.GITHUB_TOKEN ??
process.env.HF_GITHUB_TOKEN ??
process.env.HF_GH_TOKEN ??
null;
if (!token) return null; if (!token) return null;
const trimmed = token.trim(); const trimmed = token.trim();
return trimmed.length > 0 ? trimmed : null; 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. // 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. // We avoid embedding the token in this file; it is read from env at runtime.
const content = const content = [
[ "#!/bin/sh",
"#!/bin/sh", 'prompt="$1"',
'prompt="$1"', // Prefer GH_TOKEN/GITHUB_TOKEN but support HF_* aliases too.
// Prefer GH_TOKEN/GITHUB_TOKEN but support HF_* aliases too. 'token="${GH_TOKEN:-${GITHUB_TOKEN:-${HF_GITHUB_TOKEN:-${HF_GH_TOKEN:-}}}}"',
'token="${GH_TOKEN:-${GITHUB_TOKEN:-${HF_GITHUB_TOKEN:-${HF_GH_TOKEN:-}}}}"', 'case "$prompt" in',
'case "$prompt" in', ' *Username*) echo "x-access-token" ;;',
' *Username*) echo "x-access-token" ;;', ' *Password*) echo "$token" ;;',
' *Password*) echo "$token" ;;', ' *) echo "" ;;',
' *) echo "" ;;', "esac",
"esac", "",
"", ].join("\n");
].join("\n");
writeFileSync(path, content, "utf8"); writeFileSync(path, content, "utf8");
chmodSync(path, 0o700); chmodSync(path, 0o700);
@ -141,12 +135,7 @@ export async function ensureCloned(remoteUrl: string, targetPath: string): Promi
export async function remoteDefaultBaseRef(repoPath: string): Promise<string> { export async function remoteDefaultBaseRef(repoPath: string): Promise<string> {
try { try {
const { stdout } = await execFileAsync("git", [ const { stdout } = await execFileAsync("git", ["-C", repoPath, "symbolic-ref", "refs/remotes/origin/HEAD"], { env: gitEnv() });
"-C",
repoPath,
"symbolic-ref",
"refs/remotes/origin/HEAD",
], { env: gitEnv() });
const ref = stdout.trim(); // refs/remotes/origin/main const ref = stdout.trim(); // refs/remotes/origin/main
const match = ref.match(/^refs\/remotes\/(.+)$/); const match = ref.match(/^refs\/remotes\/(.+)$/);
if (match?.[1]) { if (match?.[1]) {
@ -169,17 +158,10 @@ export async function remoteDefaultBaseRef(repoPath: string): Promise<string> {
} }
export async function listRemoteBranches(repoPath: string): Promise<BranchSnapshot[]> { export async function listRemoteBranches(repoPath: string): Promise<BranchSnapshot[]> {
const { stdout } = await execFileAsync( const { stdout } = await execFileAsync("git", ["-C", repoPath, "for-each-ref", "--format=%(refname:short) %(objectname)", "refs/remotes/origin"], {
"git", maxBuffer: 1024 * 1024,
[ env: gitEnv(),
"-C", });
repoPath,
"for-each-ref",
"--format=%(refname:short) %(objectname)",
"refs/remotes/origin",
],
{ maxBuffer: 1024 * 1024, env: gitEnv() }
);
return stdout return stdout
.trim() .trim()
@ -191,24 +173,12 @@ export async function listRemoteBranches(repoPath: string): Promise<BranchSnapsh
const branchName = short.replace(/^origin\//, ""); const branchName = short.replace(/^origin\//, "");
return { branchName, commitSha: commitSha ?? "" }; return { branchName, commitSha: commitSha ?? "" };
}) })
.filter( .filter((row) => row.branchName.length > 0 && row.branchName !== "HEAD" && row.branchName !== "origin" && row.commitSha.length > 0);
(row) =>
row.branchName.length > 0 &&
row.branchName !== "HEAD" &&
row.branchName !== "origin" &&
row.commitSha.length > 0,
);
} }
async function remoteBranchExists(repoPath: string, branchName: string): Promise<boolean> { async function remoteBranchExists(repoPath: string, branchName: string): Promise<boolean> {
try { try {
await execFileAsync("git", [ await execFileAsync("git", ["-C", repoPath, "show-ref", "--verify", `refs/remotes/origin/${branchName}`], { env: gitEnv() });
"-C",
repoPath,
"show-ref",
"--verify",
`refs/remotes/origin/${branchName}`,
], { env: gitEnv() });
return true; return true;
} catch { } catch {
return false; return false;
@ -233,11 +203,10 @@ export async function diffStatForBranch(repoPath: string, branchName: string): P
try { try {
const baseRef = await remoteDefaultBaseRef(repoPath); const baseRef = await remoteDefaultBaseRef(repoPath);
const headRef = `origin/${branchName}`; const headRef = `origin/${branchName}`;
const { stdout } = await execFileAsync( const { stdout } = await execFileAsync("git", ["-C", repoPath, "diff", "--shortstat", `${baseRef}...${headRef}`], {
"git", maxBuffer: 1024 * 1024,
["-C", repoPath, "diff", "--shortstat", `${baseRef}...${headRef}`], env: gitEnv(),
{ maxBuffer: 1024 * 1024, env: gitEnv() } });
);
const trimmed = stdout.trim(); const trimmed = stdout.trim();
if (!trimmed) { if (!trimmed) {
return "+0/-0"; return "+0/-0";
@ -252,20 +221,13 @@ export async function diffStatForBranch(repoPath: string, branchName: string): P
} }
} }
export async function conflictsWithMain( export async function conflictsWithMain(repoPath: string, branchName: string): Promise<boolean> {
repoPath: string,
branchName: string
): Promise<boolean> {
try { try {
const baseRef = await remoteDefaultBaseRef(repoPath); const baseRef = await remoteDefaultBaseRef(repoPath);
const headRef = `origin/${branchName}`; const headRef = `origin/${branchName}`;
// Use merge-tree (git 2.38+) for a clean conflict check. // Use merge-tree (git 2.38+) for a clean conflict check.
try { try {
await execFileAsync( await execFileAsync("git", ["-C", repoPath, "merge-tree", "--write-tree", "--no-messages", baseRef, headRef], { env: gitEnv() });
"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. // If merge-tree exits 0, no conflicts. Non-zero exit means conflicts.
return false; return false;
} catch { } catch {
@ -279,11 +241,7 @@ export async function conflictsWithMain(
export async function getOriginOwner(repoPath: string): Promise<string> { export async function getOriginOwner(repoPath: string): Promise<string> {
try { try {
const { stdout } = await execFileAsync( const { stdout } = await execFileAsync("git", ["-C", repoPath, "remote", "get-url", "origin"], { env: gitEnv() });
"git",
["-C", repoPath, "remote", "get-url", "origin"],
{ env: gitEnv() }
);
const url = stdout.trim(); const url = stdout.trim();
// Handle SSH: git@github.com:owner/repo.git // Handle SSH: git@github.com:owner/repo.git
const sshMatch = url.match(/[:\/]([^\/]+)\/[^\/]+(?:\.git)?$/); const sshMatch = url.match(/[:\/]([^\/]+)\/[^\/]+(?:\.git)?$/);

View file

@ -36,9 +36,7 @@ interface GhPrListItem {
}>; }>;
} }
function parseCiStatus( function parseCiStatus(checks: GhPrListItem["statusCheckRollup"]): string | null {
checks: GhPrListItem["statusCheckRollup"]
): string | null {
if (!checks || checks.length === 0) return null; if (!checks || checks.length === 0) return null;
let total = 0; let total = 0;
@ -53,12 +51,7 @@ function parseCiStatus(
if (conclusion === "SUCCESS" || state === "SUCCESS") { if (conclusion === "SUCCESS" || state === "SUCCESS") {
successes++; successes++;
} else if ( } else if (status === "IN_PROGRESS" || status === "QUEUED" || status === "PENDING" || state === "PENDING") {
status === "IN_PROGRESS" ||
status === "QUEUED" ||
status === "PENDING" ||
state === "PENDING"
) {
hasRunning = true; hasRunning = true;
} }
} }
@ -70,9 +63,7 @@ function parseCiStatus(
return `${successes}/${total}`; return `${successes}/${total}`;
} }
function parseReviewStatus( function parseReviewStatus(reviews: GhPrListItem["reviews"]): { status: string | null; reviewer: string | null } {
reviews: GhPrListItem["reviews"]
): { status: string | null; reviewer: string | null } {
if (!reviews || reviews.length === 0) { if (!reviews || reviews.length === 0) {
return { status: null, reviewer: null }; return { status: null, reviewer: null };
} }
@ -120,35 +111,21 @@ function snapshotFromGhItem(item: GhPrListItem): PullRequestSnapshot {
isDraft: item.isDraft ?? false, isDraft: item.isDraft ?? false,
ciStatus: parseCiStatus(item.statusCheckRollup), ciStatus: parseCiStatus(item.statusCheckRollup),
reviewStatus, reviewStatus,
reviewer reviewer,
}; };
} }
const PR_JSON_FIELDS = const PR_JSON_FIELDS = "number,headRefName,state,title,url,author,isDraft,statusCheckRollup,reviews";
"number,headRefName,state,title,url,author,isDraft,statusCheckRollup,reviews";
export async function listPullRequests(repoPath: string): Promise<PullRequestSnapshot[]> { export async function listPullRequests(repoPath: string): Promise<PullRequestSnapshot[]> {
try { try {
const { stdout } = await execFileAsync( const { stdout } = await execFileAsync("gh", ["pr", "list", "--json", PR_JSON_FIELDS, "--limit", "200"], { maxBuffer: 1024 * 1024 * 4, cwd: repoPath });
"gh",
[
"pr",
"list",
"--json",
PR_JSON_FIELDS,
"--limit",
"200"
],
{ maxBuffer: 1024 * 1024 * 4, cwd: repoPath }
);
const parsed = JSON.parse(stdout) as GhPrListItem[]; const parsed = JSON.parse(stdout) as GhPrListItem[];
return parsed.map((item) => { return parsed.map((item) => {
// Handle fork PRs where headRefName may contain "owner:branch" // Handle fork PRs where headRefName may contain "owner:branch"
const headRefName = item.headRefName.includes(":") const headRefName = item.headRefName.includes(":") ? (item.headRefName.split(":").pop() ?? item.headRefName) : item.headRefName;
? item.headRefName.split(":").pop() ?? item.headRefName
: item.headRefName;
return snapshotFromGhItem({ ...item, headRefName }); return snapshotFromGhItem({ ...item, headRefName });
}); });
@ -157,22 +134,9 @@ export async function listPullRequests(repoPath: string): Promise<PullRequestSna
} }
} }
export async function getPrInfo( export async function getPrInfo(repoPath: string, branchName: string): Promise<PullRequestSnapshot | null> {
repoPath: string,
branchName: string
): Promise<PullRequestSnapshot | null> {
try { try {
const { stdout } = await execFileAsync( const { stdout } = await execFileAsync("gh", ["pr", "view", branchName, "--json", PR_JSON_FIELDS], { maxBuffer: 1024 * 1024 * 4, cwd: repoPath });
"gh",
[
"pr",
"view",
branchName,
"--json",
PR_JSON_FIELDS
],
{ maxBuffer: 1024 * 1024 * 4, cwd: repoPath }
);
const item = JSON.parse(stdout) as GhPrListItem; const item = JSON.parse(stdout) as GhPrListItem;
return snapshotFromGhItem(item); return snapshotFromGhItem(item);
@ -181,12 +145,7 @@ export async function getPrInfo(
} }
} }
export async function createPr( export async function createPr(repoPath: string, headBranch: string, title: string, body?: string): Promise<{ number: number; url: string }> {
repoPath: string,
headBranch: string,
title: string,
body?: string
): Promise<{ number: number; url: string }> {
const args = ["pr", "create", "--title", title, "--head", headBranch]; const args = ["pr", "create", "--title", title, "--head", headBranch];
if (body) { if (body) {
args.push("--body", body); args.push("--body", body);
@ -196,7 +155,7 @@ export async function createPr(
const { stdout } = await execFileAsync("gh", args, { const { stdout } = await execFileAsync("gh", args, {
maxBuffer: 1024 * 1024, maxBuffer: 1024 * 1024,
cwd: repoPath cwd: repoPath,
}); });
// gh pr create outputs the PR URL on success // gh pr create outputs the PR URL on success
@ -208,29 +167,17 @@ export async function createPr(
return { number, url }; return { number, url };
} }
export async function getAllowedMergeMethod( export async function getAllowedMergeMethod(repoPath: string): Promise<"squash" | "rebase" | "merge"> {
repoPath: string
): Promise<"squash" | "rebase" | "merge"> {
try { try {
// Get the repo owner/name from gh // Get the repo owner/name from gh
const { stdout: repoJson } = await execFileAsync( const { stdout: repoJson } = await execFileAsync("gh", ["repo", "view", "--json", "owner,name"], { cwd: repoPath });
"gh",
["repo", "view", "--json", "owner,name"],
{ cwd: repoPath }
);
const repo = JSON.parse(repoJson) as { owner: { login: string }; name: string }; const repo = JSON.parse(repoJson) as { owner: { login: string }; name: string };
const repoFullName = `${repo.owner.login}/${repo.name}`; const repoFullName = `${repo.owner.login}/${repo.name}`;
const { stdout } = await execFileAsync( const { stdout } = await execFileAsync("gh", ["api", `repos/${repoFullName}`, "--jq", ".allow_squash_merge, .allow_rebase_merge, .allow_merge_commit"], {
"gh", maxBuffer: 1024 * 1024,
[ cwd: repoPath,
"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 lines = stdout.trim().split("\n");
const allowSquash = lines[0]?.trim() === "true"; const allowSquash = lines[0]?.trim() === "true";
@ -248,23 +195,12 @@ export async function getAllowedMergeMethod(
export async function mergePr(repoPath: string, prNumber: number): Promise<void> { export async function mergePr(repoPath: string, prNumber: number): Promise<void> {
const method = await getAllowedMergeMethod(repoPath); const method = await getAllowedMergeMethod(repoPath);
await execFileAsync( await execFileAsync("gh", ["pr", "merge", String(prNumber), `--${method}`, "--delete-branch"], { cwd: repoPath });
"gh",
["pr", "merge", String(prNumber), `--${method}`, "--delete-branch"],
{ cwd: repoPath }
);
} }
export async function isPrMerged( export async function isPrMerged(repoPath: string, branchName: string): Promise<boolean> {
repoPath: string,
branchName: string
): Promise<boolean> {
try { try {
const { stdout } = await execFileAsync( const { stdout } = await execFileAsync("gh", ["pr", "view", branchName, "--json", "state"], { cwd: repoPath });
"gh",
["pr", "view", branchName, "--json", "state"],
{ cwd: repoPath }
);
const parsed = JSON.parse(stdout) as { state: string }; const parsed = JSON.parse(stdout) as { state: string };
return parsed.state.toUpperCase() === "MERGED"; return parsed.state.toUpperCase() === "MERGED";
} catch { } catch {
@ -272,16 +208,9 @@ export async function isPrMerged(
} }
} }
export async function getPrTitle( export async function getPrTitle(repoPath: string, branchName: string): Promise<string | null> {
repoPath: string,
branchName: string
): Promise<string | null> {
try { try {
const { stdout } = await execFileAsync( const { stdout } = await execFileAsync("gh", ["pr", "view", branchName, "--json", "title"], { cwd: repoPath });
"gh",
["pr", "view", branchName, "--json", "title"],
{ cwd: repoPath }
);
const parsed = JSON.parse(stdout) as { title: string }; const parsed = JSON.parse(stdout) as { title: string };
return parsed.title; return parsed.title;
} catch { } catch {

View file

@ -21,17 +21,11 @@ export async function graphiteGet(repoPath: string, branchName: string): Promise
} }
} }
export async function graphiteCreateBranch( export async function graphiteCreateBranch(repoPath: string, branchName: string): Promise<void> {
repoPath: string,
branchName: string
): Promise<void> {
await execFileAsync("gt", ["create", branchName], { cwd: repoPath }); await execFileAsync("gt", ["create", branchName], { cwd: repoPath });
} }
export async function graphiteCheckout( export async function graphiteCheckout(repoPath: string, branchName: string): Promise<void> {
repoPath: string,
branchName: string
): Promise<void> {
await execFileAsync("gt", ["checkout", branchName], { cwd: repoPath }); 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 }); await execFileAsync("gt", ["submit", "--no-edit"], { cwd: repoPath });
} }
export async function graphiteMergeBranch( export async function graphiteMergeBranch(repoPath: string, branchName: string): Promise<void> {
repoPath: string,
branchName: string
): Promise<void> {
await execFileAsync("gt", ["merge", branchName], { cwd: repoPath }); await execFileAsync("gt", ["merge", branchName], { cwd: repoPath });
} }
export async function graphiteAbandon( export async function graphiteAbandon(repoPath: string, branchName: string): Promise<void> {
repoPath: string,
branchName: string
): Promise<void> {
await execFileAsync("gt", ["abandon", branchName], { cwd: repoPath }); await execFileAsync("gt", ["abandon", branchName], { cwd: repoPath });
} }
@ -58,14 +46,12 @@ export interface GraphiteStackEntry {
parentBranch: string | null; parentBranch: string | null;
} }
export async function graphiteGetStack( export async function graphiteGetStack(repoPath: string): Promise<GraphiteStackEntry[]> {
repoPath: string
): Promise<GraphiteStackEntry[]> {
try { try {
// Try JSON output first // Try JSON output first
const { stdout } = await execFileAsync("gt", ["log", "--json"], { const { stdout } = await execFileAsync("gt", ["log", "--json"], {
cwd: repoPath, cwd: repoPath,
maxBuffer: 1024 * 1024 maxBuffer: 1024 * 1024,
}); });
const parsed = JSON.parse(stdout) as Array<{ const parsed = JSON.parse(stdout) as Array<{
@ -77,14 +63,14 @@ export async function graphiteGetStack(
return parsed.map((entry) => ({ return parsed.map((entry) => ({
branchName: entry.branch ?? entry.name ?? "", branchName: entry.branch ?? entry.name ?? "",
parentBranch: entry.parent ?? entry.parentBranch ?? null parentBranch: entry.parent ?? entry.parentBranch ?? null,
})); }));
} catch { } catch {
// Fall back to text parsing of `gt log` // Fall back to text parsing of `gt log`
try { try {
const { stdout } = await execFileAsync("gt", ["log"], { const { stdout } = await execFileAsync("gt", ["log"], {
cwd: repoPath, cwd: repoPath,
maxBuffer: 1024 * 1024 maxBuffer: 1024 * 1024,
}); });
const entries: GraphiteStackEntry[] = []; const entries: GraphiteStackEntry[] = [];
@ -113,9 +99,7 @@ export async function graphiteGetStack(
branchStack.pop(); branchStack.pop();
} }
const parentBranch = branchStack.length > 0 const parentBranch = branchStack.length > 0 ? (branchStack[branchStack.length - 1] ?? null) : null;
? branchStack[branchStack.length - 1] ?? null
: null;
entries.push({ branchName, parentBranch }); entries.push({ branchName, parentBranch });
branchStack.push(branchName); branchStack.push(branchName);
@ -128,15 +112,12 @@ export async function graphiteGetStack(
} }
} }
export async function graphiteGetParent( export async function graphiteGetParent(repoPath: string, branchName: string): Promise<string | null> {
repoPath: string,
branchName: string
): Promise<string | null> {
try { try {
// Try `gt get <branchName>` to see parent info // Try `gt get <branchName>` to see parent info
const { stdout } = await execFileAsync("gt", ["get", branchName], { const { stdout } = await execFileAsync("gt", ["get", branchName], {
cwd: repoPath, cwd: repoPath,
maxBuffer: 1024 * 1024 maxBuffer: 1024 * 1024,
}); });
// Parse output for parent branch reference // Parse output for parent branch reference

View file

@ -1,12 +1,5 @@
import type { AgentType } from "@openhandoff/shared"; import type { AgentType } from "@openhandoff/shared";
import type { import type { ListEventsRequest, ListPage, ListPageRequest, SessionEvent, SessionPersistDriver, SessionRecord } from "sandbox-agent";
ListEventsRequest,
ListPage,
ListPageRequest,
SessionEvent,
SessionPersistDriver,
SessionRecord
} from "sandbox-agent";
import { SandboxAgent } from "sandbox-agent"; import { SandboxAgent } from "sandbox-agent";
export type AgentId = AgentType | "opencode"; export type AgentId = AgentType | "opencode";
@ -118,18 +111,11 @@ export class SandboxAgentClient {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
const lowered = message.toLowerCase(); const lowered = message.toLowerCase();
// sandbox-agent server times out long-running ACP prompts and returns a 504-like error. // sandbox-agent server times out long-running ACP prompts and returns a 504-like error.
return ( return lowered.includes("timeout waiting for agent response") || lowered.includes("timed out waiting for agent response") || lowered.includes("504");
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> { async createSession(request: string | SandboxSessionCreateRequest): Promise<SandboxSession> {
const normalized: SandboxSessionCreateRequest = const normalized: SandboxSessionCreateRequest = typeof request === "string" ? { prompt: request } : request;
typeof request === "string"
? { prompt: request }
: request;
const sdk = await this.sdk(); const sdk = await this.sdk();
// Do not wrap createSession in a local Promise.race timeout. The underlying 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 // call is not abortable, so local timeout races create overlapping ACP requests and
@ -343,18 +329,14 @@ export class SandboxAgentClient {
} while (cursor); } while (cursor);
} }
async generateCommitMessage( async generateCommitMessage(dir: string, spec: string, task: string): Promise<string> {
dir: string,
spec: string,
task: string
): Promise<string> {
const prompt = [ const prompt = [
"Generate a conventional commit message for the following changes.", "Generate a conventional commit message for the following changes.",
"Return ONLY the commit message, no explanation or markdown formatting.", "Return ONLY the commit message, no explanation or markdown formatting.",
"", "",
`Task: ${task}`, `Task: ${task}`,
"", "",
`Spec/diff:\n${spec}` `Spec/diff:\n${spec}`,
].join("\n"); ].join("\n");
const sdk = await this.sdk(); const sdk = await this.sdk();

View file

@ -99,10 +99,10 @@ export class TerminalBellBackend implements NotifyBackend {
} }
const backendFactories: Record<string, () => NotifyBackend> = { const backendFactories: Record<string, () => NotifyBackend> = {
"openclaw": () => new OpenclawBackend(), openclaw: () => new OpenclawBackend(),
"macos-osascript": () => new MacOsNotifyBackend(), "macos-osascript": () => new MacOsNotifyBackend(),
"linux-notify-send": () => new LinuxNotifySendBackend(), "linux-notify-send": () => new LinuxNotifySendBackend(),
"terminal": () => new TerminalBellBackend(), terminal: () => new TerminalBellBackend(),
}; };
export async function createBackends(configOrder: string[]): Promise<NotifyBackend[]> { export async function createBackends(configOrder: string[]): Promise<NotifyBackend[]> {

View file

@ -49,11 +49,7 @@ export function createNotificationService(backends: NotifyBackend[]): Notificati
}, },
async changesRequested(branchName: string, prNumber: number, reviewer: string): Promise<void> { async changesRequested(branchName: string, prNumber: number, reviewer: string): Promise<void> {
await notify( await notify("Changes Requested", `Changes requested on PR #${prNumber} (${branchName}) by ${reviewer}`, "high");
"Changes Requested",
`Changes requested on PR #${prNumber} (${branchName}) by ${reviewer}`,
"high",
);
}, },
async prMerged(branchName: string, prNumber: number): Promise<void> { async prMerged(branchName: string, prNumber: number): Promise<void> {

View file

@ -15,14 +15,7 @@ export class PrStateTracker {
this.states = new Map(); this.states = new Map();
} }
update( update(repoId: string, branchName: string, prNumber: number, ci: CiState, review: ReviewState, reviewer?: string): PrStateTransition[] {
repoId: string,
branchName: string,
prNumber: number,
ci: CiState,
review: ReviewState,
reviewer?: string,
): PrStateTransition[] {
const key = `${repoId}:${branchName}`; const key = `${repoId}:${branchName}`;
const prev = this.states.get(key); const prev = this.states.get(key);
const transitions: PrStateTransition[] = []; const transitions: PrStateTransition[] = [];

View file

@ -13,7 +13,7 @@ import type {
SandboxHandle, SandboxHandle,
SandboxHealth, SandboxHealth,
SandboxHealthRequest, SandboxHealthRequest,
SandboxProvider SandboxProvider,
} from "../provider-api/index.js"; } from "../provider-api/index.js";
import type { DaytonaDriver } from "../../driver.js"; import type { DaytonaDriver } from "../../driver.js";
import { Image } from "@daytonaio/sdk"; import { Image } from "@daytonaio/sdk";
@ -33,7 +33,7 @@ export interface DaytonaProviderConfig {
export class DaytonaProvider implements SandboxProvider { export class DaytonaProvider implements SandboxProvider {
constructor( constructor(
private readonly config: DaytonaProviderConfig, private readonly config: DaytonaProviderConfig,
private readonly daytona?: DaytonaDriver private readonly daytona?: DaytonaDriver,
) {} ) {}
private static readonly SANDBOX_AGENT_PORT = 2468; private static readonly SANDBOX_AGENT_PORT = 2468;
@ -60,10 +60,7 @@ export class DaytonaProvider implements SandboxProvider {
} }
private getAcpRequestTimeoutMs(): number { private getAcpRequestTimeoutMs(): number {
const parsed = Number( const parsed = Number(process.env.HF_SANDBOX_AGENT_ACP_REQUEST_TIMEOUT_MS ?? DaytonaProvider.DEFAULT_ACP_REQUEST_TIMEOUT_MS.toString());
process.env.HF_SANDBOX_AGENT_ACP_REQUEST_TIMEOUT_MS
?? DaytonaProvider.DEFAULT_ACP_REQUEST_TIMEOUT_MS.toString()
);
if (!Number.isFinite(parsed) || parsed <= 0) { if (!Number.isFinite(parsed) || parsed <= 0) {
return DaytonaProvider.DEFAULT_ACP_REQUEST_TIMEOUT_MS; return DaytonaProvider.DEFAULT_ACP_REQUEST_TIMEOUT_MS;
} }
@ -117,7 +114,7 @@ export class DaytonaProvider implements SandboxProvider {
throw new Error( throw new Error(
"daytona provider is not configured: missing apiKey. " + "daytona provider is not configured: missing apiKey. " +
"Set HF_DAYTONA_API_KEY (or DAYTONA_API_KEY). " + "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( return Image.base(this.config.image).runCommands(
"apt-get update && apt-get install -y curl ca-certificates git openssh-client nodejs npm", "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`, `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( private async runCheckedCommand(sandboxId: string, command: string, label: string): Promise<void> {
sandboxId: string,
command: string,
label: string
): Promise<void> {
const client = this.requireClient(); const client = this.requireClient();
const result = await this.withTimeout(`execute command (${label})`, () => const result = await this.withTimeout(`execute command (${label})`, () => client.executeCommand(sandboxId, command));
client.executeCommand(sandboxId, command)
);
if (result.exitCode !== 0) { if (result.exitCode !== 0) {
throw new Error(`daytona ${label} failed (${result.exitCode}): ${result.result}`); throw new Error(`daytona ${label} failed (${result.exitCode}): ${result.result}`);
} }
@ -180,7 +171,7 @@ export class DaytonaProvider implements SandboxProvider {
capabilities(): ProviderCapabilities { capabilities(): ProviderCapabilities {
return { return {
remote: true, remote: true,
supportsSessionReuse: true supportsSessionReuse: true,
}; };
} }
@ -196,7 +187,7 @@ export class DaytonaProvider implements SandboxProvider {
workspaceId: req.workspaceId, workspaceId: req.workspaceId,
repoId: req.repoId, repoId: req.repoId,
handoffId: req.handoffId, handoffId: req.handoffId,
branchName: req.branchName branchName: req.branchName,
}); });
const createStartedAt = Date.now(); const createStartedAt = Date.now();
@ -212,12 +203,12 @@ export class DaytonaProvider implements SandboxProvider {
"openhandoff.branch": req.branchName, "openhandoff.branch": req.branchName,
}, },
autoStopInterval: this.config.autoStopInterval, autoStopInterval: this.config.autoStopInterval,
}) }),
); );
emitDebug("daytona.createSandbox.created", { emitDebug("daytona.createSandbox.created", {
sandboxId: sandbox.id, sandboxId: sandbox.id,
durationMs: Date.now() - createStartedAt, durationMs: Date.now() - createStartedAt,
state: sandbox.state ?? null state: sandbox.state ?? null,
}); });
const repoDir = `/home/daytona/openhandoff/${req.workspaceId}/${req.repoId}/${req.handoffId}/repo`; const repoDir = `/home/daytona/openhandoff/${req.workspaceId}/${req.repoId}/${req.handoffId}/repo`;
@ -229,13 +220,13 @@ export class DaytonaProvider implements SandboxProvider {
[ [
"bash", "bash",
"-lc", "-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(" "), ].join(" "),
"install git + node toolchain" "install git + node toolchain",
); );
emitDebug("daytona.createSandbox.install_toolchain.done", { emitDebug("daytona.createSandbox.install_toolchain.done", {
sandboxId: sandbox.id, sandboxId: sandbox.id,
durationMs: Date.now() - installStartedAt durationMs: Date.now() - installStartedAt,
}); });
const cloneStartedAt = Date.now(); 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`, `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.email "openhandoff@local" >/dev/null 2>&1 || true`,
`git config user.name "OpenHandoff" >/dev/null 2>&1 || true`, `git config user.name "OpenHandoff" >/dev/null 2>&1 || true`,
].join("; ") ].join("; "),
)}` )}`,
].join(" "), ].join(" "),
"clone repo" "clone repo",
); );
emitDebug("daytona.createSandbox.clone_repo.done", { emitDebug("daytona.createSandbox.clone_repo.done", {
sandboxId: sandbox.id, sandboxId: sandbox.id,
durationMs: Date.now() - cloneStartedAt durationMs: Date.now() - cloneStartedAt,
}); });
return { return {
@ -280,7 +271,7 @@ export class DaytonaProvider implements SandboxProvider {
remote: true, remote: true,
state: sandbox.state ?? null, state: sandbox.state ?? null,
cwd: repoDir, cwd: repoDir,
} },
}; };
} }
@ -290,17 +281,12 @@ export class DaytonaProvider implements SandboxProvider {
await this.ensureStarted(req.sandboxId); await this.ensureStarted(req.sandboxId);
// Reconstruct cwd from sandbox labels written at create time. // Reconstruct cwd from sandbox labels written at create time.
const info = await this.withTimeout("resume get sandbox", () => const info = await this.withTimeout("resume get sandbox", () => client.getSandbox(req.sandboxId));
client.getSandbox(req.sandboxId)
);
const labels = info.labels ?? {}; const labels = info.labels ?? {};
const workspaceId = labels["openhandoff.workspace"] ?? req.workspaceId; const workspaceId = labels["openhandoff.workspace"] ?? req.workspaceId;
const repoId = labels["openhandoff.repo_id"] ?? ""; const repoId = labels["openhandoff.repo_id"] ?? "";
const handoffId = labels["openhandoff.handoff"] ?? ""; const handoffId = labels["openhandoff.handoff"] ?? "";
const cwd = const cwd = repoId && handoffId ? `/home/daytona/openhandoff/${workspaceId}/${repoId}/${handoffId}/repo` : null;
repoId && handoffId
? `/home/daytona/openhandoff/${workspaceId}/${repoId}/${handoffId}/repo`
: null;
return { return {
sandboxId: req.sandboxId, sandboxId: req.sandboxId,
@ -309,7 +295,7 @@ export class DaytonaProvider implements SandboxProvider {
resumed: true, resumed: true,
endpoint: this.config.endpoint ?? null, endpoint: this.config.endpoint ?? null,
...(cwd ? { cwd } : {}), ...(cwd ? { cwd } : {}),
} },
}; };
} }
@ -359,9 +345,9 @@ export class DaytonaProvider implements SandboxProvider {
[ [
"bash", "bash",
"-lc", "-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(" "), ].join(" "),
"install curl" "install curl",
); );
await this.runCheckedCommand( await this.runCheckedCommand(
@ -369,9 +355,9 @@ export class DaytonaProvider implements SandboxProvider {
[ [
"bash", "bash",
"-lc", "-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(" "), ].join(" "),
"install node toolchain" "install node toolchain",
); );
await this.runCheckedCommand( await this.runCheckedCommand(
@ -379,9 +365,9 @@ export class DaytonaProvider implements SandboxProvider {
[ [
"bash", "bash",
"-lc", "-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(" "), ].join(" "),
"install sandbox-agent" "install sandbox-agent",
); );
for (const agentId of DaytonaProvider.AGENT_IDS) { for (const agentId of DaytonaProvider.AGENT_IDS) {
@ -389,7 +375,7 @@ export class DaytonaProvider implements SandboxProvider {
await this.runCheckedCommand( await this.runCheckedCommand(
req.sandboxId, req.sandboxId,
["bash", "-lc", `'export PATH="$HOME/.local/bin:$PATH"; sandbox-agent install-agent ${agentId}'`].join(" "), ["bash", "-lc", `'export PATH="$HOME/.local/bin:$PATH"; sandbox-agent install-agent ${agentId}'`].join(" "),
`install agent ${agentId}` `install agent ${agentId}`,
); );
} catch { } catch {
// Some sandbox-agent builds may not ship every agent plugin; treat this as best-effort. // 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", "bash",
"-lc", "-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(" "), ].join(" "),
"start sandbox-agent" "start sandbox-agent",
); );
await this.runCheckedCommand( await this.runCheckedCommand(
@ -411,18 +397,16 @@ export class DaytonaProvider implements SandboxProvider {
[ [
"bash", "bash",
"-lc", "-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(" "), ].join(" "),
"wait for sandbox-agent health" "wait for sandbox-agent health",
); );
const preview = await this.withTimeout("get preview endpoint", () => const preview = await this.withTimeout("get preview endpoint", () => client.getPreviewEndpoint(req.sandboxId, DaytonaProvider.SANDBOX_AGENT_PORT));
client.getPreviewEndpoint(req.sandboxId, DaytonaProvider.SANDBOX_AGENT_PORT)
);
return { return {
endpoint: preview.url, endpoint: preview.url,
token: preview.token token: preview.token,
}; };
} }
@ -436,9 +420,7 @@ export class DaytonaProvider implements SandboxProvider {
} }
try { try {
const sandbox = await this.withTimeout("health get sandbox", () => const sandbox = await this.withTimeout("health get sandbox", () => client.getSandbox(req.sandboxId));
client.getSandbox(req.sandboxId)
);
const state = String(sandbox.state ?? "unknown"); const state = String(sandbox.state ?? "unknown");
if (state.toLowerCase().includes("error")) { if (state.toLowerCase().includes("error")) {
return { return {
@ -461,15 +443,13 @@ export class DaytonaProvider implements SandboxProvider {
async attachTarget(req: AttachTargetRequest): Promise<AttachTarget> { async attachTarget(req: AttachTargetRequest): Promise<AttachTarget> {
return { return {
target: `daytona://${req.sandboxId}` target: `daytona://${req.sandboxId}`,
}; };
} }
async executeCommand(req: ExecuteSandboxCommandRequest): Promise<ExecuteSandboxCommandResult> { async executeCommand(req: ExecuteSandboxCommandRequest): Promise<ExecuteSandboxCommandResult> {
const client = this.requireClient(); const client = this.requireClient();
await this.ensureStarted(req.sandboxId); await this.ensureStarted(req.sandboxId);
return await this.withTimeout(`execute command (${req.label ?? "command"})`, () => return await this.withTimeout(`execute command (${req.label ?? "command"})`, () => client.executeCommand(req.sandboxId, req.command));
client.executeCommand(req.sandboxId, req.command)
);
} }
} }

View file

@ -42,19 +42,25 @@ export function createProviderRegistry(config: AppConfig, driver?: BackendDriver
}, },
}; };
const local = new LocalProvider({ const local = new LocalProvider(
rootDir: config.providers.local.rootDir, {
sandboxAgentPort: config.providers.local.sandboxAgentPort, rootDir: config.providers.local.rootDir,
}, gitDriver); sandboxAgentPort: config.providers.local.sandboxAgentPort,
const daytona = new DaytonaProvider({ },
endpoint: config.providers.daytona.endpoint, gitDriver,
apiKey: config.providers.daytona.apiKey, );
image: config.providers.daytona.image const daytona = new DaytonaProvider(
}, driver?.daytona); {
endpoint: config.providers.daytona.endpoint,
apiKey: config.providers.daytona.apiKey,
image: config.providers.daytona.image,
},
driver?.daytona,
);
const map: Record<ProviderId, SandboxProvider> = { const map: Record<ProviderId, SandboxProvider> = {
local, local,
daytona daytona,
}; };
return { return {
@ -66,6 +72,6 @@ export function createProviderRegistry(config: AppConfig, driver?: BackendDriver
}, },
defaultProviderId(): ProviderId { defaultProviderId(): ProviderId {
return config.providers.daytona.apiKey ? "daytona" : "local"; return config.providers.daytona.apiKey ? "daytona" : "local";
} },
}; };
} }

View file

@ -44,13 +44,7 @@ function expandHome(value: string): string {
async function branchExists(repoPath: string, branchName: string): Promise<boolean> { async function branchExists(repoPath: string, branchName: string): Promise<boolean> {
try { try {
await execFileAsync("git", [ await execFileAsync("git", ["-C", repoPath, "show-ref", "--verify", `refs/remotes/origin/${branchName}`]);
"-C",
repoPath,
"show-ref",
"--verify",
`refs/remotes/origin/${branchName}`,
]);
return true; return true;
} catch { } catch {
return false; 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> { async function checkoutBranch(repoPath: string, branchName: string, git: GitDriver): Promise<void> {
await git.fetch(repoPath); await git.fetch(repoPath);
const targetRef = (await branchExists(repoPath, branchName)) const targetRef = (await branchExists(repoPath, branchName)) ? `origin/${branchName}` : await git.remoteDefaultBaseRef(repoPath);
? `origin/${branchName}`
: await git.remoteDefaultBaseRef(repoPath);
await execFileAsync("git", ["-C", repoPath, "checkout", "-B", branchName, targetRef], { await execFileAsync("git", ["-C", repoPath, "checkout", "-B", branchName, targetRef], {
env: process.env as Record<string, string>, env: process.env as Record<string, string>,
}); });
@ -76,9 +68,7 @@ export class LocalProvider implements SandboxProvider {
) {} ) {}
private rootDir(): string { private rootDir(): string {
return expandHome( return expandHome(this.config.rootDir?.trim() || "~/.local/share/openhandoff/local-sandboxes");
this.config.rootDir?.trim() || "~/.local/share/openhandoff/local-sandboxes",
);
} }
private sandboxRoot(workspaceId: string, sandboxId: string): string { private sandboxRoot(workspaceId: string, sandboxId: string): string {
@ -89,11 +79,7 @@ export class LocalProvider implements SandboxProvider {
return resolve(this.sandboxRoot(workspaceId, sandboxId), "repo"); return resolve(this.sandboxRoot(workspaceId, sandboxId), "repo");
} }
private sandboxHandle( private sandboxHandle(workspaceId: string, sandboxId: string, repoDir: string): SandboxHandle {
workspaceId: string,
sandboxId: string,
repoDir: string,
): SandboxHandle {
return { return {
sandboxId, sandboxId,
switchTarget: `local://${repoDir}`, switchTarget: `local://${repoDir}`,
@ -242,9 +228,7 @@ export class LocalProvider implements SandboxProvider {
const detail = error as { stdout?: string; stderr?: string; code?: number }; const detail = error as { stdout?: string; stderr?: string; code?: number };
return { return {
exitCode: typeof detail.code === "number" ? detail.code : 1, exitCode: typeof detail.code === "number" ? detail.code : 1,
result: [detail.stdout, detail.stderr, error instanceof Error ? error.message : String(error)] result: [detail.stdout, detail.stderr, error instanceof Error ? error.message : String(error)].filter(Boolean).join(""),
.filter(Boolean)
.join(""),
}; };
} }
} }

View file

@ -39,13 +39,14 @@ export function deriveFallbackTitle(task: string, explicitTitle?: string): strin
const lowered = source.toLowerCase(); const lowered = source.toLowerCase();
const typePrefix = lowered.includes("fix") || lowered.includes("bug") const typePrefix =
? "fix" lowered.includes("fix") || lowered.includes("bug")
: lowered.includes("doc") || lowered.includes("readme") ? "fix"
? "docs" : lowered.includes("doc") || lowered.includes("readme")
: lowered.includes("refactor") ? "docs"
? "refactor" : lowered.includes("refactor")
: "feat"; ? "refactor"
: "feat";
const cleaned = source const cleaned = source
.split("") .split("")
@ -88,9 +89,7 @@ export function sanitizeBranchName(input: string): string {
return trimmed.slice(0, 50).replace(/-+$/g, ""); return trimmed.slice(0, 50).replace(/-+$/g, "");
} }
export function resolveCreateFlowDecision( export function resolveCreateFlowDecision(input: ResolveCreateFlowDecisionInput): ResolveCreateFlowDecisionResult {
input: ResolveCreateFlowDecisionInput
): ResolveCreateFlowDecisionResult {
const explicitBranch = input.explicitBranchName?.trim(); const explicitBranch = input.explicitBranchName?.trim();
const title = deriveFallbackTitle(input.task, input.explicitTitle); const title = deriveFallbackTitle(input.task, input.explicitTitle);
const generatedBase = sanitizeBranchName(title) || "handoff"; const generatedBase = sanitizeBranchName(title) || "handoff";
@ -98,16 +97,11 @@ export function resolveCreateFlowDecision(
const branchBase = explicitBranch && explicitBranch.length > 0 ? explicitBranch : generatedBase; const branchBase = explicitBranch && explicitBranch.length > 0 ? explicitBranch : generatedBase;
const existingBranches = new Set(input.localBranches.map((value) => value.trim()).filter((value) => value.length > 0)); const existingBranches = new Set(input.localBranches.map((value) => value.trim()).filter((value) => value.length > 0));
const existingHandoffBranches = new Set( const existingHandoffBranches = new Set(input.handoffBranches.map((value) => value.trim()).filter((value) => value.length > 0));
input.handoffBranches.map((value) => value.trim()).filter((value) => value.length > 0) const conflicts = (name: string): boolean => existingBranches.has(name) || existingHandoffBranches.has(name);
);
const conflicts = (name: string): boolean =>
existingBranches.has(name) || existingHandoffBranches.has(name);
if (explicitBranch && conflicts(branchBase)) { if (explicitBranch && conflicts(branchBase)) {
throw new Error( throw new Error(`Branch '${branchBase}' already exists. Choose a different --name/--branch value.`);
`Branch '${branchBase}' already exists. Choose a different --name/--branch value.`
);
} }
if (explicitBranch) { if (explicitBranch) {
@ -123,6 +117,6 @@ export function resolveCreateFlowDecision(
return { return {
title, title,
branchName: candidate branchName: candidate,
}; };
} }

View file

@ -15,11 +15,6 @@ export function openhandoffDataDir(config: AppConfig): string {
return resolve(dirname(dbPath)); return resolve(dirname(dbPath));
} }
export function openhandoffRepoClonePath( export function openhandoffRepoClonePath(config: AppConfig, workspaceId: string, repoId: string): string {
config: AppConfig,
workspaceId: string,
repoId: string
): string {
return resolve(join(openhandoffDataDir(config), "repos", workspaceId, repoId)); return resolve(join(openhandoffDataDir(config), "repos", workspaceId, repoId));
} }

View file

@ -23,11 +23,10 @@ export function setWindowStatus(branchName: string, status: string): number {
let stdout: string; let stdout: string;
try { try {
stdout = execFileSync( stdout = execFileSync("tmux", ["list-windows", "-a", "-F", "#{session_name}:#{window_id}:#{window_name}"], {
"tmux", encoding: "utf8",
["list-windows", "-a", "-F", "#{session_name}:#{window_id}:#{window_name}"], stdio: ["ignore", "pipe", "ignore"],
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] } });
);
} catch { } catch {
return 0; return 0;
} }
@ -51,7 +50,7 @@ export function setWindowStatus(branchName: string, status: string): number {
const newName = `${symbol} ${branchName}`; const newName = `${symbol} ${branchName}`;
spawnSync("tmux", ["rename-window", "-t", `${sessionName}:${windowId}`, newName], { spawnSync("tmux", ["rename-window", "-t", `${sessionName}:${windowId}`, newName], {
stdio: "ignore" stdio: "ignore",
}); });
count += 1; count += 1;
} }

View file

@ -1,9 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import { deriveFallbackTitle, resolveCreateFlowDecision, sanitizeBranchName } from "../src/services/create-flow.js";
deriveFallbackTitle,
resolveCreateFlowDecision,
sanitizeBranchName
} from "../src/services/create-flow.js";
describe("create flow decision", () => { describe("create flow decision", () => {
it("derives a conventional-style fallback title from task text", () => { it("derives a conventional-style fallback title from task text", () => {
@ -25,7 +21,7 @@ describe("create flow decision", () => {
const resolved = resolveCreateFlowDecision({ const resolved = resolveCreateFlowDecision({
task: "Add auth", task: "Add auth",
localBranches: ["feat-add-auth"], localBranches: ["feat-add-auth"],
handoffBranches: ["feat-add-auth-2"] handoffBranches: ["feat-add-auth-2"],
}); });
expect(resolved.title).toBe("feat: Add auth"); expect(resolved.title).toBe("feat: Add auth");
@ -38,8 +34,8 @@ describe("create flow decision", () => {
task: "new task", task: "new task",
explicitBranchName: "existing-branch", explicitBranchName: "existing-branch",
localBranches: ["existing-branch"], localBranches: ["existing-branch"],
handoffBranches: [] handoffBranches: [],
}) }),
).toThrow("already exists"); ).toThrow("already exists");
}); });
}); });

View file

@ -55,7 +55,7 @@ function createProviderWithClient(client: DaytonaClientLike): DaytonaProvider {
apiKey: "test-key", apiKey: "test-key",
image: "ubuntu:24.04", image: "ubuntu:24.04",
}, },
daytonaDriver daytonaDriver,
); );
} }
@ -112,7 +112,7 @@ describe("daytona provider snapshot image behavior", () => {
}); });
const startCommand = client.executedCommands.find((command) => 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"); const joined = client.executedCommands.join("\n");
@ -149,13 +149,15 @@ describe("daytona provider snapshot image behavior", () => {
try { try {
const provider = createProviderWithClient(hangingClient); const provider = createProviderWithClient(hangingClient);
await expect(provider.createSandbox({ await expect(
workspaceId: "default", provider.createSandbox({
repoId: "repo-1", workspaceId: "default",
repoRemote: "https://github.com/acme/repo.git", repoId: "repo-1",
branchName: "feature/test", repoRemote: "https://github.com/acme/repo.git",
handoffId: "handoff-timeout", branchName: "feature/test",
})).rejects.toThrow("daytona create sandbox timed out after 120ms"); handoffId: "handoff-timeout",
}),
).rejects.toThrow("daytona create sandbox timed out after 120ms");
} finally { } finally {
if (previous === undefined) { if (previous === undefined) {
delete process.env.HF_DAYTONA_REQUEST_TIMEOUT_MS; delete process.env.HF_DAYTONA_REQUEST_TIMEOUT_MS;
@ -173,7 +175,7 @@ describe("daytona provider snapshot image behavior", () => {
workspaceId: "default", workspaceId: "default",
sandboxId: "sandbox-1", sandboxId: "sandbox-1",
command: "echo backend-push", command: "echo backend-push",
label: "manual push" label: "manual push",
}); });
expect(result.exitCode).toBe(0); expect(result.exitCode).toBe(0);

View file

@ -2,11 +2,7 @@ import { chmodSync, mkdtempSync, writeFileSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import { gitSpiceAvailable, gitSpiceListStack, gitSpiceRestackSubtree } from "../src/integrations/git-spice/index.js";
gitSpiceAvailable,
gitSpiceListStack,
gitSpiceRestackSubtree
} from "../src/integrations/git-spice/index.js";
function makeTempDir(prefix: string): string { function makeTempDir(prefix: string): string {
return mkdtempSync(join(tmpdir(), prefix)); return mkdtempSync(join(tmpdir(), prefix));
@ -17,10 +13,7 @@ function writeScript(path: string, body: string): void {
chmodSync(path, 0o755); chmodSync(path, 0o755);
} }
async function withEnv<T>( async function withEnv<T>(updates: Record<string, string | undefined>, fn: () => Promise<T>): Promise<T> {
updates: Record<string, string | undefined>,
fn: () => Promise<T>
): Promise<T> {
const previous = new Map<string, string | undefined>(); const previous = new Map<string, string | undefined>();
for (const [key, value] of Object.entries(updates)) { for (const [key, value] of Object.entries(updates)) {
previous.set(key, process.env[key]); previous.set(key, process.env[key]);
@ -57,21 +50,21 @@ describe("git-spice integration", () => {
"fi", "fi",
'if [ \"$1\" = \"log\" ]; then', 'if [ \"$1\" = \"log\" ]; then',
" echo 'noise line'", " echo 'noise line'",
" echo '{\"branch\":\"feature/a\",\"parent\":\"main\"}'", ' echo \'{"branch":"feature/a","parent":"main"}\'',
" echo '{bad json'", " echo '{bad json'",
" echo '{\"name\":\"feature/b\",\"parentBranch\":\"feature/a\"}'", ' echo \'{"name":"feature/b","parentBranch":"feature/a"}\'',
" echo '{\"name\":\"feature/a\",\"parent\":\"main\"}'", ' echo \'{"name":"feature/a","parent":"main"}\'',
" exit 0", " exit 0",
"fi", "fi",
"exit 1" "exit 1",
].join("\n") ].join("\n"),
); );
await withEnv({ HF_GIT_SPICE_BIN: scriptPath }, async () => { await withEnv({ HF_GIT_SPICE_BIN: scriptPath }, async () => {
const rows = await gitSpiceListStack(repoPath); const rows = await gitSpiceListStack(repoPath);
expect(rows).toEqual([ expect(rows).toEqual([
{ branchName: "feature/a", parentBranch: "main" }, { 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', 'if [ \"$1\" = \"branch\" ] && [ \"$2\" = \"restack\" ] && [ \"$5\" = \"--no-prompt\" ]; then',
" exit 0", " exit 0",
"fi", "fi",
"exit 1" "exit 1",
].join("\n") ].join("\n"),
); );
await withEnv( await withEnv(
{ {
HF_GIT_SPICE_BIN: scriptPath, HF_GIT_SPICE_BIN: scriptPath,
SPICE_LOG_PATH: logPath SPICE_LOG_PATH: logPath,
}, },
async () => { async () => {
await gitSpiceRestackSubtree(repoPath, "feature/a"); await gitSpiceRestackSubtree(repoPath, "feature/a");
} },
); );
const lines = readFileSync(logPath, "utf8") const lines = readFileSync(logPath, "utf8")
@ -125,12 +118,12 @@ describe("git-spice integration", () => {
await withEnv( await withEnv(
{ {
HF_GIT_SPICE_BIN: "/non-existent/hf-git-spice-binary", HF_GIT_SPICE_BIN: "/non-existent/hf-git-spice-binary",
PATH: "/non-existent/bin" PATH: "/non-existent/bin",
}, },
async () => { async () => {
const available = await gitSpiceAvailable(repoPath); const available = await gitSpiceAvailable(repoPath);
expect(available).toBe(false); expect(available).toBe(false);
} },
); );
}); });
}); });

View file

@ -13,10 +13,7 @@ export function createTestConfig(overrides?: Partial<AppConfig>): AppConfig {
backend: { backend: {
host: "127.0.0.1", host: "127.0.0.1",
port: 7741, port: 7741,
dbPath: join( dbPath: join(tmpdir(), `hf-test-${Date.now()}-${Math.random().toString(16).slice(2)}.db`),
tmpdir(),
`hf-test-${Date.now()}-${Math.random().toString(16).slice(2)}.db`
),
opencode_poll_interval: 2, opencode_poll_interval: 2,
github_poll_interval: 30, github_poll_interval: 30,
backup_interval_secs: 3600, backup_interval_secs: 3600,
@ -29,10 +26,7 @@ export function createTestConfig(overrides?: Partial<AppConfig>): AppConfig {
}); });
} }
export function createTestRuntimeContext( export function createTestRuntimeContext(driver: BackendDriver, configOverrides?: Partial<AppConfig>): { config: AppConfig } {
driver: BackendDriver,
configOverrides?: Partial<AppConfig>
): { config: AppConfig } {
const config = createTestConfig(configOverrides); const config = createTestConfig(configOverrides);
const providers = createProviderRegistry(config, driver); const providers = createProviderRegistry(config, driver);
initActorRuntimeContext(config, providers, undefined, driver); initActorRuntimeContext(config, providers, undefined, driver);

View file

@ -62,18 +62,14 @@ export function createTestGithubDriver(overrides?: Partial<GithubDriver>): Githu
}; };
} }
export function createTestSandboxAgentDriver( export function createTestSandboxAgentDriver(overrides?: Partial<SandboxAgentDriver>): SandboxAgentDriver {
overrides?: Partial<SandboxAgentDriver>
): SandboxAgentDriver {
return { return {
createClient: (_opts) => createTestSandboxAgentClient(), createClient: (_opts) => createTestSandboxAgentClient(),
...overrides, ...overrides,
}; };
} }
export function createTestSandboxAgentClient( export function createTestSandboxAgentClient(overrides?: Partial<SandboxAgentClientLike>): SandboxAgentClientLike {
overrides?: Partial<SandboxAgentClientLike>
): SandboxAgentClientLike {
return { return {
createSession: async (_prompt) => ({ id: "test-session-1", status: "running" }), createSession: async (_prompt) => ({ id: "test-session-1", status: "running" }),
sessionStatus: async (sessionId) => ({ id: sessionId, status: "running" }), sessionStatus: async (sessionId) => ({ id: sessionId, status: "running" }),
@ -92,18 +88,14 @@ export function createTestSandboxAgentClient(
}; };
} }
export function createTestDaytonaDriver( export function createTestDaytonaDriver(overrides?: Partial<DaytonaDriver>): DaytonaDriver {
overrides?: Partial<DaytonaDriver>
): DaytonaDriver {
return { return {
createClient: (_opts) => createTestDaytonaClient(), createClient: (_opts) => createTestDaytonaClient(),
...overrides, ...overrides,
}; };
} }
export function createTestDaytonaClient( export function createTestDaytonaClient(overrides?: Partial<DaytonaClientLike>): DaytonaClientLike {
overrides?: Partial<DaytonaClientLike>
): DaytonaClientLike {
return { return {
createSandbox: async () => ({ id: "sandbox-test-1", state: "started" }), createSandbox: async () => ({ id: "sandbox-test-1", state: "started" }),
getSandbox: async (sandboxId) => ({ id: sandboxId, state: "started" }), getSandbox: async (sandboxId) => ({ id: sandboxId, state: "started" }),

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