Complete Foundry refactor checklist

This commit is contained in:
Nathan Flurry 2026-03-15 13:38:51 -07:00 committed by Nathan Flurry
parent 40bed3b0a1
commit 13fc9cb318
91 changed files with 5091 additions and 4108 deletions

View file

@ -20,7 +20,9 @@
"paths": {
"/v1/acp": {
"get": {
"tags": ["v1"],
"tags": [
"v1"
],
"operationId": "get_v1_acp_servers",
"responses": {
"200": {
@ -38,7 +40,9 @@
},
"/v1/acp/{server_id}": {
"get": {
"tags": ["v1"],
"tags": [
"v1"
],
"operationId": "get_v1_acp",
"parameters": [
{
@ -88,7 +92,9 @@
}
},
"post": {
"tags": ["v1"],
"tags": [
"v1"
],
"operationId": "post_v1_acp",
"parameters": [
{
@ -198,7 +204,9 @@
}
},
"delete": {
"tags": ["v1"],
"tags": [
"v1"
],
"operationId": "delete_v1_acp",
"parameters": [
{
@ -220,7 +228,9 @@
},
"/v1/agents": {
"get": {
"tags": ["v1"],
"tags": [
"v1"
],
"operationId": "get_v1_agents",
"parameters": [
{
@ -270,7 +280,9 @@
},
"/v1/agents/{agent}": {
"get": {
"tags": ["v1"],
"tags": [
"v1"
],
"operationId": "get_v1_agent",
"parameters": [
{
@ -339,7 +351,9 @@
},
"/v1/agents/{agent}/install": {
"post": {
"tags": ["v1"],
"tags": [
"v1"
],
"operationId": "post_v1_agent_install",
"parameters": [
{
@ -398,7 +412,9 @@
},
"/v1/config/mcp": {
"get": {
"tags": ["v1"],
"tags": [
"v1"
],
"operationId": "get_v1_config_mcp",
"parameters": [
{
@ -444,7 +460,9 @@
}
},
"put": {
"tags": ["v1"],
"tags": [
"v1"
],
"operationId": "put_v1_config_mcp",
"parameters": [
{
@ -483,7 +501,9 @@
}
},
"delete": {
"tags": ["v1"],
"tags": [
"v1"
],
"operationId": "delete_v1_config_mcp",
"parameters": [
{
@ -514,7 +534,9 @@
},
"/v1/config/skills": {
"get": {
"tags": ["v1"],
"tags": [
"v1"
],
"operationId": "get_v1_config_skills",
"parameters": [
{
@ -560,7 +582,9 @@
}
},
"put": {
"tags": ["v1"],
"tags": [
"v1"
],
"operationId": "put_v1_config_skills",
"parameters": [
{
@ -599,7 +623,9 @@
}
},
"delete": {
"tags": ["v1"],
"tags": [
"v1"
],
"operationId": "delete_v1_config_skills",
"parameters": [
{
@ -630,7 +656,9 @@
},
"/v1/fs/entries": {
"get": {
"tags": ["v1"],
"tags": [
"v1"
],
"operationId": "get_v1_fs_entries",
"parameters": [
{
@ -663,7 +691,9 @@
},
"/v1/fs/entry": {
"delete": {
"tags": ["v1"],
"tags": [
"v1"
],
"operationId": "delete_v1_fs_entry",
"parameters": [
{
@ -702,7 +732,9 @@
},
"/v1/fs/file": {
"get": {
"tags": ["v1"],
"tags": [
"v1"
],
"operationId": "get_v1_fs_file",
"parameters": [
{
@ -722,7 +754,9 @@
}
},
"put": {
"tags": ["v1"],
"tags": [
"v1"
],
"operationId": "put_v1_fs_file",
"parameters": [
{
@ -762,7 +796,9 @@
},
"/v1/fs/mkdir": {
"post": {
"tags": ["v1"],
"tags": [
"v1"
],
"operationId": "post_v1_fs_mkdir",
"parameters": [
{
@ -791,7 +827,9 @@
},
"/v1/fs/move": {
"post": {
"tags": ["v1"],
"tags": [
"v1"
],
"operationId": "post_v1_fs_move",
"requestBody": {
"content": {
@ -819,7 +857,9 @@
},
"/v1/fs/stat": {
"get": {
"tags": ["v1"],
"tags": [
"v1"
],
"operationId": "get_v1_fs_stat",
"parameters": [
{
@ -848,7 +888,9 @@
},
"/v1/fs/upload-batch": {
"post": {
"tags": ["v1"],
"tags": [
"v1"
],
"operationId": "post_v1_fs_upload_batch",
"parameters": [
{
@ -889,7 +931,9 @@
},
"/v1/health": {
"get": {
"tags": ["v1"],
"tags": [
"v1"
],
"operationId": "get_v1_health",
"responses": {
"200": {
@ -907,7 +951,9 @@
},
"/v1/processes": {
"get": {
"tags": ["v1"],
"tags": [
"v1"
],
"summary": "List all managed processes.",
"description": "Returns a list of all processes (running and exited) currently tracked\nby the runtime, sorted by process ID.",
"operationId": "get_v1_processes",
@ -935,7 +981,9 @@
}
},
"post": {
"tags": ["v1"],
"tags": [
"v1"
],
"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.",
"operationId": "post_v1_processes",
@ -995,7 +1043,9 @@
},
"/v1/processes/config": {
"get": {
"tags": ["v1"],
"tags": [
"v1"
],
"summary": "Get process runtime configuration.",
"description": "Returns the current runtime configuration for the process management API,\nincluding limits for concurrency, timeouts, and buffer sizes.",
"operationId": "get_v1_processes_config",
@ -1023,7 +1073,9 @@
}
},
"post": {
"tags": ["v1"],
"tags": [
"v1"
],
"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.",
"operationId": "post_v1_processes_config",
@ -1073,7 +1125,9 @@
},
"/v1/processes/run": {
"post": {
"tags": ["v1"],
"tags": [
"v1"
],
"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.",
"operationId": "post_v1_processes_run",
@ -1123,7 +1177,9 @@
},
"/v1/processes/{id}": {
"get": {
"tags": ["v1"],
"tags": [
"v1"
],
"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.",
"operationId": "get_v1_process",
@ -1172,7 +1228,9 @@
}
},
"delete": {
"tags": ["v1"],
"tags": [
"v1"
],
"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.",
"operationId": "delete_v1_process",
@ -1226,7 +1284,9 @@
},
"/v1/processes/{id}/input": {
"post": {
"tags": ["v1"],
"tags": [
"v1"
],
"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.",
"operationId": "post_v1_process_input",
@ -1307,7 +1367,9 @@
},
"/v1/processes/{id}/kill": {
"post": {
"tags": ["v1"],
"tags": [
"v1"
],
"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.",
"operationId": "post_v1_process_kill",
@ -1370,7 +1432,9 @@
},
"/v1/processes/{id}/logs": {
"get": {
"tags": ["v1"],
"tags": [
"v1"
],
"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.",
"operationId": "get_v1_process_logs",
@ -1468,7 +1532,9 @@
},
"/v1/processes/{id}/stop": {
"post": {
"tags": ["v1"],
"tags": [
"v1"
],
"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.",
"operationId": "post_v1_process_stop",
@ -1531,7 +1597,9 @@
},
"/v1/processes/{id}/terminal/resize": {
"post": {
"tags": ["v1"],
"tags": [
"v1"
],
"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.",
"operationId": "post_v1_process_terminal_resize",
@ -1612,7 +1680,9 @@
},
"/v1/processes/{id}/terminal/ws": {
"get": {
"tags": ["v1"],
"tags": [
"v1"
],
"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.",
"operationId": "get_v1_process_terminal_ws",
@ -1689,7 +1759,9 @@
"schemas": {
"AcpEnvelope": {
"type": "object",
"required": ["jsonrpc"],
"required": [
"jsonrpc"
],
"properties": {
"error": {
"nullable": true
@ -1723,7 +1795,11 @@
},
"AcpServerInfo": {
"type": "object",
"required": ["serverId", "agent", "createdAtMs"],
"required": [
"serverId",
"agent",
"createdAtMs"
],
"properties": {
"agent": {
"type": "string"
@ -1739,7 +1815,9 @@
},
"AcpServerListResponse": {
"type": "object",
"required": ["servers"],
"required": [
"servers"
],
"properties": {
"servers": {
"type": "array",
@ -1830,7 +1908,12 @@
},
"AgentInfo": {
"type": "object",
"required": ["id", "installed", "credentialsAvailable", "capabilities"],
"required": [
"id",
"installed",
"credentialsAvailable",
"capabilities"
],
"properties": {
"capabilities": {
"$ref": "#/components/schemas/AgentCapabilities"
@ -1873,7 +1956,11 @@
},
"AgentInstallArtifact": {
"type": "object",
"required": ["kind", "path", "source"],
"required": [
"kind",
"path",
"source"
],
"properties": {
"kind": {
"type": "string"
@ -1909,7 +1996,10 @@
},
"AgentInstallResponse": {
"type": "object",
"required": ["already_installed", "artifacts"],
"required": [
"already_installed",
"artifacts"
],
"properties": {
"already_installed": {
"type": "boolean"
@ -1924,7 +2014,9 @@
},
"AgentListResponse": {
"type": "object",
"required": ["agents"],
"required": [
"agents"
],
"properties": {
"agents": {
"type": "array",
@ -1957,7 +2049,9 @@
},
"FsActionResponse": {
"type": "object",
"required": ["path"],
"required": [
"path"
],
"properties": {
"path": {
"type": "string"
@ -1966,7 +2060,9 @@
},
"FsDeleteQuery": {
"type": "object",
"required": ["path"],
"required": [
"path"
],
"properties": {
"path": {
"type": "string"
@ -1988,7 +2084,12 @@
},
"FsEntry": {
"type": "object",
"required": ["name", "path", "entryType", "size"],
"required": [
"name",
"path",
"entryType",
"size"
],
"properties": {
"entryType": {
"$ref": "#/components/schemas/FsEntryType"
@ -2012,11 +2113,17 @@
},
"FsEntryType": {
"type": "string",
"enum": ["file", "directory"]
"enum": [
"file",
"directory"
]
},
"FsMoveRequest": {
"type": "object",
"required": ["from", "to"],
"required": [
"from",
"to"
],
"properties": {
"from": {
"type": "string"
@ -2032,7 +2139,10 @@
},
"FsMoveResponse": {
"type": "object",
"required": ["from", "to"],
"required": [
"from",
"to"
],
"properties": {
"from": {
"type": "string"
@ -2044,7 +2154,9 @@
},
"FsPathQuery": {
"type": "object",
"required": ["path"],
"required": [
"path"
],
"properties": {
"path": {
"type": "string"
@ -2053,7 +2165,11 @@
},
"FsStat": {
"type": "object",
"required": ["path", "entryType", "size"],
"required": [
"path",
"entryType",
"size"
],
"properties": {
"entryType": {
"$ref": "#/components/schemas/FsEntryType"
@ -2083,7 +2199,10 @@
},
"FsUploadBatchResponse": {
"type": "object",
"required": ["paths", "truncated"],
"required": [
"paths",
"truncated"
],
"properties": {
"paths": {
"type": "array",
@ -2098,7 +2217,10 @@
},
"FsWriteResponse": {
"type": "object",
"required": ["path", "bytesWritten"],
"required": [
"path",
"bytesWritten"
],
"properties": {
"bytesWritten": {
"type": "integer",
@ -2112,7 +2234,9 @@
},
"HealthResponse": {
"type": "object",
"required": ["status"],
"required": [
"status"
],
"properties": {
"status": {
"type": "string"
@ -2121,7 +2245,10 @@
},
"McpConfigQuery": {
"type": "object",
"required": ["directory", "mcpName"],
"required": [
"directory",
"mcpName"
],
"properties": {
"directory": {
"type": "string"
@ -2135,7 +2262,10 @@
"oneOf": [
{
"type": "object",
"required": ["command", "type"],
"required": [
"command",
"type"
],
"properties": {
"args": {
"type": "array",
@ -2169,13 +2299,18 @@
},
"type": {
"type": "string",
"enum": ["local"]
"enum": [
"local"
]
}
}
},
{
"type": "object",
"required": ["url", "type"],
"required": [
"url",
"type"
],
"properties": {
"bearerTokenEnvVar": {
"type": "string",
@ -2223,7 +2358,9 @@
},
"type": {
"type": "string",
"enum": ["remote"]
"enum": [
"remote"
]
},
"url": {
"type": "string"
@ -2237,7 +2374,11 @@
},
"ProblemDetails": {
"type": "object",
"required": ["type", "title", "status"],
"required": [
"type",
"title",
"status"
],
"properties": {
"detail": {
"type": "string",
@ -2263,7 +2404,14 @@
},
"ProcessConfig": {
"type": "object",
"required": ["maxConcurrentProcesses", "defaultRunTimeoutMs", "maxRunTimeoutMs", "maxOutputBytes", "maxLogBytesPerProcess", "maxInputBytesPerRequest"],
"required": [
"maxConcurrentProcesses",
"defaultRunTimeoutMs",
"maxRunTimeoutMs",
"maxOutputBytes",
"maxLogBytesPerProcess",
"maxInputBytesPerRequest"
],
"properties": {
"defaultRunTimeoutMs": {
"type": "integer",
@ -2295,7 +2443,9 @@
},
"ProcessCreateRequest": {
"type": "object",
"required": ["command"],
"required": [
"command"
],
"properties": {
"args": {
"type": "array",
@ -2326,7 +2476,15 @@
},
"ProcessInfo": {
"type": "object",
"required": ["id", "command", "args", "tty", "interactive", "status", "createdAtMs"],
"required": [
"id",
"command",
"args",
"tty",
"interactive",
"status",
"createdAtMs"
],
"properties": {
"args": {
"type": "array",
@ -2377,7 +2535,9 @@
},
"ProcessInputRequest": {
"type": "object",
"required": ["data"],
"required": [
"data"
],
"properties": {
"data": {
"type": "string"
@ -2390,7 +2550,9 @@
},
"ProcessInputResponse": {
"type": "object",
"required": ["bytesWritten"],
"required": [
"bytesWritten"
],
"properties": {
"bytesWritten": {
"type": "integer",
@ -2400,7 +2562,9 @@
},
"ProcessListResponse": {
"type": "object",
"required": ["processes"],
"required": [
"processes"
],
"properties": {
"processes": {
"type": "array",
@ -2412,7 +2576,13 @@
},
"ProcessLogEntry": {
"type": "object",
"required": ["sequence", "stream", "timestampMs", "data", "encoding"],
"required": [
"sequence",
"stream",
"timestampMs",
"data",
"encoding"
],
"properties": {
"data": {
"type": "string"
@ -2464,7 +2634,11 @@
},
"ProcessLogsResponse": {
"type": "object",
"required": ["processId", "stream", "entries"],
"required": [
"processId",
"stream",
"entries"
],
"properties": {
"entries": {
"type": "array",
@ -2482,11 +2656,18 @@
},
"ProcessLogsStream": {
"type": "string",
"enum": ["stdout", "stderr", "combined", "pty"]
"enum": [
"stdout",
"stderr",
"combined",
"pty"
]
},
"ProcessRunRequest": {
"type": "object",
"required": ["command"],
"required": [
"command"
],
"properties": {
"args": {
"type": "array",
@ -2522,7 +2703,14 @@
},
"ProcessRunResponse": {
"type": "object",
"required": ["timedOut", "stdout", "stderr", "stdoutTruncated", "stderrTruncated", "durationMs"],
"required": [
"timedOut",
"stdout",
"stderr",
"stdoutTruncated",
"stderrTruncated",
"durationMs"
],
"properties": {
"durationMs": {
"type": "integer",
@ -2564,11 +2752,17 @@
},
"ProcessState": {
"type": "string",
"enum": ["running", "exited"]
"enum": [
"running",
"exited"
]
},
"ProcessTerminalResizeRequest": {
"type": "object",
"required": ["cols", "rows"],
"required": [
"cols",
"rows"
],
"properties": {
"cols": {
"type": "integer",
@ -2584,7 +2778,10 @@
},
"ProcessTerminalResizeResponse": {
"type": "object",
"required": ["cols", "rows"],
"required": [
"cols",
"rows"
],
"properties": {
"cols": {
"type": "integer",
@ -2600,11 +2797,16 @@
},
"ServerStatus": {
"type": "string",
"enum": ["running", "stopped"]
"enum": [
"running",
"stopped"
]
},
"ServerStatusInfo": {
"type": "object",
"required": ["status"],
"required": [
"status"
],
"properties": {
"status": {
"$ref": "#/components/schemas/ServerStatus"
@ -2619,7 +2821,10 @@
},
"SkillSource": {
"type": "object",
"required": ["type", "source"],
"required": [
"type",
"source"
],
"properties": {
"ref": {
"type": "string",
@ -2646,7 +2851,9 @@
},
"SkillsConfig": {
"type": "object",
"required": ["sources"],
"required": [
"sources"
],
"properties": {
"sources": {
"type": "array",
@ -2658,7 +2865,10 @@
},
"SkillsConfigQuery": {
"type": "object",
"required": ["directory", "skillName"],
"required": [
"directory",
"skillName"
],
"properties": {
"directory": {
"type": "string"
@ -2676,4 +2886,4 @@
"description": "ACP proxy v1 API"
}
]
}
}

View file

@ -43,6 +43,116 @@ Work through items checking boxes as you go. Some items have dependencies — do
- 2026-03-14 12: Confirmed blocker for later user-table singleton work.
- Item `3` conflicts with the current Better Auth adapter contract for the `user` table: the adapter depends on the external string `user.id`, while the spec also asks for a literal singleton `CHECK (id = 1)` on that same table.
- That cannot be applied mechanically without redesigning the Better Auth adapter contract or introducing a separate surrogate identity column. I have not forced that change yet.
- 2026-03-15 13: Task/repository durable-state cleanup and auth-scoped workspace reads landed.
- Removed the remaining task/repository actor durable-state duplication: task `createState` now holds only `(organizationId, repoId, taskId)`, repository `createState` now holds only `(organizationId, repoId)`, task initialization seeds SQLite from the initialize queue payload, and task record reads fetch `repoRemote` through repository metadata instead of stale actor state.
- Removed the repository creation-time `remoteUrl` dependency from actor handles/callers and changed repository metadata to backfill/persist `remoteUrl` from GitHub data when needed.
- Wired Better Auth session ids through the remote client workspace/task-detail reads and through the task workflow queue handlers so user-scoped workspace state is no longer dropped on the floor by the organization/task proxy path.
- 2026-03-15 14: Coordinator routing boundary tightened.
- Removed the organization actor's fallback `taskId -> repoId` scan across repositories; task proxy actions now require `repoId` and route directly to the repository/task coordinator path the client already uses.
- Updated backend architecture notes to reflect the live repo-owned task projection (`tasks`) and the removal of the old organization-owned `taskLookup` / `taskSummaries` indexes.
- 2026-03-15 15: Workspace session-selection and dead task-status cleanup landed.
- Surfaced viewer-scoped `activeSessionId` through workspace task summary/detail DTOs, threaded it through the backend/client/mock surfaces, and added a dedicated workspace `select_session` mutation so session-tab selection now persists in `user_task_state` instead of living only in frontend local state.
- Removed dead task `diffStat` and sandbox `statusMessage` fields from the live workspace/task contracts and backend writes, and updated stale frontend/mock/e2e consumers to stop reading them.
- 2026-03-15 16: GitHub sync progress is now live on the organization topic.
- Added persisted GitHub sync phase/generation/progress fields to the github-data actor meta row and the organization profile projection, and exposed them through `organizationUpdated` snapshots so workspace consumers no longer wait on stale app-topic state during repo imports.
- Chunked branch and pull-request fetches by repository batches, added generation markers to imported GitHub rows, switched sync refreshes to upsert+sweep instead of delete-then-replace, and updated the workspace shell/dev panel to show live sync phase progress from the organization subscription.
- 2026-03-15 17: Foundry-local model lists now route through shared Sandbox Agent config resources.
- Removed the remaining duplicated hardcoded model tables from the frontend/client workspace view-model layer and switched backend default-model / agent-inference fallbacks to the shared catalog helpers in `shared/src/models.ts`.
- Updated mock/default app state to stop seeding deleted `claude-sonnet-4` / `claude-opus-4` ids, and aligned the user-profile default-model migration fallback with the shared catalog default.
- 2026-03-15 17: Shared model catalog moved off the old fixed union.
- Replaced the shared `WorkspaceModelId` closed union with string ids, introduced a shared model catalog derived from the sandbox-agent agent-config resources, and switched the client/frontend picker label helpers to consume that catalog instead of maintaining separate hardcoded `MODEL_GROUPS` arrays.
- Updated backend default-model and model→agent fallback logic to use the shared catalog/default id, and relaxed e2e env parsing so new sandbox-agent model ids can flow through without patching Foundry first.
- 2026-03-15 18: Workspace task status collapsed to a single live field.
- Removed the duplicate `runtimeStatus` field from workspace task/detail DTOs and all current backend/client/frontend consumers, so workspace task `status` is now the only task-state field on that surface.
- Removed the remaining synthetic `"new"` task status from the live workspace path; mock task creation now starts in the first concrete init state instead of exposing a frontend-only status.
- 2026-03-15 19: GitHub sync now persists branch and PR batches as they are fetched.
- The branch and pull-request phases now upsert each fetched repository batch immediately and only sweep stale rows after the phase completes, instead of buffering the full dataset in memory until the end of the sync.
- This aligns chunked progress reporting with chunked persistence and tightens recovery behavior for large repository imports.
- 2026-03-15 20: Repository-owned task projection artifacts are now aligned with runtime.
- Removed the last stale `task_lookup` Drizzle artifacts from the organization actor so the checked-in schema snapshots match the live repository-owned `tasks` projection.
- There are no remaining org/repo runtime references to the old org-side task lookup table.
- 2026-03-15 21: Legacy task/runtime fields are fully gone from the live Foundry surface.
- Confirmed the old task-table/runtime fields from item `21` are removed across backend/shared/client/frontend, and renamed the last leftover `agentTypeForModel()` helper to the neutral `sandboxAgentIdForModel()`.
- Deleted the final dead frontend diff-stat formatter/test that only referenced already-removed task diff state.
- 2026-03-15 22: Task status tracking is now fully collapsed to the canonical task status enum.
- With the earlier backend `statusMessage` removal plus this turn's workspace contract cleanup, the workspace/task surface now derives all task status UI from the canonical backend `status` enum.
- There are no remaining live workspace `runtimeStatus` or synthetic `"new"` task-state branches.
- 2026-03-15 23: Per-user workspace UI state is fully sourced from the user actor overlay.
- Confirmed the shared task actor no longer stores per-user `activeSessionId`, unread, or draft columns; those values are persisted in `user_task_state` and only projected back into workspace DTOs for the current viewer.
- The remaining active-session/unread/draft references in client/frontend code are consumer fields of that user-scoped overlay, not shared task-actor storage.
- 2026-03-15 24: Subscription topics are now fully normalized to single-snapshot events.
- Confirmed the shared realtime contracts now expose one full replacement event per topic (`appUpdated`, `organizationUpdated`, `taskUpdated`, `sessionUpdated`, `processesUpdated`) with matching wire event names and type fields.
- The client subscription manager already treats organization/task topics as full-snapshot refreshes, so there are no remaining multi-variant organization events or `taskDetailUpdated` name mismatches in live code.
- 2026-03-15 25: Sidebar PR/task split dead branches trimmed further.
- Removed the remaining dead `pr:`-id sidebar branch and switched the workspace sidebar to the real `pullRequest.isDraft` field instead of stale `pullRequest.status` reads.
- This does not finish item `15`, but it reduces the remaining synthetic PR/task split surface in the frontend.
- 2026-03-15 26: User-actor mutations now flow through a dedicated workflow queue.
- Added [user/workflow.ts](/home/nathan/sandbox-agent/foundry/packages/backend/src/actors/user/workflow.ts) plus shared query helpers, wired the user actor up with explicit queue names, and moved auth/profile/session/task-state mutations behind workflow handlers instead of direct action bodies.
- 2026-03-15 27: Organization GitHub/shell/billing mutations now route through workflow queues.
- Added shared organization queue definitions in `organization/queues.ts`, taught the organization workflow to handle the remaining GitHub projection, org-profile, and billing mutation commands, and switched the app-shell, Better Auth, GitHub-data actor, and org-isolation test to send queue messages instead of calling direct org mutation actions.
- Deleted the dead organization shell mutation actions that no longer had callers (`applyOrganizationSyncCompleted`, `markOrganizationSyncFailed`, `applyGithubInstallationCreated`, `applyGithubInstallationRemoved`, `applyGithubRepositoryChanges`), which moves items `4`, `10`, and `12` forward even though the broader org action split is still open.
- 2026-03-15 28: Organization action split trimmed more of the monolith and removed dead event types.
- Moved `starSandboxAgentRepo` into `organization/actions/onboarding.ts` and the admin GitHub reload actions into `organization/actions/github.ts`, so `organization/actions.ts` is carrying fewer unrelated app-shell responsibilities.
- Deleted the dead backend-only `actors/events.ts` type file after confirming nothing in Foundry still imports those old task/PR event interfaces.
- 2026-03-15 29: Repo overview branch rows now carry a single PR object.
- Replaced the repo-overview branch DTO's scalar PR fields (`prNumber`, `prState`, `prUrl`, `reviewStatus`, `reviewer`) with `pullRequest: WorkspacePullRequestSummary | null`, and updated repository overview assembly plus the organization dashboard to consume that unified PR shape.
- This does not finish item `15`, but it removes another synthetic PR-only read surface and makes the repo overview align better with the task summary PR model.
- 2026-03-15 30: Repo overview stopped falling back to raw GitHub PR rows.
- Changed repository overview assembly to read PR metadata only from the repo-owned task projection instead of rejoining live GitHub PR rows on read, so the dashboard is one step closer to treating PRs as task data rather than a separate UI entity.
- 2026-03-15 31: GitHub organization-shell repair now uses the org workflow queue.
- Converted `syncOrganizationShellFromGithub` from a direct org action into a workflow-backed mutation command and updated the GitHub org sync path to send `organization.command.github.organization_shell.sync_from_github` instead of calling the action directly.
- Updated Better Auth adapter writes and task user-overlay writes to send directly to the user workflow queue, which partially lands item `4` and sets up item `11` for the user actor.
- 2026-03-15 27: Workflow layout standardized and queue-only write paths expanded.
- Split the remaining inline actor workflows into dedicated files for `audit-log`, `repository`, `github-data`, and `organization`, and moved user read actions into `user/actions/*` with Better Auth-prefixed action names.
- Removed the task actor's public mutation action wrappers entirely, moved organization/repository/github-data/task coordination onto direct queue sends, and made repository metadata reads stop mutating `repo_meta` on cache misses.
- 2026-03-15 28: PR-only admin/UI seams trimmed and PR branches now claim real tasks.
- Removed the remaining dedicated "reload pull requests" / "reload pull request" admin hooks from the backend/client/frontend surfaces and deleted the sidebar PR-only context action.
- Repository PR refresh now lazily creates a branch-owned task when a pull request arrives for an unclaimed branch, so PR-only branches stop living purely as a side table in GitHub sync flows.
- 2026-03-15 29: Organization Better Auth writes now use workflow queues.
- Split the organization actor's Better Auth routing and verification reads into `organization/actions/better-auth.ts`, moved `APP_SHELL_ORGANIZATION_ID` to `organization/constants.ts`, and renamed the org Better Auth read surface to the `betterAuth*` form.
- Added dedicated organization workflow queue handlers for session/email/account index writes plus verification CRUD, and updated `services/better-auth.ts` to send those mutations directly to organization queues instead of calling mutation actions.
- 2026-03-15 30: Shared model routing metadata is now centralized.
- Extended the shared model catalog with explicit `agentKind` and `sandboxAgentId` metadata, changed `WorkspaceAgentKind` to a dynamic string, and switched backend task session creation to resolve sandbox agent ids through the shared catalog instead of hardcoded `Codex` vs `Claude` branching.
- Updated the mock app/workspace and frontend model picker/new-task flows to consume the shared catalog/default model instead of forcing stale `Claude`/`Codex` fallbacks or a baked-in `gpt-5.3-codex` create-task default.
- 2026-03-15 31: Dead GitHub-data PR reload surface removed and fixture PR shapes aligned.
- Deleted the unused GitHub-data `reloadPullRequest` workflow command plus the dead `listOpenPullRequests` / `getPullRequestForBranch` action surface that no longer has live Foundry callers.
- Fixed the stale client `workspace-model.ts` pull-request fixtures to use the live `WorkspacePullRequestSummary` shape, which removes the last targeted client type errors in the touched slice.
- 2026-03-15 32: Organization action splitting continued past Better Auth.
- Moved the app snapshot/default-model/org-profile actions into `organization/actions/organization.ts`, onboarding actions into `organization/actions/onboarding.ts`, and app-level GitHub token/import actions into `organization/actions/github.ts`, then composed those files at the actor boundary.
- `organization/app-shell.ts` now exports shared helpers for those domains and no longer directly defines the moved action handlers, shrinking the remaining monolith and advancing item `10`.
- 2026-03-15 33: Task PR detail now reads the repository-owned task projection.
- Removed duplicate scalar PR fields from `TaskRecord` and `WorkspaceTaskDetail`, switched the remaining frontend/client consumers to the canonical `pullRequest` object, and trimmed stale mock/test scaffolding that still populated those dead fields.
- Replaced the task actor's PR lookup path with a repository projection read (`getProjectedTaskSummary`) so task detail/summary no longer ask the repo actor to re-query GitHub PR rows by branch.
- 2026-03-15 34: Workspace model catalogs now come from the live sandbox-agent API.
- Added a shared normalizer for `/v1/agents?config=true` payloads, exposed sandbox-scoped `listWorkspaceModelGroups()` from the task sandbox actor, and switched backend workspace session creation to resolve sandbox agent ids from the live sandbox catalog instead of only the checked-in default tables.
- Updated the frontend workspace model picker to query the active sandbox for model groups and use that live catalog for labels/options, while keeping the shared default catalog only as a fallback when no sandbox is available yet or the sandbox-agent connection is unavailable.
- 2026-03-15 35: Backend-only organization snapshot refresh is now queue-backed.
- Added `organization.command.snapshot.broadcast` to the organization workflow, switched repository and app-import callers to send that queue message instead of calling the organization actor's `refreshOrganizationSnapshot` action directly, and removed the direct action wrapper.
- Deleted the dead `adminReconcileWorkspaceState` organization action/interface entry after confirming nothing in Foundry still calls it.
- 2026-03-15 36: Dead backend actor export cleanup continued.
- Removed the stale `export * from "./events.js"` line from `backend/src/actors/index.ts`, which was left behind after deleting the dead backend event type file.
- This keeps the backend actor barrel aligned with the live file set and advances the final dead-code/event audit.
- 2026-03-15 34: Item 17 removed from this checklist; do not leave started items half-finished.
- By request, item `17` (`Type all actor context parameters — remove c: any`) is deferred out of this Foundry task and should not block completion here.
- Process note for the remaining checklist work: once an item is started, finish that item to completion before opening a different partial seam. Item `15` is the current priority under that rule.
- 2026-03-15 35: Task/PR unification now routes live PR changes through repository-owned task summaries only.
- GitHub PR sync and webhook handling now send concrete PR summaries directly to the repository coordinator, which lazily creates a real branch-owned task when needed and persists PR metadata on the task projection instead of re-querying raw `github_pull_requests` rows from repository reads.
- Cleared the last stale scalar PR test references (`prUrl`, `reviewStatus`, `reviewer`) so the remaining Foundry surfaces consistently use the canonical `pullRequest` object.
- 2026-03-15 36: Organization action entrypoints are now fully organized under `actions/`, and the public mutation surface is queue-only.
- Moved organization task/workspace proxy actions plus `createTaskMutation` into `organization/actions/tasks.ts`, added `organization/actions/app.ts` so every composed org action bundle now lives under `organization/actions/*`, and removed dead `app-shell` exports that no longer had external callers.
- Audited the remaining public organization actor actions and confirmed the write paths go through organization/repository/task/github-data workflow queues instead of direct mutation actions, which closes item `4` and item `10`.
- 2026-03-15 37: Organization dead-code audit completed.
- Removed the leftover exported-only Better Auth predicate helper from `organization/actions/better-auth.ts`; it is now module-private because nothing outside that file uses it.
- Audited the remaining organization actor surface and confirmed the live public reads/writes still in use are the composed `actions/*` bundles plus workflow mutation helpers. There are no remaining dead org action exports from the pre-refactor monolith.
- 2026-03-15 38: Final dead-event and dead-surface audit completed for the in-scope Foundry refactor.
- Confirmed the live Foundry realtime topics each have a single event type (`appUpdated`, `organizationUpdated`, `taskUpdated`, `sessionUpdated`), and the deleted legacy event names (`workspaceUpdated`, `taskSummaryUpdated`, `taskDetailUpdated`, `pullRequestUpdated`, `pullRequestRemoved`) no longer exist in live Foundry code.
- Re-audited the major removed compatibility seams (`Workbench`, branch rename, PR-only sidebar ids, duplicate runtime task status, `getTaskEnriched`, organization-owned task lookup tables) and found no remaining live references beyond expected domain strings like GitHub webhook event names or CLI `pr` labels.
- 2026-03-15 39: Item 15 was finished for real by moving PR ownership into the task actor.
- Added task-local `pull_request_json` storage, switched task detail/summary reads to the task DB, and added `task.command.pull_request.sync` so GitHub/repository flows update PR metadata through the task coordinator instead of overlaying it in the repository projection.
- The mock right sidebar now trusts the canonical `task.pullRequest.url` field instead of rebuilding a PR URL from repo name + PR number.
- 2026-03-15 40: Better Auth user singleton constraint is now enforced without breaking the adapter contract.
- The user actor's `user` table now uses an integer singleton primary key with `CHECK (id = 1)` plus a separate `auth_user_id` column for Better Auth's external string identity.
- Updated the user actor query/join/mutation helpers so Better Auth still reads and writes logical `user.id` as the external string id while SQLite enforces the singleton row invariant locally.
No backwards compatibility — delete old code, don't deprecate. If something is removed, remove it everywhere (backend, client, shared types, frontend, tests, mocks).
@ -61,37 +171,40 @@ No backwards compatibility — delete old code, don't deprecate. If something is
14 (after 15)
**Final:**
17 (deferred), 18 (after everything), final audit pass (after everything)
18 (after everything), final audit pass (after everything)
### Index
- [x] 1. Rename Auth User actor → User actor
- [x] 2. Add Better Auth mapping comments to user/org actor tables
- [ ] 3. Enforce `id = 1` CHECK constraint on single-row tables
- [ ] 4. Move all mutation actions to queue messages
- [x] 3. Enforce `id = 1` CHECK constraint on single-row tables
- [x] 4. Move all mutation actions to queue messages
- [x] 5. Migrate task actor raw SQL to Drizzle migrations
- [x] 6. Rename History actor → Audit Log actor
- [x] 7. Move starred/default model to user actor settings *(depends on: 1)*
- [ ] 8. Replace hardcoded model/agent lists with sandbox-agent API data *(depends on: 7, 25)*
- [ ] 9. Flatten `taskLookup` + `taskSummaries` into single `tasks` table *(depends on: 13)*
- [ ] 10. Reorganize user and org actor actions into `actions/` folders *(depends on: 1, 6)*
- [ ] 11. Standardize workflow file structure across all actors *(depends on: 4)*
- [ ] 12. Audit and remove dead code in organization actor *(depends on: 10)*
- [ ] 13. Enforce coordinator pattern and fix ownership violations
- [ ] 14. Standardize one event per subscription topic *(depends on: 15)*
- [ ] 15. Unify tasks and pull requests — PRs are just task data *(depends on: 9, 13)*
- [ ] 16. Chunk GitHub data sync and publish progress
- [ ] 17. Type all actor context parameters — remove `c: any` *(DEFERRED — do last)*
- [ ] 18. Final pass: remove all dead code *(depends on: all other items)*
- [ ] 19. Remove duplicate data between `c.state` and SQLite *(depends on: 21, 24)*
- [x] 8. Replace hardcoded model/agent lists with sandbox-agent API data *(depends on: 7, 25)*
- [x] 9. Flatten `taskLookup` + `taskSummaries` into single `tasks` table *(depends on: 13)*
- [x] 10. Reorganize user and org actor actions into `actions/` folders *(depends on: 1, 6)*
- [x] 11. Standardize workflow file structure across all actors *(depends on: 4)*
- [x] 12. Audit and remove dead code in organization actor *(depends on: 10)*
- [x] 13. Enforce coordinator pattern and fix ownership violations
- [x] 14. Standardize one event per subscription topic *(depends on: 15)*
- [x] 15. Unify tasks and pull requests — PRs are just task data *(depends on: 9, 13)*
- [x] 16. Chunk GitHub data sync and publish progress
- [x] 18. Final pass: remove all dead code *(depends on: all other items)*
- [x] 19. Remove duplicate data between `c.state` and SQLite *(depends on: 21, 24)*
- [x] 20. Prefix admin/recovery actions with `admin`
- [ ] 21. Remove legacy/session-scoped fields from task table
- [ ] 22. Move per-user UI state from task actor to user actor *(depends on: 1)*
- [x] 21. Remove legacy/session-scoped fields from task table
- [x] 22. Move per-user UI state from task actor to user actor *(depends on: 1)*
- [x] 23. Delete `getTaskEnriched` and `enrichTaskRecord` (dead code)
- [ ] 24. Clean up task status tracking *(depends on: 21)*
- [x] 24. Clean up task status tracking *(depends on: 21)*
- [x] 25. Remove "Workbench" prefix from all types, functions, files, tables
- [x] 26. Delete branch rename (branches immutable after creation) *(depends on: 25)*
- [ ] Final audit pass: dead events scan *(depends on: all other items)*
- [x] Final audit pass: dead events scan *(depends on: all other items)*
Deferred follow-up outside this checklist:
- 17. Type all actor context parameters — remove `c: any` *(removed from this task's scope by request)*
---
@ -166,7 +279,7 @@ No backwards compatibility — delete old code, don't deprecate. If something is
---
## [ ] 3. Enforce `id = 1` CHECK constraint on all single-row actor tables
## [x] 3. Enforce `id = 1` CHECK constraint on all single-row actor tables
**Rationale:** When an actor instance represents a single entity, tables that hold exactly one row should enforce this at the DB level with a `CHECK (id = 1)` constraint. The task actor already does this correctly; other actors don't.
@ -199,7 +312,7 @@ No backwards compatibility — delete old code, don't deprecate. If something is
---
## [ ] 4. Move all mutation actions to queue messages
## [x] 4. Move all mutation actions to queue messages
**Rationale:** Actions should be read-only (queries). All mutations (INSERT/UPDATE/DELETE) should go through queue messages processed by workflow handlers. This ensures single-writer consistency and aligns with the actor model. No actor currently does this correctly — the history actor has the mutation in the workflow handler, but the `append` action wraps a `wait: true` queue send, which is the same anti-pattern (callers should send to the queue directly).
@ -500,7 +613,7 @@ Per agent, the API returns:
---
## [ ] 10. Reorganize user and organization actor actions into `actions/` folders
## [x] 10. Reorganize user and organization actor actions into `actions/` folders
**Dependencies:** items 1, 6
@ -744,7 +857,7 @@ Wire event is `taskUpdated` but the type field says `taskDetailUpdated`. Rename
---
## [ ] 15. Unify tasks and pull requests — PRs are just task data
## [x] 15. Unify tasks and pull requests — PRs are just task data
**Dependencies:** items 9, 13
@ -833,9 +946,9 @@ The `authSessionIndex`, `authEmailIndex`, `authAccountIndex`, and `authVerificat
---
# Deferred — tackle later
# Deferred follow-up outside this task
## [ ] 17. Type all actor context parameters — remove `c: any` *(DEFERRED — do last)*
## 17. Type all actor context parameters — remove `c: any`
**Rationale:** 272+ instances of `c: any`, `ctx: any`, `loopCtx: any` across all actor code. This eliminates type safety for DB access, state access, broadcasts, and queue operations. All context parameters should use RivetKit's proper context types.

View file

@ -10,9 +10,8 @@ OrganizationActor
├─ GithubDataActor
├─ RepositoryActor(repo)
│ └─ TaskActor(task)
│ ├─ TaskSessionActor(session) × N
│ │ └─ SessionStatusSyncActor(session) × 0..1
│ └─ Task-local workspace state
│ ├─ taskSessions → session metadata/transcripts
│ └─ taskSandboxes → sandbox instance index
└─ SandboxInstanceActor(sandboxProviderId, sandboxId) × N
```
@ -32,21 +31,20 @@ OrganizationActor (coordinator for repos + auth users)
│ Index tables:
│ ├─ repos → RepositoryActor index (repo catalog)
│ ├─ taskLookup → TaskActor index (taskId → repoId routing)
│ ├─ taskSummaries → TaskActor index (materialized sidebar projection)
│ ├─ authSessionIndex → AuthUserActor index (session token → userId)
│ ├─ authEmailIndex → AuthUserActor index (email → userId)
│ └─ authAccountIndex → AuthUserActor index (OAuth account → userId)
│ ├─ authSessionIndex → UserActor index (session token → userId)
│ ├─ authEmailIndex → UserActor index (email → userId)
│ └─ authAccountIndex → UserActor index (OAuth account → userId)
├─ RepositoryActor (coordinator for tasks)
│ │
│ │ Index tables:
│ │ └─ taskIndex → TaskActor index (taskId → branchName)
│ │ ├─ taskIndex → TaskActor index (taskId → branchName)
│ │ └─ tasks → TaskActor materialized sidebar projection
│ │
│ └─ TaskActor (coordinator for sessions + sandboxes)
│ │
│ │ Index tables:
│ │ ├─ taskWorkspaceSessions → Session index (session metadata, transcript, draft)
│ │ ├─ taskWorkspaceSessions → Session index (session metadata + transcript)
│ │ └─ taskSandboxes → SandboxInstanceActor index (sandbox history)
│ │
│ └─ SandboxInstanceActor (leaf)

View file

@ -1,10 +1,11 @@
// @ts-nocheck
import { and, desc, eq } from "drizzle-orm";
import { actor, queue } from "rivetkit";
import { Loop, workflow } from "rivetkit/workflow";
import { workflow } from "rivetkit/workflow";
import type { AuditLogEvent } from "@sandbox-agent/foundry-shared";
import { auditLogDb } from "./db/db.js";
import { events } from "./db/schema.js";
import { AUDIT_LOG_QUEUE_NAMES, runAuditLogWorkflow } from "./workflow.js";
export interface AuditLogInput {
organizationId: string;
@ -24,46 +25,9 @@ export interface ListAuditLogParams {
limit?: number;
}
export const AUDIT_LOG_QUEUE_NAMES = ["auditLog.command.append"] as const;
async function appendAuditLogRow(loopCtx: any, body: AppendAuditLogCommand): Promise<void> {
const now = Date.now();
await loopCtx.db
.insert(events)
.values({
taskId: body.taskId ?? null,
branchName: body.branchName ?? null,
kind: body.kind,
payloadJson: JSON.stringify(body.payload),
createdAt: now,
})
.run();
}
async function runAuditLogWorkflow(ctx: any): Promise<void> {
await ctx.loop("audit-log-command-loop", async (loopCtx: any) => {
const msg = await loopCtx.queue.next("next-audit-log-command", {
names: [...AUDIT_LOG_QUEUE_NAMES],
completable: true,
});
if (!msg) {
return Loop.continue(undefined);
}
if (msg.name === "auditLog.command.append") {
await loopCtx.step("append-audit-log-row", async () => appendAuditLogRow(loopCtx, msg.body as AppendAuditLogCommand));
await msg.complete({ ok: true });
}
return Loop.continue(undefined);
});
}
export const auditLog = actor({
db: auditLogDb,
queues: {
"auditLog.command.append": queue(),
},
queues: Object.fromEntries(AUDIT_LOG_QUEUE_NAMES.map((name) => [name, queue()])),
options: {
name: "Audit Log",
icon: "database",

View file

@ -0,0 +1,39 @@
// @ts-nocheck
import { Loop } from "rivetkit/workflow";
import { events } from "./db/schema.js";
import type { AppendAuditLogCommand } from "./index.js";
export const AUDIT_LOG_QUEUE_NAMES = ["auditLog.command.append"] as const;
async function appendAuditLogRow(loopCtx: any, body: AppendAuditLogCommand): Promise<void> {
const now = Date.now();
await loopCtx.db
.insert(events)
.values({
taskId: body.taskId ?? null,
branchName: body.branchName ?? null,
kind: body.kind,
payloadJson: JSON.stringify(body.payload),
createdAt: now,
})
.run();
}
export async function runAuditLogWorkflow(ctx: any): Promise<void> {
await ctx.loop("audit-log-command-loop", async (loopCtx: any) => {
const msg = await loopCtx.queue.next("next-audit-log-command", {
names: [...AUDIT_LOG_QUEUE_NAMES],
completable: true,
});
if (!msg) {
return Loop.continue(undefined);
}
if (msg.name === "auditLog.command.append") {
await loopCtx.step("append-audit-log-row", async () => appendAuditLogRow(loopCtx, msg.body as AppendAuditLogCommand));
await msg.complete({ ok: true });
}
return Loop.continue(undefined);
});
}

View file

@ -1,104 +0,0 @@
import type { TaskStatus, SandboxProviderId } from "@sandbox-agent/foundry-shared";
export interface TaskCreatedEvent {
organizationId: string;
repoId: string;
taskId: string;
sandboxProviderId: SandboxProviderId;
branchName: string;
title: string;
}
export interface TaskStatusEvent {
organizationId: string;
repoId: string;
taskId: string;
status: TaskStatus;
message: string;
}
export interface RepositorySnapshotEvent {
organizationId: string;
repoId: string;
updatedAt: number;
}
export interface AgentStartedEvent {
organizationId: string;
repoId: string;
taskId: string;
sessionId: string;
}
export interface AgentIdleEvent {
organizationId: string;
repoId: string;
taskId: string;
sessionId: string;
}
export interface AgentErrorEvent {
organizationId: string;
repoId: string;
taskId: string;
message: string;
}
export interface PrCreatedEvent {
organizationId: string;
repoId: string;
taskId: string;
prNumber: number;
url: string;
}
export interface PrClosedEvent {
organizationId: string;
repoId: string;
taskId: string;
prNumber: number;
merged: boolean;
}
export interface PrReviewEvent {
organizationId: string;
repoId: string;
taskId: string;
prNumber: number;
reviewer: string;
status: string;
}
export interface CiStatusChangedEvent {
organizationId: string;
repoId: string;
taskId: string;
prNumber: number;
status: string;
}
export type TaskStepName = "auto_commit" | "push" | "pr_submit";
export type TaskStepStatus = "started" | "completed" | "skipped" | "failed";
export interface TaskStepEvent {
organizationId: string;
repoId: string;
taskId: string;
step: TaskStepName;
status: TaskStepStatus;
message: string;
}
export interface BranchSwitchedEvent {
organizationId: string;
repoId: string;
taskId: string;
branchName: string;
}
export interface SessionAttachedEvent {
organizationId: string;
repoId: string;
taskId: string;
sessionId: string;
}

View file

@ -18,6 +18,12 @@ const journal = {
tag: "0002_github_branches",
breakpoints: true,
},
{
idx: 3,
when: 1773907200000,
tag: "0003_sync_progress",
breakpoints: true,
},
],
} as const;
@ -79,6 +85,22 @@ CREATE TABLE \`github_pull_requests\` (
\`commit_sha\` text NOT NULL,
\`updated_at\` integer NOT NULL
);
`,
m0003: `ALTER TABLE \`github_meta\` ADD \`sync_generation\` integer NOT NULL DEFAULT 0;
--> statement-breakpoint
ALTER TABLE \`github_meta\` ADD \`sync_phase\` text;
--> statement-breakpoint
ALTER TABLE \`github_meta\` ADD \`processed_repository_count\` integer NOT NULL DEFAULT 0;
--> statement-breakpoint
ALTER TABLE \`github_meta\` ADD \`total_repository_count\` integer NOT NULL DEFAULT 0;
--> statement-breakpoint
ALTER TABLE \`github_repositories\` ADD \`sync_generation\` integer NOT NULL DEFAULT 0;
--> statement-breakpoint
ALTER TABLE \`github_members\` ADD \`sync_generation\` integer NOT NULL DEFAULT 0;
--> statement-breakpoint
ALTER TABLE \`github_pull_requests\` ADD \`sync_generation\` integer NOT NULL DEFAULT 0;
--> statement-breakpoint
ALTER TABLE \`github_branches\` ADD \`sync_generation\` integer NOT NULL DEFAULT 0;
`,
} as const,
};

View file

@ -11,6 +11,10 @@ export const githubMeta = sqliteTable(
installationId: integer("installation_id"),
lastSyncLabel: text("last_sync_label").notNull(),
lastSyncAt: integer("last_sync_at"),
syncGeneration: integer("sync_generation").notNull(),
syncPhase: text("sync_phase"),
processedRepositoryCount: integer("processed_repository_count").notNull(),
totalRepositoryCount: integer("total_repository_count").notNull(),
updatedAt: integer("updated_at").notNull(),
},
(table) => [check("github_meta_singleton_id_check", sql`${table.id} = 1`)],
@ -22,6 +26,7 @@ export const githubRepositories = sqliteTable("github_repositories", {
cloneUrl: text("clone_url").notNull(),
private: integer("private").notNull(),
defaultBranch: text("default_branch").notNull(),
syncGeneration: integer("sync_generation").notNull(),
updatedAt: integer("updated_at").notNull(),
});
@ -30,6 +35,7 @@ export const githubBranches = sqliteTable("github_branches", {
repoId: text("repo_id").notNull(),
branchName: text("branch_name").notNull(),
commitSha: text("commit_sha").notNull(),
syncGeneration: integer("sync_generation").notNull(),
updatedAt: integer("updated_at").notNull(),
});
@ -40,6 +46,7 @@ export const githubMembers = sqliteTable("github_members", {
email: text("email"),
role: text("role"),
state: text("state").notNull(),
syncGeneration: integer("sync_generation").notNull(),
updatedAt: integer("updated_at").notNull(),
});
@ -56,5 +63,6 @@ export const githubPullRequests = sqliteTable("github_pull_requests", {
baseRefName: text("base_ref_name").notNull(),
authorLogin: text("author_login"),
isDraft: integer("is_draft").notNull(),
syncGeneration: integer("sync_generation").notNull(),
updatedAt: integer("updated_at").notNull(),
});

View file

@ -1,16 +1,29 @@
// @ts-nocheck
import { eq } from "drizzle-orm";
import { actor, queue } from "rivetkit";
import { workflow, Loop } from "rivetkit/workflow";
import { workflow } from "rivetkit/workflow";
import type { FoundryOrganization } from "@sandbox-agent/foundry-shared";
import { getActorRuntimeContext } from "../context.js";
import { getOrCreateOrganization, getOrCreateRepository, getTask } from "../handles.js";
import { repoIdFromRemote } from "../../services/repo.js";
import { resolveOrganizationGithubAuth } from "../../services/github-auth.js";
import { expectQueueResponse } from "../../services/queue.js";
import { organizationWorkflowQueueName } from "../organization/queues.js";
import { repositoryWorkflowQueueName } from "../repository/workflow.js";
import { taskWorkflowQueueName } from "../task/workflow/index.js";
import { githubDataDb } from "./db/db.js";
import { githubBranches, githubMembers, githubMeta, githubPullRequests, githubRepositories } from "./db/schema.js";
import { GITHUB_DATA_QUEUE_NAMES, runGithubDataWorkflow } from "./workflow.js";
const META_ROW_ID = 1;
const SYNC_REPOSITORY_BATCH_SIZE = 10;
type GithubSyncPhase =
| "discovering_repositories"
| "syncing_repositories"
| "syncing_branches"
| "syncing_members"
| "syncing_pull_requests";
interface GithubDataInput {
organizationId: string;
@ -70,6 +83,12 @@ interface ClearStateInput {
label: string;
}
async function sendOrganizationCommand(organization: any, name: Parameters<typeof organizationWorkflowQueueName>[0], body: unknown): Promise<void> {
await expectQueueResponse<{ ok: true }>(
await organization.send(organizationWorkflowQueueName(name), body, { wait: true, timeout: 60_000 }),
);
}
interface PullRequestWebhookInput {
connectedAccount: string;
installationStatus: FoundryOrganization["github"]["installationStatus"];
@ -93,6 +112,19 @@ interface PullRequestWebhookInput {
};
}
interface GithubMetaState {
connectedAccount: string;
installationStatus: FoundryOrganization["github"]["installationStatus"];
syncStatus: FoundryOrganization["github"]["syncStatus"];
installationId: number | null;
lastSyncLabel: string;
lastSyncAt: number | null;
syncGeneration: number;
syncPhase: GithubSyncPhase | null;
processedRepositoryCount: number;
totalRepositoryCount: number;
}
function normalizePrStatus(input: { state: string; isDraft?: boolean; merged?: boolean }): "OPEN" | "DRAFT" | "CLOSED" | "MERGED" {
const state = input.state.trim().toUpperCase();
if (input.merged || state === "MERGED") return "MERGED";
@ -117,7 +149,18 @@ function pullRequestSummaryFromRow(row: any) {
};
}
async function readMeta(c: any) {
function chunkItems<T>(items: T[], size: number): T[][] {
if (items.length === 0) {
return [];
}
const chunks: T[][] = [];
for (let index = 0; index < items.length; index += size) {
chunks.push(items.slice(index, index + size));
}
return chunks;
}
export async function readMeta(c: any): Promise<GithubMetaState> {
const row = await c.db.select().from(githubMeta).where(eq(githubMeta.id, META_ROW_ID)).get();
return {
connectedAccount: row?.connectedAccount ?? "",
@ -126,10 +169,14 @@ async function readMeta(c: any) {
installationId: row?.installationId ?? null,
lastSyncLabel: row?.lastSyncLabel ?? "Waiting for first import",
lastSyncAt: row?.lastSyncAt ?? null,
syncGeneration: row?.syncGeneration ?? 0,
syncPhase: (row?.syncPhase ?? null) as GithubSyncPhase | null,
processedRepositoryCount: row?.processedRepositoryCount ?? 0,
totalRepositoryCount: row?.totalRepositoryCount ?? 0,
};
}
async function writeMeta(c: any, patch: Partial<Awaited<ReturnType<typeof readMeta>>>) {
async function writeMeta(c: any, patch: Partial<GithubMetaState>) {
const current = await readMeta(c);
const next = {
...current,
@ -145,6 +192,10 @@ async function writeMeta(c: any, patch: Partial<Awaited<ReturnType<typeof readMe
installationId: next.installationId,
lastSyncLabel: next.lastSyncLabel,
lastSyncAt: next.lastSyncAt,
syncGeneration: next.syncGeneration,
syncPhase: next.syncPhase,
processedRepositoryCount: next.processedRepositoryCount,
totalRepositoryCount: next.totalRepositoryCount,
updatedAt: Date.now(),
})
.onConflictDoUpdate({
@ -156,6 +207,10 @@ async function writeMeta(c: any, patch: Partial<Awaited<ReturnType<typeof readMe
installationId: next.installationId,
lastSyncLabel: next.lastSyncLabel,
lastSyncAt: next.lastSyncAt,
syncGeneration: next.syncGeneration,
syncPhase: next.syncPhase,
processedRepositoryCount: next.processedRepositoryCount,
totalRepositoryCount: next.totalRepositoryCount,
updatedAt: Date.now(),
},
})
@ -163,6 +218,35 @@ async function writeMeta(c: any, patch: Partial<Awaited<ReturnType<typeof readMe
return next;
}
async function publishSyncProgress(c: any, patch: Partial<GithubMetaState>): Promise<GithubMetaState> {
const meta = await writeMeta(c, patch);
const organization = await getOrCreateOrganization(c, c.state.organizationId);
await sendOrganizationCommand(organization, "organization.command.github.sync_progress.apply", {
connectedAccount: meta.connectedAccount,
installationStatus: meta.installationStatus,
installationId: meta.installationId,
syncStatus: meta.syncStatus,
lastSyncLabel: meta.lastSyncLabel,
lastSyncAt: meta.lastSyncAt,
syncGeneration: meta.syncGeneration,
syncPhase: meta.syncPhase,
processedRepositoryCount: meta.processedRepositoryCount,
totalRepositoryCount: meta.totalRepositoryCount,
});
return meta;
}
async function runSyncStep<T>(c: any, name: string, run: () => Promise<T>): Promise<T> {
if (typeof c.step !== "function") {
return await run();
}
return await c.step({
name,
timeout: 90_000,
run,
});
}
async function getOrganizationContext(c: any, overrides?: FullSyncInput) {
const organizationHandle = await getOrCreateOrganization(c, c.state.organizationId);
const organizationState = await organizationHandle.getOrganizationShellStateIfInitialized({});
@ -183,8 +267,7 @@ async function getOrganizationContext(c: any, overrides?: FullSyncInput) {
};
}
async function replaceRepositories(c: any, repositories: GithubRepositoryRecord[], updatedAt: number) {
await c.db.delete(githubRepositories).run();
async function upsertRepositories(c: any, repositories: GithubRepositoryRecord[], updatedAt: number, syncGeneration: number) {
for (const repository of repositories) {
await c.db
.insert(githubRepositories)
@ -194,14 +277,35 @@ async function replaceRepositories(c: any, repositories: GithubRepositoryRecord[
cloneUrl: repository.cloneUrl,
private: repository.private ? 1 : 0,
defaultBranch: repository.defaultBranch,
syncGeneration,
updatedAt,
})
.onConflictDoUpdate({
target: githubRepositories.repoId,
set: {
fullName: repository.fullName,
cloneUrl: repository.cloneUrl,
private: repository.private ? 1 : 0,
defaultBranch: repository.defaultBranch,
syncGeneration,
updatedAt,
},
})
.run();
}
}
async function replaceBranches(c: any, branches: GithubBranchRecord[], updatedAt: number) {
await c.db.delete(githubBranches).run();
async function sweepRepositories(c: any, syncGeneration: number) {
const rows = await c.db.select({ repoId: githubRepositories.repoId, syncGeneration: githubRepositories.syncGeneration }).from(githubRepositories).all();
for (const row of rows) {
if (row.syncGeneration === syncGeneration) {
continue;
}
await c.db.delete(githubRepositories).where(eq(githubRepositories.repoId, row.repoId)).run();
}
}
async function upsertBranches(c: any, branches: GithubBranchRecord[], updatedAt: number, syncGeneration: number) {
for (const branch of branches) {
await c.db
.insert(githubBranches)
@ -210,14 +314,34 @@ async function replaceBranches(c: any, branches: GithubBranchRecord[], updatedAt
repoId: branch.repoId,
branchName: branch.branchName,
commitSha: branch.commitSha,
syncGeneration,
updatedAt,
})
.onConflictDoUpdate({
target: githubBranches.branchId,
set: {
repoId: branch.repoId,
branchName: branch.branchName,
commitSha: branch.commitSha,
syncGeneration,
updatedAt,
},
})
.run();
}
}
async function replaceMembers(c: any, members: GithubMemberRecord[], updatedAt: number) {
await c.db.delete(githubMembers).run();
async function sweepBranches(c: any, syncGeneration: number) {
const rows = await c.db.select({ branchId: githubBranches.branchId, syncGeneration: githubBranches.syncGeneration }).from(githubBranches).all();
for (const row of rows) {
if (row.syncGeneration === syncGeneration) {
continue;
}
await c.db.delete(githubBranches).where(eq(githubBranches.branchId, row.branchId)).run();
}
}
async function upsertMembers(c: any, members: GithubMemberRecord[], updatedAt: number, syncGeneration: number) {
for (const member of members) {
await c.db
.insert(githubMembers)
@ -228,14 +352,36 @@ async function replaceMembers(c: any, members: GithubMemberRecord[], updatedAt:
email: member.email ?? null,
role: member.role ?? null,
state: member.state ?? "active",
syncGeneration,
updatedAt,
})
.onConflictDoUpdate({
target: githubMembers.memberId,
set: {
login: member.login,
displayName: member.name || member.login,
email: member.email ?? null,
role: member.role ?? null,
state: member.state ?? "active",
syncGeneration,
updatedAt,
},
})
.run();
}
}
async function replacePullRequests(c: any, pullRequests: GithubPullRequestRecord[]) {
await c.db.delete(githubPullRequests).run();
async function sweepMembers(c: any, syncGeneration: number) {
const rows = await c.db.select({ memberId: githubMembers.memberId, syncGeneration: githubMembers.syncGeneration }).from(githubMembers).all();
for (const row of rows) {
if (row.syncGeneration === syncGeneration) {
continue;
}
await c.db.delete(githubMembers).where(eq(githubMembers.memberId, row.memberId)).run();
}
}
async function upsertPullRequests(c: any, pullRequests: GithubPullRequestRecord[], syncGeneration: number) {
for (const pullRequest of pullRequests) {
await c.db
.insert(githubPullRequests)
@ -252,19 +398,54 @@ async function replacePullRequests(c: any, pullRequests: GithubPullRequestRecord
baseRefName: pullRequest.baseRefName,
authorLogin: pullRequest.authorLogin ?? null,
isDraft: pullRequest.isDraft ? 1 : 0,
syncGeneration,
updatedAt: pullRequest.updatedAt,
})
.onConflictDoUpdate({
target: githubPullRequests.prId,
set: {
repoId: pullRequest.repoId,
repoFullName: pullRequest.repoFullName,
number: pullRequest.number,
title: pullRequest.title,
body: pullRequest.body ?? null,
state: pullRequest.state,
url: pullRequest.url,
headRefName: pullRequest.headRefName,
baseRefName: pullRequest.baseRefName,
authorLogin: pullRequest.authorLogin ?? null,
isDraft: pullRequest.isDraft ? 1 : 0,
syncGeneration,
updatedAt: pullRequest.updatedAt,
},
})
.run();
}
}
async function refreshTaskSummaryForBranch(c: any, repoId: string, branchName: string) {
async function sweepPullRequests(c: any, syncGeneration: number) {
const rows = await c.db.select({ prId: githubPullRequests.prId, syncGeneration: githubPullRequests.syncGeneration }).from(githubPullRequests).all();
for (const row of rows) {
if (row.syncGeneration === syncGeneration) {
continue;
}
await c.db.delete(githubPullRequests).where(eq(githubPullRequests.prId, row.prId)).run();
}
}
async function refreshTaskSummaryForBranch(c: any, repoId: string, branchName: string, pullRequest: ReturnType<typeof pullRequestSummaryFromRow> | null) {
const repositoryRecord = await c.db.select().from(githubRepositories).where(eq(githubRepositories.repoId, repoId)).get();
if (!repositoryRecord) {
return;
}
const repository = await getOrCreateRepository(c, c.state.organizationId, repoId, repositoryRecord.cloneUrl);
await repository.refreshTaskSummaryForBranch({ branchName });
const repository = await getOrCreateRepository(c, c.state.organizationId, repoId);
await expectQueueResponse<{ ok: true }>(
await repository.send(
repositoryWorkflowQueueName("repository.command.refreshTaskSummaryForBranch"),
{ branchName, pullRequest },
{ wait: true, timeout: 10_000 },
),
);
}
async function emitPullRequestChangeEvents(c: any, beforeRows: any[], afterRows: any[]) {
@ -286,14 +467,14 @@ async function emitPullRequestChangeEvents(c: any, beforeRows: any[], afterRows:
if (!changed) {
continue;
}
await refreshTaskSummaryForBranch(c, row.repoId, row.headRefName);
await refreshTaskSummaryForBranch(c, row.repoId, row.headRefName, pullRequestSummaryFromRow(row));
}
for (const [prId, row] of beforeById) {
if (afterById.has(prId)) {
continue;
}
await refreshTaskSummaryForBranch(c, row.repoId, row.headRefName);
await refreshTaskSummaryForBranch(c, row.repoId, row.headRefName, null);
}
}
@ -302,7 +483,7 @@ async function autoArchiveTaskForClosedPullRequest(c: any, row: any) {
if (!repositoryRecord) {
return;
}
const repository = await getOrCreateRepository(c, c.state.organizationId, row.repoId, repositoryRecord.cloneUrl);
const repository = await getOrCreateRepository(c, c.state.organizationId, row.repoId);
const match = await repository.findTaskForBranch({
branchName: row.headRefName,
});
@ -311,7 +492,7 @@ async function autoArchiveTaskForClosedPullRequest(c: any, row: any) {
}
try {
const task = getTask(c, c.state.organizationId, row.repoId, match.taskId);
await task.archive({ reason: `PR ${String(row.state).toLowerCase()}` });
await task.send(taskWorkflowQueueName("task.command.archive"), { reason: `PR ${String(row.state).toLowerCase()}` }, { wait: false });
} catch {
// Best-effort only. Task summary refresh will still clear the PR state.
}
@ -363,8 +544,7 @@ async function resolveMembers(c: any, context: Awaited<ReturnType<typeof getOrga
return await appShell.github.listOrganizationMembers(context.accessToken, context.githubLogin);
}
async function resolvePullRequests(
c: any,
async function listPullRequestsForRepositories(
context: Awaited<ReturnType<typeof getOrganizationContext>>,
repositories: GithubRepositoryRecord[],
): Promise<GithubPullRequestRecord[]> {
@ -448,11 +628,51 @@ async function listRepositoryBranchesForContext(
}
async function resolveBranches(
_c: any,
c: any,
context: Awaited<ReturnType<typeof getOrganizationContext>>,
repositories: GithubRepositoryRecord[],
): Promise<GithubBranchRecord[]> {
return (await Promise.all(repositories.map((repository) => listRepositoryBranchesForContext(context, repository)))).flat();
onBatch?: (branches: GithubBranchRecord[]) => Promise<void>,
onProgress?: (processedRepositoryCount: number, totalRepositoryCount: number) => Promise<void>,
): Promise<void> {
const batches = chunkItems(repositories, SYNC_REPOSITORY_BATCH_SIZE);
let processedRepositoryCount = 0;
for (const batch of batches) {
const batchBranches = await runSyncStep(c, `github-sync-branches-${processedRepositoryCount / SYNC_REPOSITORY_BATCH_SIZE + 1}`, async () =>
(await Promise.all(batch.map((repository) => listRepositoryBranchesForContext(context, repository)))).flat(),
);
if (onBatch) {
await onBatch(batchBranches);
}
processedRepositoryCount += batch.length;
if (onProgress) {
await onProgress(processedRepositoryCount, repositories.length);
}
}
}
async function resolvePullRequests(
c: any,
context: Awaited<ReturnType<typeof getOrganizationContext>>,
repositories: GithubRepositoryRecord[],
onBatch?: (pullRequests: GithubPullRequestRecord[]) => Promise<void>,
onProgress?: (processedRepositoryCount: number, totalRepositoryCount: number) => Promise<void>,
): Promise<void> {
const batches = chunkItems(repositories, SYNC_REPOSITORY_BATCH_SIZE);
let processedRepositoryCount = 0;
for (const batch of batches) {
const batchPullRequests = await runSyncStep(c, `github-sync-pull-requests-${processedRepositoryCount / SYNC_REPOSITORY_BATCH_SIZE + 1}`, async () =>
listPullRequestsForRepositories(context, batch),
);
if (onBatch) {
await onBatch(batchPullRequests);
}
processedRepositoryCount += batch.length;
if (onProgress) {
await onProgress(processedRepositoryCount, repositories.length);
}
}
}
async function refreshRepositoryBranches(
@ -461,6 +681,7 @@ async function refreshRepositoryBranches(
repository: GithubRepositoryRecord,
updatedAt: number,
): Promise<void> {
const currentMeta = await readMeta(c);
const nextBranches = await listRepositoryBranchesForContext(context, repository);
await c.db
.delete(githubBranches)
@ -475,6 +696,7 @@ async function refreshRepositoryBranches(
repoId: branch.repoId,
branchName: branch.branchName,
commitSha: branch.commitSha,
syncGeneration: currentMeta.syncGeneration,
updatedAt,
})
.run();
@ -485,118 +707,176 @@ async function readAllPullRequestRows(c: any) {
return await c.db.select().from(githubPullRequests).all();
}
async function runFullSync(c: any, input: FullSyncInput = {}) {
export async function runFullSync(c: any, input: FullSyncInput = {}) {
const startedAt = Date.now();
const beforeRows = await readAllPullRequestRows(c);
const context = await getOrganizationContext(c, input);
const currentMeta = await readMeta(c);
let context: Awaited<ReturnType<typeof getOrganizationContext>> | null = null;
let syncGeneration = currentMeta.syncGeneration + 1;
await writeMeta(c, {
connectedAccount: context.connectedAccount,
installationStatus: context.installationStatus,
installationId: context.installationId,
syncStatus: "syncing",
lastSyncLabel: input.label?.trim() || "Syncing GitHub data...",
});
try {
context = await getOrganizationContext(c, input);
syncGeneration = currentMeta.syncGeneration + 1;
const repositories = await resolveRepositories(c, context);
const branches = await resolveBranches(c, context, repositories);
const members = await resolveMembers(c, context);
const pullRequests = await resolvePullRequests(c, context, repositories);
await replaceRepositories(c, repositories, startedAt);
await replaceBranches(c, branches, startedAt);
await replaceMembers(c, members, startedAt);
await replacePullRequests(c, pullRequests);
const organization = await getOrCreateOrganization(c, c.state.organizationId);
await organization.applyGithubDataProjection({
connectedAccount: context.connectedAccount,
installationStatus: context.installationStatus,
installationId: context.installationId,
syncStatus: "synced",
lastSyncLabel: repositories.length > 0 ? `Synced ${repositories.length} repositories` : "No repositories available",
lastSyncAt: startedAt,
repositories,
});
const meta = await writeMeta(c, {
connectedAccount: context.connectedAccount,
installationStatus: context.installationStatus,
installationId: context.installationId,
syncStatus: "synced",
lastSyncLabel: repositories.length > 0 ? `Synced ${repositories.length} repositories` : "No repositories available",
lastSyncAt: startedAt,
});
const afterRows = await readAllPullRequestRows(c);
await emitPullRequestChangeEvents(c, beforeRows, afterRows);
return {
...meta,
repositoryCount: repositories.length,
memberCount: members.length,
pullRequestCount: afterRows.length,
};
}
const GITHUB_DATA_QUEUE_NAMES = ["githubData.command.syncRepos"] as const;
async function runGithubDataWorkflow(ctx: any): Promise<void> {
// Initial sync: if this actor was just created and has never synced,
// kick off the first full sync automatically.
await ctx.step({
name: "github-data-initial-sync",
timeout: 5 * 60_000,
run: async () => {
const meta = await readMeta(ctx);
if (meta.syncStatus !== "pending") {
return; // Already synced or syncing — skip initial sync
}
try {
await runFullSync(ctx, { label: "Importing repository catalog..." });
} catch (error) {
// Best-effort initial sync. Write the error to meta so the client
// sees the failure and can trigger a manual retry.
const currentMeta = await readMeta(ctx);
const organization = await getOrCreateOrganization(ctx, ctx.state.organizationId);
await organization.markOrganizationSyncFailed({
message: error instanceof Error ? error.message : "GitHub import failed",
installationStatus: currentMeta.installationStatus,
});
}
},
});
// Command loop for explicit sync requests (reload, re-import, etc.)
await ctx.loop("github-data-command-loop", async (loopCtx: any) => {
const msg = await loopCtx.queue.next("next-github-data-command", {
names: [...GITHUB_DATA_QUEUE_NAMES],
completable: true,
await publishSyncProgress(c, {
connectedAccount: context.connectedAccount,
installationStatus: context.installationStatus,
installationId: context.installationId,
syncStatus: "syncing",
lastSyncLabel: input.label?.trim() || "Syncing GitHub data...",
syncGeneration,
syncPhase: "discovering_repositories",
processedRepositoryCount: 0,
totalRepositoryCount: 0,
});
if (!msg) {
return Loop.continue(undefined);
}
try {
if (msg.name === "githubData.command.syncRepos") {
await loopCtx.step({
name: "github-data-sync-repos",
timeout: 5 * 60_000,
run: async () => {
const body = msg.body as FullSyncInput;
await runFullSync(loopCtx, body);
},
const repositories = await runSyncStep(c, "github-sync-repositories", async () => resolveRepositories(c, context));
const totalRepositoryCount = repositories.length;
await publishSyncProgress(c, {
connectedAccount: context.connectedAccount,
installationStatus: context.installationStatus,
installationId: context.installationId,
syncStatus: "syncing",
lastSyncLabel: totalRepositoryCount > 0 ? `Importing ${totalRepositoryCount} repositories...` : "No repositories available",
syncGeneration,
syncPhase: "syncing_repositories",
processedRepositoryCount: totalRepositoryCount,
totalRepositoryCount,
});
await upsertRepositories(c, repositories, startedAt, syncGeneration);
const organization = await getOrCreateOrganization(c, c.state.organizationId);
await sendOrganizationCommand(organization, "organization.command.github.data_projection.apply", {
connectedAccount: context.connectedAccount,
installationStatus: context.installationStatus,
installationId: context.installationId,
syncStatus: "syncing",
lastSyncLabel: totalRepositoryCount > 0 ? `Imported ${totalRepositoryCount} repositories` : "No repositories available",
lastSyncAt: currentMeta.lastSyncAt,
syncGeneration,
syncPhase: totalRepositoryCount > 0 ? "syncing_branches" : null,
processedRepositoryCount: 0,
totalRepositoryCount,
repositories,
});
await resolveBranches(
c,
context,
repositories,
async (batchBranches) => {
await upsertBranches(c, batchBranches, startedAt, syncGeneration);
},
async (processedRepositoryCount, repositoryCount) => {
await publishSyncProgress(c, {
connectedAccount: context.connectedAccount,
installationStatus: context.installationStatus,
installationId: context.installationId,
syncStatus: "syncing",
lastSyncLabel: `Synced branches for ${processedRepositoryCount} of ${repositoryCount} repositories`,
syncGeneration,
syncPhase: "syncing_branches",
processedRepositoryCount,
totalRepositoryCount: repositoryCount,
});
await msg.complete({ ok: true });
return Loop.continue(undefined);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await msg.complete({ error: message }).catch(() => {});
}
},
);
return Loop.continue(undefined);
});
await publishSyncProgress(c, {
connectedAccount: context.connectedAccount,
installationStatus: context.installationStatus,
installationId: context.installationId,
syncStatus: "syncing",
lastSyncLabel: "Syncing GitHub members...",
syncGeneration,
syncPhase: "syncing_members",
processedRepositoryCount: totalRepositoryCount,
totalRepositoryCount,
});
const members = await runSyncStep(c, "github-sync-members", async () => resolveMembers(c, context));
await upsertMembers(c, members, startedAt, syncGeneration);
await sweepMembers(c, syncGeneration);
await resolvePullRequests(
c,
context,
repositories,
async (batchPullRequests) => {
await upsertPullRequests(c, batchPullRequests, syncGeneration);
},
async (processedRepositoryCount, repositoryCount) => {
await publishSyncProgress(c, {
connectedAccount: context.connectedAccount,
installationStatus: context.installationStatus,
installationId: context.installationId,
syncStatus: "syncing",
lastSyncLabel: `Synced pull requests for ${processedRepositoryCount} of ${repositoryCount} repositories`,
syncGeneration,
syncPhase: "syncing_pull_requests",
processedRepositoryCount,
totalRepositoryCount: repositoryCount,
});
},
);
await sweepBranches(c, syncGeneration);
await sweepPullRequests(c, syncGeneration);
await sweepRepositories(c, syncGeneration);
await sendOrganizationCommand(organization, "organization.command.github.data_projection.apply", {
connectedAccount: context.connectedAccount,
installationStatus: context.installationStatus,
installationId: context.installationId,
syncStatus: "synced",
lastSyncLabel: totalRepositoryCount > 0 ? `Synced ${totalRepositoryCount} repositories` : "No repositories available",
lastSyncAt: startedAt,
syncGeneration,
syncPhase: null,
processedRepositoryCount: totalRepositoryCount,
totalRepositoryCount,
repositories,
});
const meta = await writeMeta(c, {
connectedAccount: context.connectedAccount,
installationStatus: context.installationStatus,
installationId: context.installationId,
syncStatus: "synced",
lastSyncLabel: totalRepositoryCount > 0 ? `Synced ${totalRepositoryCount} repositories` : "No repositories available",
lastSyncAt: startedAt,
syncGeneration,
syncPhase: null,
processedRepositoryCount: totalRepositoryCount,
totalRepositoryCount,
});
const afterRows = await readAllPullRequestRows(c);
await emitPullRequestChangeEvents(c, beforeRows, afterRows);
return {
...meta,
repositoryCount: repositories.length,
memberCount: members.length,
pullRequestCount: afterRows.length,
};
} catch (error) {
const message = error instanceof Error ? error.message : "GitHub import failed";
await publishSyncProgress(c, {
connectedAccount: context?.connectedAccount ?? currentMeta.connectedAccount,
installationStatus: context?.installationStatus ?? currentMeta.installationStatus,
installationId: context?.installationId ?? currentMeta.installationId,
syncStatus: "error",
lastSyncLabel: message,
syncGeneration,
syncPhase: null,
processedRepositoryCount: 0,
totalRepositoryCount: 0,
});
throw error;
}
}
export const githubData = actor({
@ -651,11 +931,6 @@ export const githubData = actor({
};
},
async listPullRequestsForRepository(c, input: { repoId: string }) {
const rows = await c.db.select().from(githubPullRequests).where(eq(githubPullRequests.repoId, input.repoId)).all();
return rows.map(pullRequestSummaryFromRow);
},
async listBranchesForRepository(c, input: { repoId: string }) {
const rows = await c.db.select().from(githubBranches).where(eq(githubBranches.repoId, input.repoId)).all();
return rows
@ -666,36 +941,10 @@ export const githubData = actor({
.sort((left, right) => left.branchName.localeCompare(right.branchName));
},
async listOpenPullRequests(c) {
const rows = await c.db.select().from(githubPullRequests).all();
return rows.map(pullRequestSummaryFromRow).sort((left, right) => right.updatedAtMs - left.updatedAtMs);
},
},
});
async getPullRequestForBranch(c, input: { repoId: string; branchName: string }) {
const rows = await c.db.select().from(githubPullRequests).where(eq(githubPullRequests.repoId, input.repoId)).all();
const match = rows.find((candidate) => candidate.headRefName === input.branchName) ?? null;
if (!match) {
return null;
}
return {
number: match.number,
status: match.isDraft ? ("draft" as const) : ("ready" as const),
};
},
async adminFullSync(c, input: FullSyncInput = {}) {
return await runFullSync(c, input);
},
async adminReloadOrganization(c) {
return await runFullSync(c, { label: "Reloading GitHub organization..." });
},
async adminReloadAllPullRequests(c) {
return await runFullSync(c, { label: "Reloading GitHub pull requests..." });
},
async reloadRepository(c, input: { repoId: string }) {
export async function reloadRepositoryMutation(c: any, input: { repoId: string }) {
const context = await getOrganizationContext(c);
const current = await c.db.select().from(githubRepositories).where(eq(githubRepositories.repoId, input.repoId)).get();
if (!current) {
@ -713,6 +962,7 @@ export const githubData = actor({
}
const updatedAt = Date.now();
const currentMeta = await readMeta(c);
await c.db
.insert(githubRepositories)
.values({
@ -721,6 +971,7 @@ export const githubData = actor({
cloneUrl: repository.cloneUrl,
private: repository.private ? 1 : 0,
defaultBranch: repository.defaultBranch,
syncGeneration: currentMeta.syncGeneration,
updatedAt,
})
.onConflictDoUpdate({
@ -730,6 +981,7 @@ export const githubData = actor({
cloneUrl: repository.cloneUrl,
private: repository.private ? 1 : 0,
defaultBranch: repository.defaultBranch,
syncGeneration: currentMeta.syncGeneration,
updatedAt,
},
})
@ -747,7 +999,7 @@ export const githubData = actor({
);
const organization = await getOrCreateOrganization(c, c.state.organizationId);
await organization.applyGithubRepositoryProjection({
await sendOrganizationCommand(organization, "organization.command.github.repository_projection.apply", {
repoId: input.repoId,
remoteUrl: repository.cloneUrl,
});
@ -758,98 +1010,11 @@ export const githubData = actor({
private: repository.private,
defaultBranch: repository.defaultBranch,
};
},
async reloadPullRequest(c, input: { repoId: string; prNumber: number }) {
const repository = await c.db.select().from(githubRepositories).where(eq(githubRepositories.repoId, input.repoId)).get();
if (!repository) {
throw new Error(`Unknown GitHub repository: ${input.repoId}`);
}
const context = await getOrganizationContext(c);
const { appShell } = getActorRuntimeContext();
const pullRequest =
context.installationId != null
? await appShell.github.getInstallationPullRequest(context.installationId, repository.fullName, input.prNumber)
: context.accessToken
? await appShell.github.getUserPullRequest(context.accessToken, repository.fullName, input.prNumber)
: null;
if (!pullRequest) {
throw new Error(`Unable to reload pull request #${input.prNumber} for ${repository.fullName}`);
}
}
export async function clearStateMutation(c: any, input: ClearStateInput) {
const beforeRows = await readAllPullRequestRows(c);
const updatedAt = Date.now();
const nextState = normalizePrStatus(pullRequest);
const prId = `${input.repoId}#${input.prNumber}`;
if (nextState === "CLOSED" || nextState === "MERGED") {
await c.db.delete(githubPullRequests).where(eq(githubPullRequests.prId, prId)).run();
} else {
await c.db
.insert(githubPullRequests)
.values({
prId,
repoId: input.repoId,
repoFullName: repository.fullName,
number: pullRequest.number,
title: pullRequest.title,
body: pullRequest.body ?? null,
state: nextState,
url: pullRequest.url,
headRefName: pullRequest.headRefName,
baseRefName: pullRequest.baseRefName,
authorLogin: pullRequest.authorLogin ?? null,
isDraft: pullRequest.isDraft ? 1 : 0,
updatedAt,
})
.onConflictDoUpdate({
target: githubPullRequests.prId,
set: {
title: pullRequest.title,
body: pullRequest.body ?? null,
state: nextState,
url: pullRequest.url,
headRefName: pullRequest.headRefName,
baseRefName: pullRequest.baseRefName,
authorLogin: pullRequest.authorLogin ?? null,
isDraft: pullRequest.isDraft ? 1 : 0,
updatedAt,
},
})
.run();
}
const afterRows = await readAllPullRequestRows(c);
await emitPullRequestChangeEvents(c, beforeRows, afterRows);
const closed = afterRows.find((row) => row.prId === prId);
if (!closed && (nextState === "CLOSED" || nextState === "MERGED")) {
const previous = beforeRows.find((row) => row.prId === prId);
if (previous) {
await autoArchiveTaskForClosedPullRequest(c, {
...previous,
state: nextState,
});
}
}
return pullRequestSummaryFromRow(
afterRows.find((row) => row.prId === prId) ?? {
prId,
repoId: input.repoId,
repoFullName: repository.fullName,
number: input.prNumber,
title: pullRequest.title,
state: nextState,
url: pullRequest.url,
headRefName: pullRequest.headRefName,
baseRefName: pullRequest.baseRefName,
authorLogin: pullRequest.authorLogin ?? null,
isDraft: pullRequest.isDraft ? 1 : 0,
updatedAt,
},
);
},
async adminClearState(c, input: ClearStateInput) {
const beforeRows = await readAllPullRequestRows(c);
const currentMeta = await readMeta(c);
await c.db.delete(githubPullRequests).run();
await c.db.delete(githubBranches).run();
await c.db.delete(githubRepositories).run();
@ -861,26 +1026,35 @@ export const githubData = actor({
syncStatus: "pending",
lastSyncLabel: input.label,
lastSyncAt: null,
syncGeneration: currentMeta.syncGeneration,
syncPhase: null,
processedRepositoryCount: 0,
totalRepositoryCount: 0,
});
const organization = await getOrCreateOrganization(c, c.state.organizationId);
await organization.applyGithubDataProjection({
await sendOrganizationCommand(organization, "organization.command.github.data_projection.apply", {
connectedAccount: input.connectedAccount,
installationStatus: input.installationStatus,
installationId: input.installationId,
syncStatus: "pending",
lastSyncLabel: input.label,
lastSyncAt: null,
syncGeneration: currentMeta.syncGeneration,
syncPhase: null,
processedRepositoryCount: 0,
totalRepositoryCount: 0,
repositories: [],
});
await emitPullRequestChangeEvents(c, beforeRows, []);
},
}
async handlePullRequestWebhook(c, input: PullRequestWebhookInput) {
export async function handlePullRequestWebhookMutation(c: any, input: PullRequestWebhookInput) {
const beforeRows = await readAllPullRequestRows(c);
const repoId = repoIdFromRemote(input.repository.cloneUrl);
const currentRepository = await c.db.select().from(githubRepositories).where(eq(githubRepositories.repoId, repoId)).get();
const updatedAt = Date.now();
const currentMeta = await readMeta(c);
const state = normalizePrStatus(input.pullRequest);
const prId = `${repoId}#${input.pullRequest.number}`;
@ -892,6 +1066,7 @@ export const githubData = actor({
cloneUrl: input.repository.cloneUrl,
private: input.repository.private ? 1 : 0,
defaultBranch: currentRepository?.defaultBranch ?? input.pullRequest.baseRefName ?? "main",
syncGeneration: currentMeta.syncGeneration,
updatedAt,
})
.onConflictDoUpdate({
@ -901,6 +1076,7 @@ export const githubData = actor({
cloneUrl: input.repository.cloneUrl,
private: input.repository.private ? 1 : 0,
defaultBranch: currentRepository?.defaultBranch ?? input.pullRequest.baseRefName ?? "main",
syncGeneration: currentMeta.syncGeneration,
updatedAt,
},
})
@ -924,6 +1100,7 @@ export const githubData = actor({
baseRefName: input.pullRequest.baseRefName,
authorLogin: input.pullRequest.authorLogin ?? null,
isDraft: input.pullRequest.isDraft ? 1 : 0,
syncGeneration: currentMeta.syncGeneration,
updatedAt,
})
.onConflictDoUpdate({
@ -937,23 +1114,27 @@ export const githubData = actor({
baseRefName: input.pullRequest.baseRefName,
authorLogin: input.pullRequest.authorLogin ?? null,
isDraft: input.pullRequest.isDraft ? 1 : 0,
syncGeneration: currentMeta.syncGeneration,
updatedAt,
},
})
.run();
}
await writeMeta(c, {
await publishSyncProgress(c, {
connectedAccount: input.connectedAccount,
installationStatus: input.installationStatus,
installationId: input.installationId,
syncStatus: "synced",
lastSyncLabel: "GitHub webhook received",
lastSyncAt: updatedAt,
syncPhase: null,
processedRepositoryCount: 0,
totalRepositoryCount: 0,
});
const organization = await getOrCreateOrganization(c, c.state.organizationId);
await organization.applyGithubRepositoryProjection({
await sendOrganizationCommand(organization, "organization.command.github.repository_projection.apply", {
repoId,
remoteUrl: input.repository.cloneUrl,
});
@ -969,6 +1150,4 @@ export const githubData = actor({
});
}
}
},
},
});
}

View file

@ -0,0 +1,76 @@
// @ts-nocheck
import { Loop } from "rivetkit/workflow";
import { clearStateMutation, handlePullRequestWebhookMutation, reloadRepositoryMutation, runFullSync } from "./index.js";
export const GITHUB_DATA_QUEUE_NAMES = [
"githubData.command.syncRepos",
"githubData.command.reloadRepository",
"githubData.command.clearState",
"githubData.command.handlePullRequestWebhook",
] as const;
export type GithubDataQueueName = (typeof GITHUB_DATA_QUEUE_NAMES)[number];
export function githubDataWorkflowQueueName(name: GithubDataQueueName): GithubDataQueueName {
return name;
}
export async function runGithubDataWorkflow(ctx: any): Promise<void> {
const meta = await ctx.step({
name: "github-data-read-meta",
timeout: 30_000,
run: async () => {
const { readMeta } = await import("./index.js");
return await readMeta(ctx);
},
});
if (meta.syncStatus === "pending") {
try {
await runFullSync(ctx, { label: "Importing repository catalog..." });
} catch {
// Best-effort initial sync. runFullSync persists the failure state.
}
}
await ctx.loop("github-data-command-loop", async (loopCtx: any) => {
const msg = await loopCtx.queue.next("next-github-data-command", {
names: [...GITHUB_DATA_QUEUE_NAMES],
completable: true,
});
if (!msg) {
return Loop.continue(undefined);
}
try {
if (msg.name === "githubData.command.syncRepos") {
await runFullSync(loopCtx, msg.body);
await msg.complete({ ok: true });
return Loop.continue(undefined);
}
if (msg.name === "githubData.command.reloadRepository") {
const result = await reloadRepositoryMutation(loopCtx, msg.body);
await msg.complete(result);
return Loop.continue(undefined);
}
if (msg.name === "githubData.command.clearState") {
await clearStateMutation(loopCtx, msg.body);
await msg.complete({ ok: true });
return Loop.continue(undefined);
}
if (msg.name === "githubData.command.handlePullRequestWebhook") {
await handlePullRequestWebhookMutation(loopCtx, msg.body);
await msg.complete({ ok: true });
return Loop.continue(undefined);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await msg.complete({ error: message }).catch(() => {});
}
return Loop.continue(undefined);
});
}

View file

@ -20,12 +20,11 @@ export function getUser(c: any, userId: string) {
return actorClient(c).user.get(userKey(userId));
}
export async function getOrCreateRepository(c: any, organizationId: string, repoId: string, remoteUrl: string) {
export async function getOrCreateRepository(c: any, organizationId: string, repoId: string) {
return await actorClient(c).repository.getOrCreate(repositoryKey(organizationId, repoId), {
createWithInput: {
organizationId,
repoId,
remoteUrl,
},
});
}

View file

@ -32,7 +32,6 @@ export const registry = setup({
});
export * from "./context.js";
export * from "./events.js";
export * from "./audit-log/index.js";
export * from "./user/index.js";
export * from "./github-data/index.js";

View file

@ -1,76 +1,31 @@
// @ts-nocheck
import { desc, eq } from "drizzle-orm";
import { Loop } from "rivetkit/workflow";
import type {
CreateTaskInput,
AuditLogEvent,
HistoryQueryInput,
ListTasksInput,
SandboxProviderId,
RepoOverview,
RepoRecord,
StarSandboxAgentRepoInput,
StarSandboxAgentRepoResult,
SwitchResult,
TaskRecord,
TaskSummary,
TaskWorkspaceChangeModelInput,
TaskWorkspaceCreateTaskInput,
TaskWorkspaceDiffInput,
TaskWorkspaceRenameInput,
TaskWorkspaceRenameSessionInput,
TaskWorkspaceSelectInput,
TaskWorkspaceSetSessionUnreadInput,
TaskWorkspaceSendMessageInput,
TaskWorkspaceSessionInput,
TaskWorkspaceUpdateDraftInput,
WorkspaceRepositorySummary,
WorkspaceTaskSummary,
OrganizationEvent,
OrganizationGithubSummary,
OrganizationSummarySnapshot,
OrganizationUseInput,
} from "@sandbox-agent/foundry-shared";
import { getActorRuntimeContext } from "../context.js";
import { getOrCreateAuditLog, getOrCreateGithubData, getTask as getTaskHandle, getOrCreateRepository, selfOrganization } from "../handles.js";
import { getOrCreateRepository } from "../handles.js";
import { logActorWarning, resolveErrorMessage } from "../logging.js";
import { defaultSandboxProviderId } from "../../sandbox-config.js";
import { repoIdFromRemote } from "../../services/repo.js";
import { resolveOrganizationGithubAuth } from "../../services/github-auth.js";
import { organizationProfile, repos } from "./db/schema.js";
import { agentTypeForModel } from "../task/workspace.js";
import { expectQueueResponse } from "../../services/queue.js";
import { organizationAppActions } from "./app-shell.js";
import { organizationAppActions } from "./actions/app.js";
import { organizationBetterAuthActions } from "./actions/better-auth.js";
import { organizationOnboardingActions } from "./actions/onboarding.js";
import { organizationGithubActions } from "./actions/github.js";
import { organizationShellActions } from "./actions/organization.js";
import { organizationTaskActions } from "./actions/tasks.js";
export { createTaskMutation } from "./actions/tasks.js";
interface OrganizationState {
organizationId: string;
}
interface GetTaskInput {
organizationId: string;
repoId?: string;
taskId: string;
}
interface TaskProxyActionInput extends GetTaskInput {
reason?: string;
}
interface RepoOverviewInput {
organizationId: string;
repoId: string;
}
const ORGANIZATION_QUEUE_NAMES = ["organization.command.createTask", "organization.command.syncGithubSession"] as const;
const SANDBOX_AGENT_REPO = "rivet-dev/sandbox-agent";
type OrganizationQueueName = (typeof ORGANIZATION_QUEUE_NAMES)[number];
export { ORGANIZATION_QUEUE_NAMES };
export function organizationWorkflowQueueName(name: OrganizationQueueName): OrganizationQueueName {
return name;
}
const ORGANIZATION_PROFILE_ROW_ID = 1;
function assertOrganization(c: { state: OrganizationState }, organizationId: string): void {
@ -79,28 +34,6 @@ function assertOrganization(c: { state: OrganizationState }, organizationId: str
}
}
async function collectAllTaskSummaries(c: any): Promise<TaskSummary[]> {
const repoRows = await c.db.select({ repoId: repos.repoId, remoteUrl: repos.remoteUrl }).from(repos).orderBy(desc(repos.updatedAt)).all();
const all: TaskSummary[] = [];
for (const row of repoRows) {
try {
const repository = await getOrCreateRepository(c, c.state.organizationId, row.repoId, row.remoteUrl);
const snapshot = await repository.listTaskSummaries({ includeArchived: true });
all.push(...snapshot);
} catch (error) {
logActorWarning("organization", "failed collecting tasks for repo", {
organizationId: c.state.organizationId,
repoId: row.repoId,
error: resolveErrorMessage(error),
});
}
}
all.sort((a, b) => b.updatedAt - a.updatedAt);
return all;
}
function repoLabelFromRemote(remoteUrl: string): string {
try {
const url = new URL(remoteUrl.startsWith("http") ? remoteUrl : `https://${remoteUrl}`);
@ -127,67 +60,30 @@ function buildRepoSummary(repoRow: { repoId: string; remoteUrl: string; updatedA
};
}
async function resolveRepositoryForTask(c: any, taskId: string, repoId?: string | null) {
if (repoId) {
const repoRow = await c.db.select({ remoteUrl: repos.remoteUrl }).from(repos).where(eq(repos.repoId, repoId)).get();
if (!repoRow) {
throw new Error(`Unknown repo: ${repoId}`);
}
const repository = await getOrCreateRepository(c, c.state.organizationId, repoId, repoRow.remoteUrl);
return { repoId, repository };
}
const repoRows = await c.db.select({ repoId: repos.repoId, remoteUrl: repos.remoteUrl }).from(repos).orderBy(desc(repos.updatedAt)).all();
for (const row of repoRows) {
const repository = await getOrCreateRepository(c, c.state.organizationId, row.repoId, row.remoteUrl);
const summaries = await repository.listTaskSummaries({ includeArchived: true });
if (summaries.some((summary: TaskSummary) => summary.taskId === taskId)) {
return { repoId: row.repoId, repository };
}
}
throw new Error(`Unknown task: ${taskId}`);
}
async function reconcileWorkspaceProjection(c: any): Promise<OrganizationSummarySnapshot> {
const repoRows = await c.db
.select({ repoId: repos.repoId, remoteUrl: repos.remoteUrl, updatedAt: repos.updatedAt })
.from(repos)
.orderBy(desc(repos.updatedAt))
.all();
const taskRows: WorkspaceTaskSummary[] = [];
for (const row of repoRows) {
try {
const repository = await getOrCreateRepository(c, c.state.organizationId, row.repoId, row.remoteUrl);
taskRows.push(...(await repository.listWorkspaceTaskSummaries({})));
} catch (error) {
logActorWarning("organization", "failed collecting repo during workspace reconciliation", {
organizationId: c.state.organizationId,
repoId: row.repoId,
error: resolveErrorMessage(error),
});
}
}
taskRows.sort((left, right) => right.updatedAtMs - left.updatedAtMs);
function buildGithubSummary(profile: any, importedRepoCount: number): OrganizationGithubSummary {
return {
organizationId: c.state.organizationId,
repos: repoRows.map((row) => buildRepoSummary(row, taskRows)).sort((left, right) => right.latestActivityMs - left.latestActivityMs),
taskSummaries: taskRows,
connectedAccount: profile?.githubConnectedAccount ?? "",
installationStatus: profile?.githubInstallationStatus ?? "install_required",
syncStatus: profile?.githubSyncStatus ?? "pending",
importedRepoCount,
lastSyncLabel: profile?.githubLastSyncLabel ?? "Waiting for first import",
lastSyncAt: profile?.githubLastSyncAt ?? null,
lastWebhookAt: profile?.githubLastWebhookAt ?? null,
lastWebhookEvent: profile?.githubLastWebhookEvent ?? "",
syncGeneration: profile?.githubSyncGeneration ?? 0,
syncPhase: profile?.githubSyncPhase ?? null,
processedRepositoryCount: profile?.githubProcessedRepositoryCount ?? 0,
totalRepositoryCount: profile?.githubTotalRepositoryCount ?? 0,
};
}
async function requireWorkspaceTask(c: any, repoId: string, taskId: string) {
return getTaskHandle(c, c.state.organizationId, repoId, taskId);
}
/**
* Reads the organization sidebar snapshot by fanning out one level to the
* repository coordinators. Task summaries are repository-owned; organization
* only aggregates them.
*/
async function getOrganizationSummarySnapshot(c: any): Promise<OrganizationSummarySnapshot> {
const profile = await c.db.select().from(organizationProfile).where(eq(organizationProfile.id, ORGANIZATION_PROFILE_ROW_ID)).get();
const repoRows = await c.db
.select({
repoId: repos.repoId,
@ -200,7 +96,7 @@ async function getOrganizationSummarySnapshot(c: any): Promise<OrganizationSumma
const summaries: WorkspaceTaskSummary[] = [];
for (const row of repoRows) {
try {
const repository = await getOrCreateRepository(c, c.state.organizationId, row.repoId, row.remoteUrl);
const repository = await getOrCreateRepository(c, c.state.organizationId, row.repoId);
summaries.push(...(await repository.listWorkspaceTaskSummaries({})));
} catch (error) {
logActorWarning("organization", "failed reading repository task projection", {
@ -214,98 +110,26 @@ async function getOrganizationSummarySnapshot(c: any): Promise<OrganizationSumma
return {
organizationId: c.state.organizationId,
github: buildGithubSummary(profile, repoRows.length),
repos: repoRows.map((row) => buildRepoSummary(row, summaries)).sort((left, right) => right.latestActivityMs - left.latestActivityMs),
taskSummaries: summaries,
};
}
async function broadcastOrganizationSnapshot(c: any): Promise<void> {
export async function refreshOrganizationSnapshotMutation(c: any): Promise<void> {
c.broadcast("organizationUpdated", {
type: "organizationUpdated",
snapshot: await getOrganizationSummarySnapshot(c),
} satisfies OrganizationEvent);
}
async function createTaskMutation(c: any, input: CreateTaskInput): Promise<TaskRecord> {
assertOrganization(c, input.organizationId);
const { config } = getActorRuntimeContext();
const sandboxProviderId = input.sandboxProviderId ?? defaultSandboxProviderId(config);
const repoId = input.repoId;
const repoRow = await c.db.select({ remoteUrl: repos.remoteUrl }).from(repos).where(eq(repos.repoId, repoId)).get();
if (!repoRow) {
throw new Error(`Unknown repo: ${repoId}`);
}
const remoteUrl = repoRow.remoteUrl;
const repository = await getOrCreateRepository(c, c.state.organizationId, repoId, remoteUrl);
const created = await repository.createTask({
task: input.task,
sandboxProviderId,
agentType: input.agentType ?? null,
explicitTitle: input.explicitTitle ?? null,
explicitBranchName: input.explicitBranchName ?? null,
onBranch: input.onBranch ?? null,
});
return created;
}
export async function runOrganizationWorkflow(ctx: any): Promise<void> {
await ctx.loop("organization-command-loop", async (loopCtx: any) => {
const msg = await loopCtx.queue.next("next-organization-command", {
names: [...ORGANIZATION_QUEUE_NAMES],
completable: true,
});
if (!msg) {
return Loop.continue(undefined);
}
try {
if (msg.name === "organization.command.createTask") {
const result = await loopCtx.step({
name: "organization-create-task",
timeout: 5 * 60_000,
run: async () => createTaskMutation(loopCtx, msg.body as CreateTaskInput),
});
await msg.complete(result);
return Loop.continue(undefined);
}
if (msg.name === "organization.command.syncGithubSession") {
await loopCtx.step({
name: "organization-sync-github-session",
timeout: 60_000,
run: async () => {
const { syncGithubOrganizations } = await import("./app-shell.js");
await syncGithubOrganizations(loopCtx, msg.body as { sessionId: string; accessToken: string });
},
});
await msg.complete({ ok: true });
return Loop.continue(undefined);
}
} catch (error) {
const message = resolveErrorMessage(error);
logActorWarning("organization", "organization workflow command failed", {
queueName: msg.name,
error: message,
});
await msg.complete({ error: message }).catch((completeError: unknown) => {
logActorWarning("organization", "organization workflow failed completing error response", {
queueName: msg.name,
error: resolveErrorMessage(completeError),
});
});
}
return Loop.continue(undefined);
});
}
export const organizationActions = {
...organizationBetterAuthActions,
...organizationGithubActions,
...organizationOnboardingActions,
...organizationShellActions,
...organizationAppActions,
...organizationTaskActions,
async useOrganization(c: any, input: OrganizationUseInput): Promise<{ organizationId: string }> {
assertOrganization(c, input.organizationId);
return { organizationId: c.state.organizationId };
@ -334,381 +158,180 @@ export const organizationActions = {
}));
},
async createTask(c: any, input: CreateTaskInput): Promise<TaskRecord> {
const self = selfOrganization(c);
return expectQueueResponse<TaskRecord>(
await self.send(organizationWorkflowQueueName("organization.command.createTask"), input, {
wait: true,
timeout: 10_000,
}),
);
},
async starSandboxAgentRepo(c: any, input: StarSandboxAgentRepoInput): Promise<StarSandboxAgentRepoResult> {
async getOrganizationSummary(c: any, input: OrganizationUseInput): Promise<OrganizationSummarySnapshot> {
assertOrganization(c, input.organizationId);
const { driver } = getActorRuntimeContext();
const auth = await resolveOrganizationGithubAuth(c, c.state.organizationId);
await driver.github.starRepository(SANDBOX_AGENT_REPO, {
githubToken: auth?.githubToken ?? null,
});
return {
repo: SANDBOX_AGENT_REPO,
starredAt: Date.now(),
};
return await getOrganizationSummarySnapshot(c);
},
};
async refreshOrganizationSnapshot(c: any): Promise<void> {
await broadcastOrganizationSnapshot(c);
export async function applyGithubRepositoryProjectionMutation(c: any, input: { repoId: string; remoteUrl: string }): Promise<void> {
const now = Date.now();
await c.db
.insert(repos)
.values({
repoId: input.repoId,
remoteUrl: input.remoteUrl,
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: repos.repoId,
set: {
remoteUrl: input.remoteUrl,
updatedAt: now,
},
})
.run();
await refreshOrganizationSnapshotMutation(c);
}
export async function applyGithubDataProjectionMutation(
c: any,
input: {
connectedAccount: string;
installationStatus: string;
installationId: number | null;
syncStatus: string;
lastSyncLabel: string;
lastSyncAt: number | null;
syncGeneration: number;
syncPhase: string | null;
processedRepositoryCount: number;
totalRepositoryCount: number;
repositories: Array<{ fullName: string; cloneUrl: string; private: boolean }>;
},
): Promise<void> {
const existingRepos = await c.db.select({ repoId: repos.repoId }).from(repos).all();
const nextRepoIds = new Set<string>();
const now = Date.now();
async applyGithubRepositoryProjection(c: any, input: { repoId: string; remoteUrl: string }): Promise<void> {
const now = Date.now();
const existing = await c.db.select({ repoId: repos.repoId }).from(repos).where(eq(repos.repoId, input.repoId)).get();
const profile = await c.db
.select({ id: organizationProfile.id })
.from(organizationProfile)
.where(eq(organizationProfile.id, ORGANIZATION_PROFILE_ROW_ID))
.get();
if (profile) {
await c.db
.update(organizationProfile)
.set({
githubConnectedAccount: input.connectedAccount,
githubInstallationStatus: input.installationStatus,
githubSyncStatus: input.syncStatus,
githubInstallationId: input.installationId,
githubLastSyncLabel: input.lastSyncLabel,
githubLastSyncAt: input.lastSyncAt,
githubSyncGeneration: input.syncGeneration,
githubSyncPhase: input.syncPhase,
githubProcessedRepositoryCount: input.processedRepositoryCount,
githubTotalRepositoryCount: input.totalRepositoryCount,
updatedAt: now,
})
.where(eq(organizationProfile.id, ORGANIZATION_PROFILE_ROW_ID))
.run();
}
for (const repository of input.repositories) {
const repoId = repoIdFromRemote(repository.cloneUrl);
nextRepoIds.add(repoId);
await c.db
.insert(repos)
.values({
repoId: input.repoId,
remoteUrl: input.remoteUrl,
repoId,
remoteUrl: repository.cloneUrl,
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: repos.repoId,
set: {
remoteUrl: input.remoteUrl,
remoteUrl: repository.cloneUrl,
updatedAt: now,
},
})
.run();
await broadcastOrganizationSnapshot(c);
},
}
async applyGithubDataProjection(
c: any,
input: {
connectedAccount: string;
installationStatus: string;
installationId: number | null;
syncStatus: string;
lastSyncLabel: string;
lastSyncAt: number | null;
repositories: Array<{ fullName: string; cloneUrl: string; private: boolean }>;
},
): Promise<void> {
const existingRepos = await c.db.select({ repoId: repos.repoId, remoteUrl: repos.remoteUrl, updatedAt: repos.updatedAt }).from(repos).all();
const existingById = new Map(existingRepos.map((repo) => [repo.repoId, repo]));
const nextRepoIds = new Set<string>();
const now = Date.now();
for (const repository of input.repositories) {
const repoId = repoIdFromRemote(repository.cloneUrl);
nextRepoIds.add(repoId);
await c.db
.insert(repos)
.values({
repoId,
remoteUrl: repository.cloneUrl,
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: repos.repoId,
set: {
remoteUrl: repository.cloneUrl,
updatedAt: now,
},
})
.run();
await broadcastOrganizationSnapshot(c);
for (const repo of existingRepos) {
if (nextRepoIds.has(repo.repoId)) {
continue;
}
await c.db.delete(repos).where(eq(repos.repoId, repo.repoId)).run();
}
for (const repo of existingRepos) {
if (nextRepoIds.has(repo.repoId)) {
continue;
}
await c.db.delete(repos).where(eq(repos.repoId, repo.repoId)).run();
await broadcastOrganizationSnapshot(c);
}
await refreshOrganizationSnapshotMutation(c);
}
const profile = await c.db
.select({ id: organizationProfile.id })
.from(organizationProfile)
.where(eq(organizationProfile.id, ORGANIZATION_PROFILE_ROW_ID))
.get();
if (profile) {
await c.db
.update(organizationProfile)
.set({
githubConnectedAccount: input.connectedAccount,
githubInstallationStatus: input.installationStatus,
githubSyncStatus: input.syncStatus,
githubInstallationId: input.installationId,
githubLastSyncLabel: input.lastSyncLabel,
githubLastSyncAt: input.lastSyncAt,
updatedAt: now,
})
.where(eq(organizationProfile.id, ORGANIZATION_PROFILE_ROW_ID))
.run();
}
export async function applyGithubSyncProgressMutation(
c: any,
input: {
connectedAccount: string;
installationStatus: string;
installationId: number | null;
syncStatus: string;
lastSyncLabel: string;
lastSyncAt: number | null;
syncGeneration: number;
syncPhase: string | null;
processedRepositoryCount: number;
totalRepositoryCount: number;
},
): Promise<void> {
const profile = await c.db
.select({ id: organizationProfile.id })
.from(organizationProfile)
.where(eq(organizationProfile.id, ORGANIZATION_PROFILE_ROW_ID))
.get();
if (!profile) {
return;
}
async recordGithubWebhookReceipt(
c: any,
input: {
organizationId: string;
event: string;
action?: string | null;
receivedAt?: number;
},
): Promise<void> {
assertOrganization(c, input.organizationId);
await c.db
.update(organizationProfile)
.set({
githubConnectedAccount: input.connectedAccount,
githubInstallationStatus: input.installationStatus,
githubSyncStatus: input.syncStatus,
githubInstallationId: input.installationId,
githubLastSyncLabel: input.lastSyncLabel,
githubLastSyncAt: input.lastSyncAt,
githubSyncGeneration: input.syncGeneration,
githubSyncPhase: input.syncPhase,
githubProcessedRepositoryCount: input.processedRepositoryCount,
githubTotalRepositoryCount: input.totalRepositoryCount,
updatedAt: Date.now(),
})
.where(eq(organizationProfile.id, ORGANIZATION_PROFILE_ROW_ID))
.run();
const profile = await c.db
.select({ id: organizationProfile.id })
.from(organizationProfile)
.where(eq(organizationProfile.id, ORGANIZATION_PROFILE_ROW_ID))
.get();
if (!profile) {
return;
}
await refreshOrganizationSnapshotMutation(c);
}
await c.db
.update(organizationProfile)
.set({
githubLastWebhookAt: input.receivedAt ?? Date.now(),
githubLastWebhookEvent: input.action ? `${input.event}.${input.action}` : input.event,
})
.where(eq(organizationProfile.id, ORGANIZATION_PROFILE_ROW_ID))
.run();
export async function recordGithubWebhookReceiptMutation(
c: any,
input: {
organizationId: string;
event: string;
action?: string | null;
receivedAt?: number;
},
): Promise<void> {
assertOrganization(c, input.organizationId);
async getOrganizationSummary(c: any, input: OrganizationUseInput): Promise<OrganizationSummarySnapshot> {
assertOrganization(c, input.organizationId);
return await getOrganizationSummarySnapshot(c);
},
const profile = await c.db
.select({ id: organizationProfile.id })
.from(organizationProfile)
.where(eq(organizationProfile.id, ORGANIZATION_PROFILE_ROW_ID))
.get();
if (!profile) {
return;
}
async adminReconcileWorkspaceState(c: any, input: OrganizationUseInput): Promise<OrganizationSummarySnapshot> {
assertOrganization(c, input.organizationId);
return await reconcileWorkspaceProjection(c);
},
async createWorkspaceTask(c: any, input: TaskWorkspaceCreateTaskInput): Promise<{ taskId: string; sessionId?: string }> {
// Step 1: Create the task record (wait: true — local state mutations only).
const created = await organizationActions.createTask(c, {
organizationId: c.state.organizationId,
repoId: input.repoId,
task: input.task,
...(input.title ? { explicitTitle: input.title } : {}),
...(input.onBranch ? { onBranch: input.onBranch } : input.branch ? { explicitBranchName: input.branch } : {}),
...(input.model ? { agentType: agentTypeForModel(input.model) } : {}),
});
// Step 2: Enqueue session creation + initial message (wait: false).
// The task workflow creates the session record and sends the message in
// the background. The client observes progress via push events on the
// task subscription topic.
const task = await requireWorkspaceTask(c, input.repoId, created.taskId);
await task.createWorkspaceSessionAndSend({
model: input.model,
text: input.task,
});
return { taskId: created.taskId };
},
async markWorkspaceUnread(c: any, input: TaskWorkspaceSelectInput): Promise<void> {
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await task.markWorkspaceUnread({});
},
async renameWorkspaceTask(c: any, input: TaskWorkspaceRenameInput): Promise<void> {
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await task.renameWorkspaceTask(input);
},
async createWorkspaceSession(c: any, input: TaskWorkspaceSelectInput & { model?: string }): Promise<{ sessionId: string }> {
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
return await task.createWorkspaceSession({ ...(input.model ? { model: input.model } : {}) });
},
async renameWorkspaceSession(c: any, input: TaskWorkspaceRenameSessionInput): Promise<void> {
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await task.renameWorkspaceSession(input);
},
async setWorkspaceSessionUnread(c: any, input: TaskWorkspaceSetSessionUnreadInput): Promise<void> {
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await task.setWorkspaceSessionUnread(input);
},
async updateWorkspaceDraft(c: any, input: TaskWorkspaceUpdateDraftInput): Promise<void> {
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await task.updateWorkspaceDraft(input);
},
async changeWorkspaceModel(c: any, input: TaskWorkspaceChangeModelInput): Promise<void> {
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await task.changeWorkspaceModel(input);
},
async sendWorkspaceMessage(c: any, input: TaskWorkspaceSendMessageInput): Promise<void> {
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await task.sendWorkspaceMessage(input);
},
async stopWorkspaceSession(c: any, input: TaskWorkspaceSessionInput): Promise<void> {
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await task.stopWorkspaceSession(input);
},
async closeWorkspaceSession(c: any, input: TaskWorkspaceSessionInput): Promise<void> {
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await task.closeWorkspaceSession(input);
},
async publishWorkspacePr(c: any, input: TaskWorkspaceSelectInput): Promise<void> {
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await task.publishWorkspacePr({});
},
async revertWorkspaceFile(c: any, input: TaskWorkspaceDiffInput): Promise<void> {
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await task.revertWorkspaceFile(input);
},
async adminReloadGithubOrganization(c: any): Promise<void> {
await getOrCreateGithubData(c, c.state.organizationId).adminReloadOrganization({});
},
async adminReloadGithubPullRequests(c: any): Promise<void> {
await getOrCreateGithubData(c, c.state.organizationId).adminReloadAllPullRequests({});
},
async adminReloadGithubRepository(c: any, input: { repoId: string }): Promise<void> {
await getOrCreateGithubData(c, c.state.organizationId).reloadRepository(input);
},
async adminReloadGithubPullRequest(c: any, input: { repoId: string; prNumber: number }): Promise<void> {
await getOrCreateGithubData(c, c.state.organizationId).reloadPullRequest(input);
},
async listTasks(c: any, input: ListTasksInput): Promise<TaskSummary[]> {
assertOrganization(c, input.organizationId);
if (input.repoId) {
const repoRow = await c.db.select({ remoteUrl: repos.remoteUrl }).from(repos).where(eq(repos.repoId, input.repoId)).get();
if (!repoRow) {
throw new Error(`Unknown repo: ${input.repoId}`);
}
const repository = await getOrCreateRepository(c, c.state.organizationId, input.repoId, repoRow.remoteUrl);
return await repository.listTaskSummaries({ includeArchived: true });
}
return await collectAllTaskSummaries(c);
},
async getRepoOverview(c: any, input: RepoOverviewInput): Promise<RepoOverview> {
assertOrganization(c, input.organizationId);
const repoRow = await c.db.select({ remoteUrl: repos.remoteUrl }).from(repos).where(eq(repos.repoId, input.repoId)).get();
if (!repoRow) {
throw new Error(`Unknown repo: ${input.repoId}`);
}
const repository = await getOrCreateRepository(c, c.state.organizationId, input.repoId, repoRow.remoteUrl);
return await repository.getRepoOverview({});
},
async switchTask(c: any, input: { repoId?: string; taskId: string }): Promise<SwitchResult> {
const { repoId } = await resolveRepositoryForTask(c, input.taskId, input.repoId);
const h = getTaskHandle(c, c.state.organizationId, repoId, input.taskId);
const record = await h.get();
const switched = await h.switch();
return {
organizationId: c.state.organizationId,
taskId: input.taskId,
sandboxProviderId: record.sandboxProviderId,
switchTarget: switched.switchTarget,
};
},
async auditLog(c: any, input: HistoryQueryInput): Promise<AuditLogEvent[]> {
assertOrganization(c, input.organizationId);
const limit = input.limit ?? 20;
const repoRows = await c.db.select({ repoId: repos.repoId }).from(repos).all();
const allEvents: AuditLogEvent[] = [];
for (const row of repoRows) {
try {
const auditLog = await getOrCreateAuditLog(c, c.state.organizationId, row.repoId);
const items = await auditLog.list({
branch: input.branch,
taskId: input.taskId,
limit,
});
allEvents.push(...items);
} catch (error) {
logActorWarning("organization", "audit log lookup failed for repo", {
organizationId: c.state.organizationId,
repoId: row.repoId,
error: resolveErrorMessage(error),
});
}
}
allEvents.sort((a, b) => b.createdAt - a.createdAt);
return allEvents.slice(0, limit);
},
async getTask(c: any, input: GetTaskInput): Promise<TaskRecord> {
assertOrganization(c, input.organizationId);
const { repoId } = await resolveRepositoryForTask(c, input.taskId, input.repoId);
return await getTaskHandle(c, c.state.organizationId, repoId, input.taskId).get();
},
async attachTask(c: any, input: TaskProxyActionInput): Promise<{ target: string; sessionId: string | null }> {
assertOrganization(c, input.organizationId);
const { repoId } = await resolveRepositoryForTask(c, input.taskId, input.repoId);
const h = getTaskHandle(c, c.state.organizationId, repoId, input.taskId);
return await h.attach({ reason: input.reason });
},
async pushTask(c: any, input: TaskProxyActionInput): Promise<void> {
assertOrganization(c, input.organizationId);
const { repoId } = await resolveRepositoryForTask(c, input.taskId, input.repoId);
const h = getTaskHandle(c, c.state.organizationId, repoId, input.taskId);
await h.push({ reason: input.reason });
},
async syncTask(c: any, input: TaskProxyActionInput): Promise<void> {
assertOrganization(c, input.organizationId);
const { repoId } = await resolveRepositoryForTask(c, input.taskId, input.repoId);
const h = getTaskHandle(c, c.state.organizationId, repoId, input.taskId);
await h.sync({ reason: input.reason });
},
async mergeTask(c: any, input: TaskProxyActionInput): Promise<void> {
assertOrganization(c, input.organizationId);
const { repoId } = await resolveRepositoryForTask(c, input.taskId, input.repoId);
const h = getTaskHandle(c, c.state.organizationId, repoId, input.taskId);
await h.merge({ reason: input.reason });
},
async archiveTask(c: any, input: TaskProxyActionInput): Promise<void> {
assertOrganization(c, input.organizationId);
const { repoId } = await resolveRepositoryForTask(c, input.taskId, input.repoId);
const h = getTaskHandle(c, c.state.organizationId, repoId, input.taskId);
await h.archive({ reason: input.reason });
},
async killTask(c: any, input: TaskProxyActionInput): Promise<void> {
assertOrganization(c, input.organizationId);
const { repoId } = await resolveRepositoryForTask(c, input.taskId, input.repoId);
const h = getTaskHandle(c, c.state.organizationId, repoId, input.taskId);
await h.kill({ reason: input.reason });
},
};
await c.db
.update(organizationProfile)
.set({
githubLastWebhookAt: input.receivedAt ?? Date.now(),
githubLastWebhookEvent: input.action ? `${input.event}.${input.action}` : input.event,
})
.where(eq(organizationProfile.id, ORGANIZATION_PROFILE_ROW_ID))
.run();
}

View file

@ -0,0 +1 @@
export { organizationAppActions } from "../app-shell.js";

View file

@ -0,0 +1,323 @@
import {
and,
asc,
count as sqlCount,
desc,
eq,
gt,
gte,
inArray,
isNotNull,
isNull,
like,
lt,
lte,
ne,
notInArray,
or,
} from "drizzle-orm";
import { authAccountIndex, authEmailIndex, authSessionIndex, authVerification } from "../db/schema.js";
import { APP_SHELL_ORGANIZATION_ID } from "../constants.js";
function assertAppOrganization(c: any): void {
if (c.state.organizationId !== APP_SHELL_ORGANIZATION_ID) {
throw new Error(`App shell action requires organization ${APP_SHELL_ORGANIZATION_ID}, got ${c.state.organizationId}`);
}
}
function organizationAuthColumn(table: any, field: string): any {
const column = table[field];
if (!column) {
throw new Error(`Unknown auth table field: ${field}`);
}
return column;
}
function normalizeAuthValue(value: unknown): unknown {
if (value instanceof Date) {
return value.getTime();
}
if (Array.isArray(value)) {
return value.map((entry) => normalizeAuthValue(entry));
}
return value;
}
function organizationAuthClause(table: any, clause: { field: string; value: unknown; operator?: string }): any {
const column = organizationAuthColumn(table, clause.field);
const value = normalizeAuthValue(clause.value);
switch (clause.operator) {
case "ne":
return value === null ? isNotNull(column) : ne(column, value as any);
case "lt":
return lt(column, value as any);
case "lte":
return lte(column, value as any);
case "gt":
return gt(column, value as any);
case "gte":
return gte(column, value as any);
case "in":
return inArray(column, Array.isArray(value) ? (value as any[]) : [value as any]);
case "not_in":
return notInArray(column, Array.isArray(value) ? (value as any[]) : [value as any]);
case "contains":
return like(column, `%${String(value ?? "")}%`);
case "starts_with":
return like(column, `${String(value ?? "")}%`);
case "ends_with":
return like(column, `%${String(value ?? "")}`);
case "eq":
default:
return value === null ? isNull(column) : eq(column, value as any);
}
}
function organizationBetterAuthWhere(table: any, clauses: any[] | undefined): any {
if (!clauses || clauses.length === 0) {
return undefined;
}
let expr = organizationAuthClause(table, clauses[0]);
for (const clause of clauses.slice(1)) {
const next = organizationAuthClause(table, clause);
expr = clause.connector === "OR" ? or(expr, next) : and(expr, next);
}
return expr;
}
export async function betterAuthUpsertSessionIndexMutation(c: any, input: { sessionId: string; sessionToken: string; userId: string }) {
assertAppOrganization(c);
const now = Date.now();
await c.db
.insert(authSessionIndex)
.values({
sessionId: input.sessionId,
sessionToken: input.sessionToken,
userId: input.userId,
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: authSessionIndex.sessionId,
set: {
sessionToken: input.sessionToken,
userId: input.userId,
updatedAt: now,
},
})
.run();
return await c.db.select().from(authSessionIndex).where(eq(authSessionIndex.sessionId, input.sessionId)).get();
}
export async function betterAuthDeleteSessionIndexMutation(c: any, input: { sessionId?: string; sessionToken?: string }) {
assertAppOrganization(c);
const clauses = [
...(input.sessionId ? [{ field: "sessionId", value: input.sessionId }] : []),
...(input.sessionToken ? [{ field: "sessionToken", value: input.sessionToken }] : []),
];
if (clauses.length === 0) {
return;
}
const predicate = organizationBetterAuthWhere(authSessionIndex, clauses);
await c.db.delete(authSessionIndex).where(predicate!).run();
}
export async function betterAuthUpsertEmailIndexMutation(c: any, input: { email: string; userId: string }) {
assertAppOrganization(c);
const now = Date.now();
await c.db
.insert(authEmailIndex)
.values({
email: input.email,
userId: input.userId,
updatedAt: now,
})
.onConflictDoUpdate({
target: authEmailIndex.email,
set: {
userId: input.userId,
updatedAt: now,
},
})
.run();
return await c.db.select().from(authEmailIndex).where(eq(authEmailIndex.email, input.email)).get();
}
export async function betterAuthDeleteEmailIndexMutation(c: any, input: { email: string }) {
assertAppOrganization(c);
await c.db.delete(authEmailIndex).where(eq(authEmailIndex.email, input.email)).run();
}
export async function betterAuthUpsertAccountIndexMutation(
c: any,
input: { id: string; providerId: string; accountId: string; userId: string },
) {
assertAppOrganization(c);
const now = Date.now();
await c.db
.insert(authAccountIndex)
.values({
id: input.id,
providerId: input.providerId,
accountId: input.accountId,
userId: input.userId,
updatedAt: now,
})
.onConflictDoUpdate({
target: authAccountIndex.id,
set: {
providerId: input.providerId,
accountId: input.accountId,
userId: input.userId,
updatedAt: now,
},
})
.run();
return await c.db.select().from(authAccountIndex).where(eq(authAccountIndex.id, input.id)).get();
}
export async function betterAuthDeleteAccountIndexMutation(c: any, input: { id?: string; providerId?: string; accountId?: string }) {
assertAppOrganization(c);
if (input.id) {
await c.db.delete(authAccountIndex).where(eq(authAccountIndex.id, input.id)).run();
return;
}
if (input.providerId && input.accountId) {
await c.db
.delete(authAccountIndex)
.where(and(eq(authAccountIndex.providerId, input.providerId), eq(authAccountIndex.accountId, input.accountId)))
.run();
}
}
export async function betterAuthCreateVerificationMutation(c: any, input: { data: Record<string, unknown> }) {
assertAppOrganization(c);
await c.db.insert(authVerification).values(input.data as any).run();
return await c.db.select().from(authVerification).where(eq(authVerification.id, input.data.id as string)).get();
}
export async function betterAuthUpdateVerificationMutation(c: any, input: { where: any[]; update: Record<string, unknown> }) {
assertAppOrganization(c);
const predicate = organizationBetterAuthWhere(authVerification, input.where);
if (!predicate) {
return null;
}
await c.db.update(authVerification).set(input.update as any).where(predicate).run();
return await c.db.select().from(authVerification).where(predicate).get();
}
export async function betterAuthUpdateManyVerificationMutation(c: any, input: { where: any[]; update: Record<string, unknown> }) {
assertAppOrganization(c);
const predicate = organizationBetterAuthWhere(authVerification, input.where);
if (!predicate) {
return 0;
}
await c.db.update(authVerification).set(input.update as any).where(predicate).run();
const row = await c.db.select({ value: sqlCount() }).from(authVerification).where(predicate).get();
return row?.value ?? 0;
}
export async function betterAuthDeleteVerificationMutation(c: any, input: { where: any[] }) {
assertAppOrganization(c);
const predicate = organizationBetterAuthWhere(authVerification, input.where);
if (!predicate) {
return;
}
await c.db.delete(authVerification).where(predicate).run();
}
export async function betterAuthDeleteManyVerificationMutation(c: any, input: { where: any[] }) {
assertAppOrganization(c);
const predicate = organizationBetterAuthWhere(authVerification, input.where);
if (!predicate) {
return 0;
}
const rows = await c.db.select().from(authVerification).where(predicate).all();
await c.db.delete(authVerification).where(predicate).run();
return rows.length;
}
export const organizationBetterAuthActions = {
async betterAuthFindSessionIndex(c: any, input: { sessionId?: string; sessionToken?: string }) {
assertAppOrganization(c);
const clauses = [
...(input.sessionId ? [{ field: "sessionId", value: input.sessionId }] : []),
...(input.sessionToken ? [{ field: "sessionToken", value: input.sessionToken }] : []),
];
if (clauses.length === 0) {
return null;
}
const predicate = organizationBetterAuthWhere(authSessionIndex, clauses);
return await c.db.select().from(authSessionIndex).where(predicate!).get();
},
async betterAuthFindEmailIndex(c: any, input: { email: string }) {
assertAppOrganization(c);
return await c.db.select().from(authEmailIndex).where(eq(authEmailIndex.email, input.email)).get();
},
async betterAuthFindAccountIndex(c: any, input: { id?: string; providerId?: string; accountId?: string }) {
assertAppOrganization(c);
if (input.id) {
return await c.db.select().from(authAccountIndex).where(eq(authAccountIndex.id, input.id)).get();
}
if (!input.providerId || !input.accountId) {
return null;
}
return await c.db
.select()
.from(authAccountIndex)
.where(and(eq(authAccountIndex.providerId, input.providerId), eq(authAccountIndex.accountId, input.accountId)))
.get();
},
async betterAuthFindOneVerification(c: any, input: { where: any[] }) {
assertAppOrganization(c);
const predicate = organizationBetterAuthWhere(authVerification, input.where);
return predicate ? await c.db.select().from(authVerification).where(predicate).get() : null;
},
async betterAuthFindManyVerification(c: any, input: { where?: any[]; limit?: number; sortBy?: any; offset?: number }) {
assertAppOrganization(c);
const predicate = organizationBetterAuthWhere(authVerification, input.where);
let query = c.db.select().from(authVerification);
if (predicate) {
query = query.where(predicate);
}
if (input.sortBy?.field) {
const column = organizationAuthColumn(authVerification, input.sortBy.field);
query = query.orderBy(input.sortBy.direction === "asc" ? asc(column) : desc(column));
}
if (typeof input.limit === "number") {
query = query.limit(input.limit);
}
if (typeof input.offset === "number") {
query = query.offset(input.offset);
}
return await query.all();
},
async betterAuthCountVerification(c: any, input: { where?: any[] }) {
assertAppOrganization(c);
const predicate = organizationBetterAuthWhere(authVerification, input.where);
const row = predicate
? await c.db.select({ value: sqlCount() }).from(authVerification).where(predicate).get()
: await c.db.select({ value: sqlCount() }).from(authVerification).get();
return row?.value ?? 0;
},
};

View file

@ -0,0 +1,91 @@
import { desc } from "drizzle-orm";
import type { FoundryAppSnapshot } from "@sandbox-agent/foundry-shared";
import { getOrCreateGithubData, getOrCreateOrganization } from "../../handles.js";
import { authSessionIndex } from "../db/schema.js";
import { githubDataWorkflowQueueName } from "../../github-data/workflow.js";
import {
assertAppOrganization,
buildAppSnapshot,
requireEligibleOrganization,
requireSignedInSession,
} from "../app-shell.js";
import { getBetterAuthService } from "../../../services/better-auth.js";
import { expectQueueResponse } from "../../../services/queue.js";
import { organizationWorkflowQueueName } from "../queues.js";
export const organizationGithubActions = {
async resolveAppGithubToken(
c: any,
input: { organizationId: string; requireRepoScope?: boolean },
): Promise<{ accessToken: string; scopes: string[] } | null> {
assertAppOrganization(c);
const auth = getBetterAuthService();
const rows = await c.db.select().from(authSessionIndex).orderBy(desc(authSessionIndex.updatedAt)).all();
for (const row of rows) {
const authState = await auth.getAuthState(row.sessionId);
if (authState?.sessionState?.activeOrganizationId !== input.organizationId) {
continue;
}
const token = await auth.getAccessTokenForSession(row.sessionId);
if (!token?.accessToken) {
continue;
}
const scopes = token.scopes;
if (input.requireRepoScope !== false && scopes.length > 0 && !scopes.some((scope) => scope === "repo" || scope.startsWith("repo:"))) {
continue;
}
return {
accessToken: token.accessToken,
scopes,
};
}
return null;
},
async triggerAppRepoImport(c: any, input: { sessionId: string; organizationId: string }): Promise<FoundryAppSnapshot> {
assertAppOrganization(c);
const session = await requireSignedInSession(c, input.sessionId);
requireEligibleOrganization(session, input.organizationId);
const githubData = await getOrCreateGithubData(c, input.organizationId);
const summary = await githubData.getSummary({});
if (summary.syncStatus === "syncing") {
return await buildAppSnapshot(c, input.sessionId);
}
const organizationHandle = await getOrCreateOrganization(c, input.organizationId);
await expectQueueResponse<{ ok: true }>(
await organizationHandle.send(
organizationWorkflowQueueName("organization.command.shell.sync_started.mark"),
{ label: "Importing repository catalog..." },
{ wait: true, timeout: 10_000 },
),
);
await expectQueueResponse<{ ok: true }>(
await organizationHandle.send(organizationWorkflowQueueName("organization.command.snapshot.broadcast"), {}, { wait: true, timeout: 10_000 }),
);
await githubData.send("githubData.command.syncRepos", { label: "Importing repository catalog..." }, { wait: false });
return await buildAppSnapshot(c, input.sessionId);
},
async adminReloadGithubOrganization(c: any): Promise<void> {
const githubData = await getOrCreateGithubData(c, c.state.organizationId);
await expectQueueResponse<{ ok: true }>(
await githubData.send(githubDataWorkflowQueueName("githubData.command.syncRepos"), { label: "Reloading GitHub organization..." }, { wait: true, timeout: 10_000 }),
);
},
async adminReloadGithubRepository(c: any, input: { repoId: string }): Promise<void> {
const githubData = await getOrCreateGithubData(c, c.state.organizationId);
await expectQueueResponse<unknown>(
await githubData.send(githubDataWorkflowQueueName("githubData.command.reloadRepository"), input, { wait: true, timeout: 10_000 }),
);
},
};

View file

@ -0,0 +1,82 @@
import { randomUUID } from "node:crypto";
import type { FoundryAppSnapshot, StarSandboxAgentRepoInput, StarSandboxAgentRepoResult } from "@sandbox-agent/foundry-shared";
import { getOrCreateGithubData, getOrCreateOrganization } from "../../handles.js";
import {
assertAppOrganization,
buildAppSnapshot,
getOrganizationState,
requireEligibleOrganization,
requireSignedInSession,
} from "../app-shell.js";
import { getBetterAuthService } from "../../../services/better-auth.js";
import { getActorRuntimeContext } from "../../context.js";
import { resolveOrganizationGithubAuth } from "../../../services/github-auth.js";
const SANDBOX_AGENT_REPO = "rivet-dev/sandbox-agent";
export const organizationOnboardingActions = {
async skipAppStarterRepo(c: any, input: { sessionId: string }): Promise<FoundryAppSnapshot> {
assertAppOrganization(c);
const session = await requireSignedInSession(c, input.sessionId);
await getBetterAuthService().upsertUserProfile(session.authUserId, {
starterRepoStatus: "skipped",
starterRepoSkippedAt: Date.now(),
starterRepoStarredAt: null,
});
return await buildAppSnapshot(c, input.sessionId);
},
async starAppStarterRepo(c: any, input: { sessionId: string; organizationId: string }): Promise<FoundryAppSnapshot> {
assertAppOrganization(c);
const session = await requireSignedInSession(c, input.sessionId);
requireEligibleOrganization(session, input.organizationId);
const organization = await getOrCreateOrganization(c, input.organizationId);
await organization.starSandboxAgentRepo({
organizationId: input.organizationId,
});
await getBetterAuthService().upsertUserProfile(session.authUserId, {
starterRepoStatus: "starred",
starterRepoStarredAt: Date.now(),
starterRepoSkippedAt: null,
});
return await buildAppSnapshot(c, input.sessionId);
},
async selectAppOrganization(c: any, input: { sessionId: string; organizationId: string }): Promise<FoundryAppSnapshot> {
assertAppOrganization(c);
const session = await requireSignedInSession(c, input.sessionId);
requireEligibleOrganization(session, input.organizationId);
await getBetterAuthService().setActiveOrganization(input.sessionId, input.organizationId);
await getOrCreateGithubData(c, input.organizationId);
return await buildAppSnapshot(c, input.sessionId);
},
async beginAppGithubInstall(c: any, input: { sessionId: string; organizationId: string }): Promise<{ url: string }> {
assertAppOrganization(c);
const session = await requireSignedInSession(c, input.sessionId);
requireEligibleOrganization(session, input.organizationId);
const { appShell } = getActorRuntimeContext();
const organizationHandle = await getOrCreateOrganization(c, input.organizationId);
const organizationState = await getOrganizationState(organizationHandle);
if (organizationState.snapshot.kind !== "organization") {
return {
url: `${appShell.appUrl}/organizations/${input.organizationId}`,
};
}
return {
url: await appShell.github.buildInstallationUrl(organizationState.githubLogin, randomUUID()),
};
},
async starSandboxAgentRepo(c: any, input: StarSandboxAgentRepoInput): Promise<StarSandboxAgentRepoResult> {
const { driver } = getActorRuntimeContext();
const auth = await resolveOrganizationGithubAuth(c, c.state.organizationId);
await driver.github.starRepository(SANDBOX_AGENT_REPO, {
githubToken: auth?.githubToken ?? null,
});
return {
repo: SANDBOX_AGENT_REPO,
starredAt: Date.now(),
};
},
};

View file

@ -0,0 +1,61 @@
import type { FoundryAppSnapshot, UpdateFoundryOrganizationProfileInput, WorkspaceModelId } from "@sandbox-agent/foundry-shared";
import { getBetterAuthService } from "../../../services/better-auth.js";
import { getOrCreateOrganization } from "../../handles.js";
import { expectQueueResponse } from "../../../services/queue.js";
import {
assertAppOrganization,
assertOrganizationShell,
buildAppSnapshot,
buildOrganizationState,
buildOrganizationStateIfInitialized,
requireEligibleOrganization,
requireSignedInSession,
} from "../app-shell.js";
import { organizationWorkflowQueueName } from "../queues.js";
export const organizationShellActions = {
async getAppSnapshot(c: any, input: { sessionId: string }): Promise<FoundryAppSnapshot> {
return await buildAppSnapshot(c, input.sessionId);
},
async setAppDefaultModel(c: any, input: { sessionId: string; defaultModel: WorkspaceModelId }): Promise<FoundryAppSnapshot> {
assertAppOrganization(c);
const session = await requireSignedInSession(c, input.sessionId);
await getBetterAuthService().upsertUserProfile(session.authUserId, {
defaultModel: input.defaultModel,
});
return await buildAppSnapshot(c, input.sessionId);
},
async updateAppOrganizationProfile(
c: any,
input: { sessionId: string; organizationId: string } & UpdateFoundryOrganizationProfileInput,
): Promise<FoundryAppSnapshot> {
assertAppOrganization(c);
const session = await requireSignedInSession(c, input.sessionId);
requireEligibleOrganization(session, input.organizationId);
const organization = await getOrCreateOrganization(c, input.organizationId);
await expectQueueResponse<{ ok: true }>(
await organization.send(
organizationWorkflowQueueName("organization.command.shell.profile.update"),
{
displayName: input.displayName,
slug: input.slug,
primaryDomain: input.primaryDomain,
},
{ wait: true, timeout: 10_000 },
),
);
return await buildAppSnapshot(c, input.sessionId);
},
async getOrganizationShellState(c: any): Promise<any> {
assertOrganizationShell(c);
return await buildOrganizationState(c);
},
async getOrganizationShellStateIfInitialized(c: any): Promise<any | null> {
assertOrganizationShell(c);
return await buildOrganizationStateIfInitialized(c);
},
};

View file

@ -0,0 +1,387 @@
// @ts-nocheck
import { desc, eq } from "drizzle-orm";
import type {
AuditLogEvent,
CreateTaskInput,
HistoryQueryInput,
ListTasksInput,
RepoOverview,
SwitchResult,
TaskRecord,
TaskSummary,
TaskWorkspaceChangeModelInput,
TaskWorkspaceCreateTaskInput,
TaskWorkspaceDiffInput,
TaskWorkspaceRenameInput,
TaskWorkspaceRenameSessionInput,
TaskWorkspaceSelectInput,
TaskWorkspaceSetSessionUnreadInput,
TaskWorkspaceSendMessageInput,
TaskWorkspaceSessionInput,
TaskWorkspaceUpdateDraftInput,
} from "@sandbox-agent/foundry-shared";
import { getActorRuntimeContext } from "../../context.js";
import { getOrCreateAuditLog, getOrCreateRepository, getTask as getTaskHandle, selfOrganization } from "../../handles.js";
import { defaultSandboxProviderId } from "../../../sandbox-config.js";
import { expectQueueResponse } from "../../../services/queue.js";
import { logActorWarning, resolveErrorMessage } from "../../logging.js";
import { repositoryWorkflowQueueName } from "../../repository/workflow.js";
import { taskWorkflowQueueName } from "../../task/workflow/index.js";
import { repos } from "../db/schema.js";
import { organizationWorkflowQueueName } from "../queues.js";
function assertOrganization(c: { state: { organizationId: string } }, organizationId: string): void {
if (organizationId !== c.state.organizationId) {
throw new Error(`Organization actor mismatch: actor=${c.state.organizationId} command=${organizationId}`);
}
}
async function requireRepositoryForTask(c: any, repoId: string) {
const repoRow = await c.db.select({ repoId: repos.repoId }).from(repos).where(eq(repos.repoId, repoId)).get();
if (!repoRow) {
throw new Error(`Unknown repo: ${repoId}`);
}
return await getOrCreateRepository(c, c.state.organizationId, repoId);
}
async function requireWorkspaceTask(c: any, repoId: string, taskId: string) {
return getTaskHandle(c, c.state.organizationId, repoId, taskId);
}
async function collectAllTaskSummaries(c: any): Promise<TaskSummary[]> {
const repoRows = await c.db.select({ repoId: repos.repoId, remoteUrl: repos.remoteUrl }).from(repos).orderBy(desc(repos.updatedAt)).all();
const all: TaskSummary[] = [];
for (const row of repoRows) {
try {
const repository = await getOrCreateRepository(c, c.state.organizationId, row.repoId);
const snapshot = await repository.listTaskSummaries({ includeArchived: true });
all.push(...snapshot);
} catch (error) {
logActorWarning("organization", "failed collecting tasks for repo", {
organizationId: c.state.organizationId,
repoId: row.repoId,
error: resolveErrorMessage(error),
});
}
}
all.sort((a, b) => b.updatedAt - a.updatedAt);
return all;
}
interface GetTaskInput {
organizationId: string;
repoId: string;
taskId: string;
}
interface TaskProxyActionInput extends GetTaskInput {
reason?: string;
}
interface RepoOverviewInput {
organizationId: string;
repoId: string;
}
export async function createTaskMutation(c: any, input: CreateTaskInput): Promise<TaskRecord> {
assertOrganization(c, input.organizationId);
const { config } = getActorRuntimeContext();
const sandboxProviderId = input.sandboxProviderId ?? defaultSandboxProviderId(config);
await requireRepositoryForTask(c, input.repoId);
const repository = await getOrCreateRepository(c, c.state.organizationId, input.repoId);
return expectQueueResponse<TaskRecord>(
await repository.send(
repositoryWorkflowQueueName("repository.command.createTask"),
{
task: input.task,
sandboxProviderId,
explicitTitle: input.explicitTitle ?? null,
explicitBranchName: input.explicitBranchName ?? null,
onBranch: input.onBranch ?? null,
},
{
wait: true,
timeout: 10_000,
},
),
);
}
export const organizationTaskActions = {
async createTask(c: any, input: CreateTaskInput): Promise<TaskRecord> {
const self = selfOrganization(c);
return expectQueueResponse<TaskRecord>(
await self.send(organizationWorkflowQueueName("organization.command.createTask"), input, {
wait: true,
timeout: 10_000,
}),
);
},
async createWorkspaceTask(c: any, input: TaskWorkspaceCreateTaskInput): Promise<{ taskId: string; sessionId?: string }> {
const created = await organizationTaskActions.createTask(c, {
organizationId: c.state.organizationId,
repoId: input.repoId,
task: input.task,
...(input.title ? { explicitTitle: input.title } : {}),
...(input.onBranch ? { onBranch: input.onBranch } : input.branch ? { explicitBranchName: input.branch } : {}),
});
const task = await requireWorkspaceTask(c, input.repoId, created.taskId);
await task.send(
taskWorkflowQueueName("task.command.workspace.create_session_and_send"),
{
model: input.model,
text: input.task,
authSessionId: input.authSessionId,
},
{ wait: false },
);
return { taskId: created.taskId };
},
async markWorkspaceUnread(c: any, input: TaskWorkspaceSelectInput): Promise<void> {
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await expectQueueResponse<{ ok: true }>(
await task.send(taskWorkflowQueueName("task.command.workspace.mark_unread"), { authSessionId: input.authSessionId }, { wait: true, timeout: 10_000 }),
);
},
async renameWorkspaceTask(c: any, input: TaskWorkspaceRenameInput): Promise<void> {
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await expectQueueResponse<{ ok: true }>(
await task.send(taskWorkflowQueueName("task.command.workspace.rename_task"), { value: input.value }, { wait: true, timeout: 20_000 }),
);
},
async createWorkspaceSession(c: any, input: TaskWorkspaceSelectInput & { model?: string }): Promise<{ sessionId: string }> {
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
return await expectQueueResponse<{ sessionId: string }>(
await task.send(
taskWorkflowQueueName("task.command.workspace.create_session"),
{
...(input.model ? { model: input.model } : {}),
...(input.authSessionId ? { authSessionId: input.authSessionId } : {}),
},
{ wait: true, timeout: 10_000 },
),
);
},
async renameWorkspaceSession(c: any, input: TaskWorkspaceRenameSessionInput): Promise<void> {
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await expectQueueResponse<{ ok: true }>(
await task.send(
taskWorkflowQueueName("task.command.workspace.rename_session"),
{ sessionId: input.sessionId, title: input.title, authSessionId: input.authSessionId },
{ wait: true, timeout: 10_000 },
),
);
},
async selectWorkspaceSession(c: any, input: TaskWorkspaceSessionInput): Promise<void> {
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await expectQueueResponse<{ ok: true }>(
await task.send(
taskWorkflowQueueName("task.command.workspace.select_session"),
{ sessionId: input.sessionId, authSessionId: input.authSessionId },
{ wait: true, timeout: 10_000 },
),
);
},
async setWorkspaceSessionUnread(c: any, input: TaskWorkspaceSetSessionUnreadInput): Promise<void> {
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await expectQueueResponse<{ ok: true }>(
await task.send(
taskWorkflowQueueName("task.command.workspace.set_session_unread"),
{ sessionId: input.sessionId, unread: input.unread, authSessionId: input.authSessionId },
{ wait: true, timeout: 10_000 },
),
);
},
async updateWorkspaceDraft(c: any, input: TaskWorkspaceUpdateDraftInput): Promise<void> {
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await task.send(
taskWorkflowQueueName("task.command.workspace.update_draft"),
{
sessionId: input.sessionId,
text: input.text,
attachments: input.attachments,
authSessionId: input.authSessionId,
},
{ wait: false },
);
},
async changeWorkspaceModel(c: any, input: TaskWorkspaceChangeModelInput): Promise<void> {
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await expectQueueResponse<{ ok: true }>(
await task.send(
taskWorkflowQueueName("task.command.workspace.change_model"),
{ sessionId: input.sessionId, model: input.model, authSessionId: input.authSessionId },
{ wait: true, timeout: 10_000 },
),
);
},
async sendWorkspaceMessage(c: any, input: TaskWorkspaceSendMessageInput): Promise<void> {
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await task.send(
taskWorkflowQueueName("task.command.workspace.send_message"),
{
sessionId: input.sessionId,
text: input.text,
attachments: input.attachments,
authSessionId: input.authSessionId,
},
{ wait: false },
);
},
async stopWorkspaceSession(c: any, input: TaskWorkspaceSessionInput): Promise<void> {
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await task.send(
taskWorkflowQueueName("task.command.workspace.stop_session"),
{ sessionId: input.sessionId, authSessionId: input.authSessionId },
{ wait: false },
);
},
async closeWorkspaceSession(c: any, input: TaskWorkspaceSessionInput): Promise<void> {
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await task.send(
taskWorkflowQueueName("task.command.workspace.close_session"),
{ sessionId: input.sessionId, authSessionId: input.authSessionId },
{ wait: false },
);
},
async publishWorkspacePr(c: any, input: TaskWorkspaceSelectInput): Promise<void> {
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await task.send(taskWorkflowQueueName("task.command.workspace.publish_pr"), {}, { wait: false });
},
async revertWorkspaceFile(c: any, input: TaskWorkspaceDiffInput): Promise<void> {
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
await task.send(taskWorkflowQueueName("task.command.workspace.revert_file"), input, { wait: false });
},
async getRepoOverview(c: any, input: RepoOverviewInput): Promise<RepoOverview> {
assertOrganization(c, input.organizationId);
const repository = await requireRepositoryForTask(c, input.repoId);
return await repository.getRepoOverview({});
},
async listTasks(c: any, input: ListTasksInput): Promise<TaskSummary[]> {
assertOrganization(c, input.organizationId);
if (input.repoId) {
const repository = await requireRepositoryForTask(c, input.repoId);
return await repository.listTaskSummaries({ includeArchived: true });
}
return await collectAllTaskSummaries(c);
},
async switchTask(c: any, input: { repoId: string; taskId: string }): Promise<SwitchResult> {
await requireRepositoryForTask(c, input.repoId);
const h = getTaskHandle(c, c.state.organizationId, input.repoId, input.taskId);
const record = await h.get();
const switched = await expectQueueResponse<{ switchTarget: string }>(
await h.send(taskWorkflowQueueName("task.command.switch"), {}, { wait: true, timeout: 10_000 }),
);
return {
organizationId: c.state.organizationId,
taskId: input.taskId,
sandboxProviderId: record.sandboxProviderId,
switchTarget: switched.switchTarget,
};
},
async auditLog(c: any, input: HistoryQueryInput): Promise<AuditLogEvent[]> {
assertOrganization(c, input.organizationId);
const limit = input.limit ?? 20;
const repoRows = await c.db.select({ repoId: repos.repoId }).from(repos).orderBy(desc(repos.updatedAt)).all();
const allEvents: AuditLogEvent[] = [];
for (const row of repoRows) {
try {
const auditLog = await getOrCreateAuditLog(c, c.state.organizationId, row.repoId);
const items = await auditLog.list({
branch: input.branch,
taskId: input.taskId,
limit,
});
allEvents.push(...items);
} catch (error) {
logActorWarning("organization", "audit log lookup failed for repo", {
organizationId: c.state.organizationId,
repoId: row.repoId,
error: resolveErrorMessage(error),
});
}
}
allEvents.sort((a, b) => b.createdAt - a.createdAt);
return allEvents.slice(0, limit);
},
async getTask(c: any, input: GetTaskInput): Promise<TaskRecord> {
assertOrganization(c, input.organizationId);
await requireRepositoryForTask(c, input.repoId);
return await getTaskHandle(c, c.state.organizationId, input.repoId, input.taskId).get();
},
async attachTask(c: any, input: TaskProxyActionInput): Promise<{ target: string; sessionId: string | null }> {
assertOrganization(c, input.organizationId);
await requireRepositoryForTask(c, input.repoId);
const h = getTaskHandle(c, c.state.organizationId, input.repoId, input.taskId);
return await expectQueueResponse<{ target: string; sessionId: string | null }>(
await h.send(taskWorkflowQueueName("task.command.attach"), { reason: input.reason }, { wait: true, timeout: 10_000 }),
);
},
async pushTask(c: any, input: TaskProxyActionInput): Promise<void> {
assertOrganization(c, input.organizationId);
await requireRepositoryForTask(c, input.repoId);
const h = getTaskHandle(c, c.state.organizationId, input.repoId, input.taskId);
await h.send(taskWorkflowQueueName("task.command.push"), { reason: input.reason }, { wait: false });
},
async syncTask(c: any, input: TaskProxyActionInput): Promise<void> {
assertOrganization(c, input.organizationId);
await requireRepositoryForTask(c, input.repoId);
const h = getTaskHandle(c, c.state.organizationId, input.repoId, input.taskId);
await h.send(taskWorkflowQueueName("task.command.sync"), { reason: input.reason }, { wait: false });
},
async mergeTask(c: any, input: TaskProxyActionInput): Promise<void> {
assertOrganization(c, input.organizationId);
await requireRepositoryForTask(c, input.repoId);
const h = getTaskHandle(c, c.state.organizationId, input.repoId, input.taskId);
await h.send(taskWorkflowQueueName("task.command.merge"), { reason: input.reason }, { wait: false });
},
async archiveTask(c: any, input: TaskProxyActionInput): Promise<void> {
assertOrganization(c, input.organizationId);
await requireRepositoryForTask(c, input.repoId);
const h = getTaskHandle(c, c.state.organizationId, input.repoId, input.taskId);
await h.send(taskWorkflowQueueName("task.command.archive"), { reason: input.reason }, { wait: false });
},
async killTask(c: any, input: TaskProxyActionInput): Promise<void> {
assertOrganization(c, input.organizationId);
await requireRepositoryForTask(c, input.repoId);
const h = getTaskHandle(c, c.state.organizationId, input.repoId, input.taskId);
await h.send(taskWorkflowQueueName("task.command.kill"), { reason: input.reason }, { wait: false });
},
};

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1 @@
export const APP_SHELL_ORGANIZATION_ID = "app";

View file

@ -56,6 +56,10 @@ CREATE TABLE `organization_profile` (
`github_last_sync_at` integer,
`github_last_webhook_at` integer,
`github_last_webhook_event` text,
`github_sync_generation` integer NOT NULL,
`github_sync_phase` text,
`github_processed_repository_count` integer NOT NULL,
`github_total_repository_count` integer NOT NULL,
`stripe_customer_id` text,
`stripe_subscription_id` text,
`stripe_price_id` text,
@ -86,8 +90,3 @@ CREATE TABLE `stripe_lookup` (
`organization_id` text NOT NULL,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE `task_lookup` (
`task_id` text PRIMARY KEY NOT NULL,
`repo_id` text NOT NULL
);

View file

@ -373,6 +373,34 @@
"notNull": false,
"autoincrement": false
},
"github_sync_generation": {
"name": "github_sync_generation",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"github_sync_phase": {
"name": "github_sync_phase",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"github_processed_repository_count": {
"name": "github_processed_repository_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"github_total_repository_count": {
"name": "github_total_repository_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"stripe_customer_id": {
"name": "stripe_customer_id",
"type": "text",
@ -549,30 +577,6 @@
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"task_lookup": {
"name": "task_lookup",
"columns": {
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"repo_id": {
"name": "repo_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},

View file

@ -10,6 +10,12 @@ const journal = {
tag: "0000_melted_viper",
breakpoints: true,
},
{
idx: 1,
when: 1773907201000,
tag: "0001_github_sync_progress",
breakpoints: true,
},
],
} as const;
@ -104,6 +110,14 @@ CREATE TABLE \`stripe_lookup\` (
\`organization_id\` text NOT NULL,
\`updated_at\` integer NOT NULL
);
`,
m0001: `ALTER TABLE \`organization_profile\` ADD \`github_sync_generation\` integer NOT NULL DEFAULT 0;
--> statement-breakpoint
ALTER TABLE \`organization_profile\` ADD \`github_sync_phase\` text;
--> statement-breakpoint
ALTER TABLE \`organization_profile\` ADD \`github_processed_repository_count\` integer NOT NULL DEFAULT 0;
--> statement-breakpoint
ALTER TABLE \`organization_profile\` ADD \`github_total_repository_count\` integer NOT NULL DEFAULT 0;
`,
} as const,
};

View file

@ -36,6 +36,10 @@ export const organizationProfile = sqliteTable(
githubLastSyncAt: integer("github_last_sync_at"),
githubLastWebhookAt: integer("github_last_webhook_at"),
githubLastWebhookEvent: text("github_last_webhook_event"),
githubSyncGeneration: integer("github_sync_generation").notNull(),
githubSyncPhase: text("github_sync_phase"),
githubProcessedRepositoryCount: integer("github_processed_repository_count").notNull(),
githubTotalRepositoryCount: integer("github_total_repository_count").notNull(),
stripeCustomerId: text("stripe_customer_id"),
stripeSubscriptionId: text("stripe_subscription_id"),
stripePriceId: text("stripe_price_id"),

View file

@ -1,7 +1,9 @@
import { actor, queue } from "rivetkit";
import { workflow } from "rivetkit/workflow";
import { organizationDb } from "./db/db.js";
import { runOrganizationWorkflow, ORGANIZATION_QUEUE_NAMES, organizationActions } from "./actions.js";
import { organizationActions } from "./actions.js";
import { ORGANIZATION_QUEUE_NAMES } from "./queues.js";
import { runOrganizationWorkflow } from "./workflow.js";
export const organization = actor({
db: organizationDb,

View file

@ -0,0 +1,36 @@
export const ORGANIZATION_QUEUE_NAMES = [
"organization.command.createTask",
"organization.command.snapshot.broadcast",
"organization.command.syncGithubSession",
"organization.command.better_auth.session_index.upsert",
"organization.command.better_auth.session_index.delete",
"organization.command.better_auth.email_index.upsert",
"organization.command.better_auth.email_index.delete",
"organization.command.better_auth.account_index.upsert",
"organization.command.better_auth.account_index.delete",
"organization.command.better_auth.verification.create",
"organization.command.better_auth.verification.update",
"organization.command.better_auth.verification.update_many",
"organization.command.better_auth.verification.delete",
"organization.command.better_auth.verification.delete_many",
"organization.command.github.repository_projection.apply",
"organization.command.github.data_projection.apply",
"organization.command.github.sync_progress.apply",
"organization.command.github.webhook_receipt.record",
"organization.command.github.organization_shell.sync_from_github",
"organization.command.shell.profile.update",
"organization.command.shell.sync_started.mark",
"organization.command.billing.stripe_customer.apply",
"organization.command.billing.stripe_subscription.apply",
"organization.command.billing.free_plan.apply",
"organization.command.billing.payment_method.set",
"organization.command.billing.status.set",
"organization.command.billing.invoice.upsert",
"organization.command.billing.seat_usage.record",
] as const;
export type OrganizationQueueName = (typeof ORGANIZATION_QUEUE_NAMES)[number];
export function organizationWorkflowQueueName(name: OrganizationQueueName): OrganizationQueueName {
return name;
}

View file

@ -0,0 +1,349 @@
// @ts-nocheck
import { Loop } from "rivetkit/workflow";
import { logActorWarning, resolveErrorMessage } from "../logging.js";
import type { CreateTaskInput } from "@sandbox-agent/foundry-shared";
import {
applyGithubDataProjectionMutation,
applyGithubRepositoryProjectionMutation,
applyGithubSyncProgressMutation,
createTaskMutation,
recordGithubWebhookReceiptMutation,
refreshOrganizationSnapshotMutation,
} from "./actions.js";
import {
betterAuthCreateVerificationMutation,
betterAuthDeleteAccountIndexMutation,
betterAuthDeleteEmailIndexMutation,
betterAuthDeleteManyVerificationMutation,
betterAuthDeleteSessionIndexMutation,
betterAuthDeleteVerificationMutation,
betterAuthUpdateManyVerificationMutation,
betterAuthUpdateVerificationMutation,
betterAuthUpsertAccountIndexMutation,
betterAuthUpsertEmailIndexMutation,
betterAuthUpsertSessionIndexMutation,
} from "./actions/better-auth.js";
import {
applyOrganizationFreePlanMutation,
applyOrganizationStripeCustomerMutation,
applyOrganizationStripeSubscriptionMutation,
markOrganizationSyncStartedMutation,
recordOrganizationSeatUsageMutation,
setOrganizationBillingPaymentMethodMutation,
setOrganizationBillingStatusMutation,
syncOrganizationShellFromGithubMutation,
updateOrganizationShellProfileMutation,
upsertOrganizationInvoiceMutation,
} from "./app-shell.js";
import { ORGANIZATION_QUEUE_NAMES } from "./queues.js";
export async function runOrganizationWorkflow(ctx: any): Promise<void> {
await ctx.loop("organization-command-loop", async (loopCtx: any) => {
const msg = await loopCtx.queue.next("next-organization-command", {
names: [...ORGANIZATION_QUEUE_NAMES],
completable: true,
});
if (!msg) {
return Loop.continue(undefined);
}
try {
if (msg.name === "organization.command.createTask") {
const result = await loopCtx.step({
name: "organization-create-task",
timeout: 5 * 60_000,
run: async () => createTaskMutation(loopCtx, msg.body as CreateTaskInput),
});
await msg.complete(result);
return Loop.continue(undefined);
}
if (msg.name === "organization.command.snapshot.broadcast") {
await loopCtx.step({
name: "organization-snapshot-broadcast",
timeout: 60_000,
run: async () => refreshOrganizationSnapshotMutation(loopCtx),
});
await msg.complete({ ok: true });
return Loop.continue(undefined);
}
if (msg.name === "organization.command.syncGithubSession") {
await loopCtx.step({
name: "organization-sync-github-session",
timeout: 60_000,
run: async () => {
const { syncGithubOrganizations } = await import("./app-shell.js");
await syncGithubOrganizations(loopCtx, msg.body as { sessionId: string; accessToken: string });
},
});
await msg.complete({ ok: true });
return Loop.continue(undefined);
}
if (msg.name === "organization.command.better_auth.session_index.upsert") {
const result = await loopCtx.step({
name: "organization-better-auth-session-index-upsert",
timeout: 60_000,
run: async () => betterAuthUpsertSessionIndexMutation(loopCtx, msg.body),
});
await msg.complete(result);
return Loop.continue(undefined);
}
if (msg.name === "organization.command.better_auth.session_index.delete") {
await loopCtx.step({
name: "organization-better-auth-session-index-delete",
timeout: 60_000,
run: async () => betterAuthDeleteSessionIndexMutation(loopCtx, msg.body),
});
await msg.complete({ ok: true });
return Loop.continue(undefined);
}
if (msg.name === "organization.command.better_auth.email_index.upsert") {
const result = await loopCtx.step({
name: "organization-better-auth-email-index-upsert",
timeout: 60_000,
run: async () => betterAuthUpsertEmailIndexMutation(loopCtx, msg.body),
});
await msg.complete(result);
return Loop.continue(undefined);
}
if (msg.name === "organization.command.better_auth.email_index.delete") {
await loopCtx.step({
name: "organization-better-auth-email-index-delete",
timeout: 60_000,
run: async () => betterAuthDeleteEmailIndexMutation(loopCtx, msg.body),
});
await msg.complete({ ok: true });
return Loop.continue(undefined);
}
if (msg.name === "organization.command.better_auth.account_index.upsert") {
const result = await loopCtx.step({
name: "organization-better-auth-account-index-upsert",
timeout: 60_000,
run: async () => betterAuthUpsertAccountIndexMutation(loopCtx, msg.body),
});
await msg.complete(result);
return Loop.continue(undefined);
}
if (msg.name === "organization.command.better_auth.account_index.delete") {
await loopCtx.step({
name: "organization-better-auth-account-index-delete",
timeout: 60_000,
run: async () => betterAuthDeleteAccountIndexMutation(loopCtx, msg.body),
});
await msg.complete({ ok: true });
return Loop.continue(undefined);
}
if (msg.name === "organization.command.better_auth.verification.create") {
const result = await loopCtx.step({
name: "organization-better-auth-verification-create",
timeout: 60_000,
run: async () => betterAuthCreateVerificationMutation(loopCtx, msg.body),
});
await msg.complete(result);
return Loop.continue(undefined);
}
if (msg.name === "organization.command.better_auth.verification.update") {
const result = await loopCtx.step({
name: "organization-better-auth-verification-update",
timeout: 60_000,
run: async () => betterAuthUpdateVerificationMutation(loopCtx, msg.body),
});
await msg.complete(result);
return Loop.continue(undefined);
}
if (msg.name === "organization.command.better_auth.verification.update_many") {
const result = await loopCtx.step({
name: "organization-better-auth-verification-update-many",
timeout: 60_000,
run: async () => betterAuthUpdateManyVerificationMutation(loopCtx, msg.body),
});
await msg.complete(result);
return Loop.continue(undefined);
}
if (msg.name === "organization.command.better_auth.verification.delete") {
await loopCtx.step({
name: "organization-better-auth-verification-delete",
timeout: 60_000,
run: async () => betterAuthDeleteVerificationMutation(loopCtx, msg.body),
});
await msg.complete({ ok: true });
return Loop.continue(undefined);
}
if (msg.name === "organization.command.better_auth.verification.delete_many") {
const result = await loopCtx.step({
name: "organization-better-auth-verification-delete-many",
timeout: 60_000,
run: async () => betterAuthDeleteManyVerificationMutation(loopCtx, msg.body),
});
await msg.complete(result);
return Loop.continue(undefined);
}
if (msg.name === "organization.command.github.repository_projection.apply") {
await loopCtx.step({
name: "organization-github-repository-projection-apply",
timeout: 60_000,
run: async () => applyGithubRepositoryProjectionMutation(loopCtx, msg.body),
});
await msg.complete({ ok: true });
return Loop.continue(undefined);
}
if (msg.name === "organization.command.github.data_projection.apply") {
await loopCtx.step({
name: "organization-github-data-projection-apply",
timeout: 60_000,
run: async () => applyGithubDataProjectionMutation(loopCtx, msg.body),
});
await msg.complete({ ok: true });
return Loop.continue(undefined);
}
if (msg.name === "organization.command.github.sync_progress.apply") {
await loopCtx.step({
name: "organization-github-sync-progress-apply",
timeout: 60_000,
run: async () => applyGithubSyncProgressMutation(loopCtx, msg.body),
});
await msg.complete({ ok: true });
return Loop.continue(undefined);
}
if (msg.name === "organization.command.github.webhook_receipt.record") {
await loopCtx.step({
name: "organization-github-webhook-receipt-record",
timeout: 60_000,
run: async () => recordGithubWebhookReceiptMutation(loopCtx, msg.body),
});
await msg.complete({ ok: true });
return Loop.continue(undefined);
}
if (msg.name === "organization.command.github.organization_shell.sync_from_github") {
const result = await loopCtx.step({
name: "organization-github-organization-shell-sync-from-github",
timeout: 60_000,
run: async () => syncOrganizationShellFromGithubMutation(loopCtx, msg.body),
});
await msg.complete(result);
return Loop.continue(undefined);
}
if (msg.name === "organization.command.shell.profile.update") {
await loopCtx.step({
name: "organization-shell-profile-update",
timeout: 60_000,
run: async () => updateOrganizationShellProfileMutation(loopCtx, msg.body),
});
await msg.complete({ ok: true });
return Loop.continue(undefined);
}
if (msg.name === "organization.command.shell.sync_started.mark") {
await loopCtx.step({
name: "organization-shell-sync-started-mark",
timeout: 60_000,
run: async () => markOrganizationSyncStartedMutation(loopCtx, msg.body),
});
await msg.complete({ ok: true });
return Loop.continue(undefined);
}
if (msg.name === "organization.command.billing.stripe_customer.apply") {
await loopCtx.step({
name: "organization-billing-stripe-customer-apply",
timeout: 60_000,
run: async () => applyOrganizationStripeCustomerMutation(loopCtx, msg.body),
});
await msg.complete({ ok: true });
return Loop.continue(undefined);
}
if (msg.name === "organization.command.billing.stripe_subscription.apply") {
await loopCtx.step({
name: "organization-billing-stripe-subscription-apply",
timeout: 60_000,
run: async () => applyOrganizationStripeSubscriptionMutation(loopCtx, msg.body),
});
await msg.complete({ ok: true });
return Loop.continue(undefined);
}
if (msg.name === "organization.command.billing.free_plan.apply") {
await loopCtx.step({
name: "organization-billing-free-plan-apply",
timeout: 60_000,
run: async () => applyOrganizationFreePlanMutation(loopCtx, msg.body),
});
await msg.complete({ ok: true });
return Loop.continue(undefined);
}
if (msg.name === "organization.command.billing.payment_method.set") {
await loopCtx.step({
name: "organization-billing-payment-method-set",
timeout: 60_000,
run: async () => setOrganizationBillingPaymentMethodMutation(loopCtx, msg.body),
});
await msg.complete({ ok: true });
return Loop.continue(undefined);
}
if (msg.name === "organization.command.billing.status.set") {
await loopCtx.step({
name: "organization-billing-status-set",
timeout: 60_000,
run: async () => setOrganizationBillingStatusMutation(loopCtx, msg.body),
});
await msg.complete({ ok: true });
return Loop.continue(undefined);
}
if (msg.name === "organization.command.billing.invoice.upsert") {
await loopCtx.step({
name: "organization-billing-invoice-upsert",
timeout: 60_000,
run: async () => upsertOrganizationInvoiceMutation(loopCtx, msg.body),
});
await msg.complete({ ok: true });
return Loop.continue(undefined);
}
if (msg.name === "organization.command.billing.seat_usage.record") {
await loopCtx.step({
name: "organization-billing-seat-usage-record",
timeout: 60_000,
run: async () => recordOrganizationSeatUsageMutation(loopCtx, msg.body),
});
await msg.complete({ ok: true });
return Loop.continue(undefined);
}
} catch (error) {
const message = resolveErrorMessage(error);
logActorWarning("organization", "organization workflow command failed", {
queueName: msg.name,
error: message,
});
await msg.complete({ error: message }).catch((completeError: unknown) => {
logActorWarning("organization", "organization workflow failed completing error response", {
queueName: msg.name,
error: resolveErrorMessage(completeError),
});
});
}
return Loop.continue(undefined);
});
}

View file

@ -1,9 +1,7 @@
// @ts-nocheck
import { randomUUID } from "node:crypto";
import { and, desc, eq, isNotNull, ne } from "drizzle-orm";
import { Loop } from "rivetkit/workflow";
import type {
AgentType,
RepoOverview,
SandboxProviderId,
TaskRecord,
@ -12,19 +10,21 @@ import type {
WorkspaceSessionSummary,
WorkspaceTaskSummary,
} from "@sandbox-agent/foundry-shared";
import { getGithubData, getOrCreateAuditLog, getOrCreateOrganization, getOrCreateTask, getTask, selfRepository } from "../handles.js";
import { getActorRuntimeContext } from "../context.js";
import { getOrCreateAuditLog, getOrCreateOrganization, getOrCreateTask, getTask } from "../handles.js";
import { organizationWorkflowQueueName } from "../organization/queues.js";
import { taskWorkflowQueueName } from "../task/workflow/index.js";
import { deriveFallbackTitle, resolveCreateFlowDecision } from "../../services/create-flow.js";
import { expectQueueResponse } from "../../services/queue.js";
import { isActorNotFoundError, logActorWarning, resolveErrorMessage } from "../logging.js";
import { defaultSandboxProviderId } from "../../sandbox-config.js";
import { repoMeta, taskIndex, tasks } from "./db/schema.js";
interface CreateTaskCommand {
task: string;
sandboxProviderId: SandboxProviderId;
agentType: AgentType | null;
explicitTitle: string | null;
explicitBranchName: string | null;
initialPrompt: string | null;
onBranch: string | null;
}
@ -38,18 +38,8 @@ interface ListTaskSummariesCommand {
includeArchived?: boolean;
}
interface GetPullRequestForBranchCommand {
branchName: string;
}
const REPOSITORY_QUEUE_NAMES = ["repository.command.createTask", "repository.command.registerTaskBranch"] as const;
type RepositoryQueueName = (typeof REPOSITORY_QUEUE_NAMES)[number];
export { REPOSITORY_QUEUE_NAMES };
export function repositoryWorkflowQueueName(name: RepositoryQueueName): RepositoryQueueName {
return name;
interface GetProjectedTaskSummaryCommand {
taskId: string;
}
function isStaleTaskReferenceError(error: unknown): boolean {
@ -109,26 +99,14 @@ async function upsertTaskSummary(c: any, taskSummary: WorkspaceTaskSummary): Pro
async function notifyOrganizationSnapshotChanged(c: any): Promise<void> {
const organization = await getOrCreateOrganization(c, c.state.organizationId);
await organization.refreshOrganizationSnapshot({});
await expectQueueResponse<{ ok: true }>(
await organization.send(organizationWorkflowQueueName("organization.command.snapshot.broadcast"), {}, { wait: true, timeout: 10_000 }),
);
}
async function persistRemoteUrl(c: any, remoteUrl: string): Promise<void> {
c.state.remoteUrl = remoteUrl;
await c.db
.insert(repoMeta)
.values({
id: 1,
remoteUrl,
updatedAt: Date.now(),
})
.onConflictDoUpdate({
target: repoMeta.id,
set: {
remoteUrl,
updatedAt: Date.now(),
},
})
.run();
async function readStoredRemoteUrl(c: any): Promise<string | null> {
const row = await c.db.select({ remoteUrl: repoMeta.remoteUrl }).from(repoMeta).where(eq(repoMeta.id, 1)).get();
return row?.remoteUrl ?? null;
}
async function deleteStaleTaskIndexRow(c: any, taskId: string): Promise<void> {
@ -164,31 +142,6 @@ async function listKnownTaskBranches(c: any): Promise<string[]> {
return rows.map((row) => row.branchName).filter((value): value is string => typeof value === "string" && value.trim().length > 0);
}
function parseJsonValue<T>(value: string | null | undefined, fallback: T): T {
if (!value) {
return fallback;
}
try {
return JSON.parse(value) as T;
} catch {
return fallback;
}
}
function taskSummaryRowFromSummary(taskSummary: WorkspaceTaskSummary) {
return {
taskId: taskSummary.id,
title: taskSummary.title,
status: taskSummary.status,
repoName: taskSummary.repoName,
updatedAtMs: taskSummary.updatedAtMs,
branch: taskSummary.branch,
pullRequestJson: JSON.stringify(taskSummary.pullRequest),
sessionsSummaryJson: JSON.stringify(taskSummary.sessionsSummary),
};
}
async function resolveGitHubRepository(c: any) {
const githubData = getGithubData(c, c.state.organizationId);
return await githubData.getRepository({ repoId: c.state.repoId }).catch(() => null);
@ -199,17 +152,29 @@ async function listGitHubBranches(c: any): Promise<Array<{ branchName: string; c
return await githubData.listBranchesForRepository({ repoId: c.state.repoId }).catch(() => []);
}
async function createTaskMutation(c: any, cmd: CreateTaskCommand): Promise<TaskRecord> {
async function resolveRepositoryRemoteUrl(c: any): Promise<string> {
const storedRemoteUrl = await readStoredRemoteUrl(c);
if (storedRemoteUrl) {
return storedRemoteUrl;
}
const repository = await resolveGitHubRepository(c);
const remoteUrl = repository?.cloneUrl?.trim();
if (!remoteUrl) {
throw new Error(`Missing remote URL for repo ${c.state.repoId}`);
}
return remoteUrl;
}
export async function createTaskMutation(c: any, cmd: CreateTaskCommand): Promise<TaskRecord> {
const organizationId = c.state.organizationId;
const repoId = c.state.repoId;
const repoRemote = c.state.remoteUrl;
await resolveRepositoryRemoteUrl(c);
const onBranch = cmd.onBranch?.trim() || null;
const taskId = randomUUID();
let initialBranchName: string | null = null;
let initialTitle: string | null = null;
await persistRemoteUrl(c, repoRemote);
if (onBranch) {
initialBranchName = onBranch;
initialTitle = deriveFallbackTitle(cmd.task, cmd.explicitTitle ?? undefined);
@ -251,15 +216,6 @@ async function createTaskMutation(c: any, cmd: CreateTaskCommand): Promise<TaskR
organizationId,
repoId,
taskId,
repoRemote,
branchName: initialBranchName,
title: initialTitle,
task: cmd.task,
sandboxProviderId: cmd.sandboxProviderId,
agentType: cmd.agentType,
explicitTitle: null,
explicitBranchName: null,
initialPrompt: cmd.initialPrompt,
});
} catch (error) {
if (initialBranchName) {
@ -268,7 +224,21 @@ async function createTaskMutation(c: any, cmd: CreateTaskCommand): Promise<TaskR
throw error;
}
const created = await taskHandle.initialize({ sandboxProviderId: cmd.sandboxProviderId });
const created = await expectQueueResponse<TaskRecord>(
await taskHandle.send(
taskWorkflowQueueName("task.command.initialize"),
{
sandboxProviderId: cmd.sandboxProviderId,
branchName: initialBranchName,
title: initialTitle,
task: cmd.task,
},
{
wait: true,
timeout: 10_000,
},
),
);
try {
await upsertTaskSummary(c, await taskHandle.getTaskSummary({}));
@ -313,25 +283,12 @@ async function createTaskMutation(c: any, cmd: CreateTaskCommand): Promise<TaskR
return created;
}
async function upsertTaskSummary(c: any, taskSummary: WorkspaceTaskSummary): Promise<void> {
await c.db
.insert(tasks)
.values(taskSummaryRowFromSummary(taskSummary))
.onConflictDoUpdate({
target: tasks.taskId,
set: taskSummaryRowFromSummary(taskSummary),
})
.run();
}
async function registerTaskBranchMutation(c: any, cmd: RegisterTaskBranchCommand): Promise<{ branchName: string; headSha: string }> {
export async function registerTaskBranchMutation(c: any, cmd: RegisterTaskBranchCommand): Promise<{ branchName: string; headSha: string }> {
const branchName = cmd.branchName.trim();
if (!branchName) {
throw new Error("branchName is required");
}
await persistRemoteUrl(c, c.state.remoteUrl);
const existingOwner = await c.db
.select({ taskId: taskIndex.taskId })
.from(taskIndex)
@ -397,6 +354,7 @@ async function listTaskSummaries(c: any, includeArchived = false): Promise<TaskS
title: row.title,
status: row.status,
updatedAt: row.updatedAtMs,
pullRequest: parseJsonValue<WorkspacePullRequestSummary | null>(row.pullRequestJson, null),
}))
.filter((row) => includeArchived || row.status !== "archived");
}
@ -413,12 +371,8 @@ function sortOverviewBranches(
taskId: string | null;
taskTitle: string | null;
taskStatus: TaskRecord["status"] | null;
prNumber: number | null;
prState: string | null;
prUrl: string | null;
pullRequest: WorkspacePullRequestSummary | null;
ciStatus: string | null;
reviewStatus: string | null;
reviewer: string | null;
updatedAt: number;
}>,
defaultBranch: string | null,
@ -438,60 +392,59 @@ function sortOverviewBranches(
});
}
export async function runRepositoryWorkflow(ctx: any): Promise<void> {
await ctx.loop("repository-command-loop", async (loopCtx: any) => {
const msg = await loopCtx.queue.next("next-repository-command", {
names: [...REPOSITORY_QUEUE_NAMES],
completable: true,
export async function applyTaskSummaryUpdateMutation(c: any, input: { taskSummary: WorkspaceTaskSummary }): Promise<void> {
await upsertTaskSummary(c, input.taskSummary);
await notifyOrganizationSnapshotChanged(c);
}
export async function removeTaskSummaryMutation(c: any, input: { taskId: string }): Promise<void> {
await c.db.delete(tasks).where(eq(tasks.taskId, input.taskId)).run();
await notifyOrganizationSnapshotChanged(c);
}
export async function refreshTaskSummaryForBranchMutation(
c: any,
input: { branchName: string; pullRequest?: WorkspacePullRequestSummary | null },
): Promise<void> {
const pullRequest = input.pullRequest ?? null;
let rows = await c.db.select({ taskId: tasks.taskId }).from(tasks).where(eq(tasks.branch, input.branchName)).all();
if (rows.length === 0 && pullRequest) {
const { config } = getActorRuntimeContext();
const created = await createTaskMutation(c, {
task: pullRequest.title?.trim() || `Review ${input.branchName}`,
sandboxProviderId: defaultSandboxProviderId(config),
explicitTitle: pullRequest.title?.trim() || input.branchName,
explicitBranchName: null,
onBranch: input.branchName,
});
if (!msg) {
return Loop.continue(undefined);
}
rows = [{ taskId: created.taskId }];
}
for (const row of rows) {
try {
if (msg.name === "repository.command.createTask") {
const result = await loopCtx.step({
name: "repository-create-task",
timeout: 5 * 60_000,
run: async () => createTaskMutation(loopCtx, msg.body as CreateTaskCommand),
});
await msg.complete(result);
return Loop.continue(undefined);
}
if (msg.name === "repository.command.registerTaskBranch") {
const result = await loopCtx.step({
name: "repository-register-task-branch",
timeout: 60_000,
run: async () => registerTaskBranchMutation(loopCtx, msg.body as RegisterTaskBranchCommand),
});
await msg.complete(result);
return Loop.continue(undefined);
}
const task = getTask(c, c.state.organizationId, c.state.repoId, row.taskId);
await expectQueueResponse<{ ok: true }>(
await task.send(
taskWorkflowQueueName("task.command.pull_request.sync"),
{ pullRequest },
{ wait: true, timeout: 10_000 },
),
);
} catch (error) {
const message = resolveErrorMessage(error);
logActorWarning("repository", "repository workflow command failed", {
queueName: msg.name,
error: message,
logActorWarning("repository", "failed refreshing task summary for branch", {
organizationId: c.state.organizationId,
repoId: c.state.repoId,
branchName: input.branchName,
taskId: row.taskId,
error: resolveErrorMessage(error),
});
await msg.complete({ error: message }).catch(() => {});
}
}
return Loop.continue(undefined);
});
}
export const repositoryActions = {
async createTask(c: any, cmd: CreateTaskCommand): Promise<TaskRecord> {
const self = selfRepository(c);
return expectQueueResponse<TaskRecord>(
await self.send(repositoryWorkflowQueueName("repository.command.createTask"), cmd, {
wait: true,
timeout: 10_000,
}),
);
},
async listReservedBranches(c: any): Promise<string[]> {
return await listKnownTaskBranches(c);
},
@ -506,23 +459,19 @@ export const repositoryActions = {
async getRepositoryMetadata(c: any): Promise<{ defaultBranch: string | null; fullName: string | null; remoteUrl: string }> {
const repository = await resolveGitHubRepository(c);
const remoteUrl = await resolveRepositoryRemoteUrl(c);
return {
defaultBranch: repository?.defaultBranch ?? null,
fullName: repository?.fullName ?? null,
remoteUrl: c.state.remoteUrl,
remoteUrl,
};
},
async getRepoOverview(c: any): Promise<RepoOverview> {
await persistRemoteUrl(c, c.state.remoteUrl);
const now = Date.now();
const repository = await resolveGitHubRepository(c);
const remoteUrl = await resolveRepositoryRemoteUrl(c);
const githubBranches = await listGitHubBranches(c).catch(() => []);
const githubData = getGithubData(c, c.state.organizationId);
const prRows = await githubData.listPullRequestsForRepository({ repoId: c.state.repoId }).catch(() => []);
const prByBranch = new Map(prRows.map((row) => [row.headRefName, row]));
const taskRows = await c.db.select().from(tasks).all();
const taskMetaByBranch = new Map<
@ -558,19 +507,15 @@ export const repositoryActions = {
const branches = sortOverviewBranches(
[...branchMap.values()].map((branch) => {
const taskMeta = taskMetaByBranch.get(branch.branchName);
const pr = taskMeta?.pullRequest ?? prByBranch.get(branch.branchName) ?? null;
const pr = taskMeta?.pullRequest ?? null;
return {
branchName: branch.branchName,
commitSha: branch.commitSha,
taskId: taskMeta?.taskId ?? null,
taskTitle: taskMeta?.title ?? null,
taskStatus: taskMeta?.status ?? null,
prNumber: pr?.number ?? null,
prState: "state" in (pr ?? {}) ? pr.state : null,
prUrl: "url" in (pr ?? {}) ? pr.url : null,
pullRequest: pr,
ciStatus: null,
reviewStatus: pr && "isDraft" in pr ? (pr.isDraft ? "draft" : "ready") : null,
reviewer: pr?.authorLogin ?? null,
updatedAt: Math.max(taskMeta?.updatedAt ?? 0, pr?.updatedAtMs ?? 0, now),
};
}),
@ -580,58 +525,24 @@ export const repositoryActions = {
return {
organizationId: c.state.organizationId,
repoId: c.state.repoId,
remoteUrl: c.state.remoteUrl,
remoteUrl,
baseRef: repository?.defaultBranch ?? null,
fetchedAt: now,
branches,
};
},
async applyTaskSummaryUpdate(c: any, input: { taskSummary: WorkspaceTaskSummary }): Promise<void> {
await upsertTaskSummary(c, input.taskSummary);
await notifyOrganizationSnapshotChanged(c);
},
async removeTaskSummary(c: any, input: { taskId: string }): Promise<void> {
await c.db.delete(tasks).where(eq(tasks.taskId, input.taskId)).run();
await notifyOrganizationSnapshotChanged(c);
},
async findTaskForBranch(c: any, input: { branchName: string }): Promise<{ taskId: string | null }> {
const row = await c.db.select({ taskId: tasks.taskId }).from(tasks).where(eq(tasks.branch, input.branchName)).get();
return { taskId: row?.taskId ?? null };
},
async refreshTaskSummaryForBranch(c: any, input: { branchName: string }): Promise<void> {
const rows = await c.db.select({ taskId: tasks.taskId }).from(tasks).where(eq(tasks.branch, input.branchName)).all();
for (const row of rows) {
try {
const task = getTask(c, c.state.organizationId, c.state.repoId, row.taskId);
await upsertTaskSummary(c, await task.getTaskSummary({}));
} catch (error) {
logActorWarning("repository", "failed refreshing task summary for branch", {
organizationId: c.state.organizationId,
repoId: c.state.repoId,
branchName: input.branchName,
taskId: row.taskId,
error: resolveErrorMessage(error),
});
}
}
await notifyOrganizationSnapshotChanged(c);
},
async getPullRequestForBranch(c: any, cmd: GetPullRequestForBranchCommand): Promise<WorkspacePullRequestSummary | null> {
const branchName = cmd.branchName?.trim();
if (!branchName) {
async getProjectedTaskSummary(c: any, input: GetProjectedTaskSummaryCommand): Promise<WorkspaceTaskSummary | null> {
const taskId = input.taskId?.trim();
if (!taskId) {
return null;
}
const githubData = getGithubData(c, c.state.organizationId);
const rows = await githubData.listPullRequestsForRepository({
repoId: c.state.repoId,
});
return rows.find((candidate: WorkspacePullRequestSummary) => candidate.headRefName === branchName) ?? null;
const row = await c.db.select().from(tasks).where(eq(tasks.taskId, taskId)).get();
return row ? taskSummaryFromRow(c, row) : null;
},
};

View file

@ -1,12 +1,12 @@
import { actor, queue } from "rivetkit";
import { workflow } from "rivetkit/workflow";
import { repositoryDb } from "./db/db.js";
import { REPOSITORY_QUEUE_NAMES, repositoryActions, runRepositoryWorkflow } from "./actions.js";
import { repositoryActions } from "./actions.js";
import { REPOSITORY_QUEUE_NAMES, runRepositoryWorkflow } from "./workflow.js";
export interface RepositoryInput {
organizationId: string;
repoId: string;
remoteUrl: string;
}
export const repository = actor({
@ -20,7 +20,6 @@ export const repository = actor({
createState: (_c, input: RepositoryInput) => ({
organizationId: input.organizationId,
repoId: input.repoId,
remoteUrl: input.remoteUrl,
}),
actions: repositoryActions,
run: workflow(runRepositoryWorkflow),

View file

@ -0,0 +1,97 @@
// @ts-nocheck
import { Loop } from "rivetkit/workflow";
import { logActorWarning, resolveErrorMessage } from "../logging.js";
import {
applyTaskSummaryUpdateMutation,
createTaskMutation,
refreshTaskSummaryForBranchMutation,
registerTaskBranchMutation,
removeTaskSummaryMutation,
} from "./actions.js";
export const REPOSITORY_QUEUE_NAMES = [
"repository.command.createTask",
"repository.command.registerTaskBranch",
"repository.command.applyTaskSummaryUpdate",
"repository.command.removeTaskSummary",
"repository.command.refreshTaskSummaryForBranch",
] as const;
export type RepositoryQueueName = (typeof REPOSITORY_QUEUE_NAMES)[number];
export function repositoryWorkflowQueueName(name: RepositoryQueueName): RepositoryQueueName {
return name;
}
export async function runRepositoryWorkflow(ctx: any): Promise<void> {
await ctx.loop("repository-command-loop", async (loopCtx: any) => {
const msg = await loopCtx.queue.next("next-repository-command", {
names: [...REPOSITORY_QUEUE_NAMES],
completable: true,
});
if (!msg) {
return Loop.continue(undefined);
}
try {
if (msg.name === "repository.command.createTask") {
const result = await loopCtx.step({
name: "repository-create-task",
timeout: 5 * 60_000,
run: async () => createTaskMutation(loopCtx, msg.body),
});
await msg.complete(result);
return Loop.continue(undefined);
}
if (msg.name === "repository.command.registerTaskBranch") {
const result = await loopCtx.step({
name: "repository-register-task-branch",
timeout: 60_000,
run: async () => registerTaskBranchMutation(loopCtx, msg.body),
});
await msg.complete(result);
return Loop.continue(undefined);
}
if (msg.name === "repository.command.applyTaskSummaryUpdate") {
await loopCtx.step({
name: "repository-apply-task-summary-update",
timeout: 30_000,
run: async () => applyTaskSummaryUpdateMutation(loopCtx, msg.body),
});
await msg.complete({ ok: true });
return Loop.continue(undefined);
}
if (msg.name === "repository.command.removeTaskSummary") {
await loopCtx.step({
name: "repository-remove-task-summary",
timeout: 30_000,
run: async () => removeTaskSummaryMutation(loopCtx, msg.body),
});
await msg.complete({ ok: true });
return Loop.continue(undefined);
}
if (msg.name === "repository.command.refreshTaskSummaryForBranch") {
await loopCtx.step({
name: "repository-refresh-task-summary-for-branch",
timeout: 60_000,
run: async () => refreshTaskSummaryForBranchMutation(loopCtx, msg.body),
});
await msg.complete({ ok: true });
return Loop.continue(undefined);
}
} catch (error) {
const message = resolveErrorMessage(error);
logActorWarning("repository", "repository workflow command failed", {
queueName: msg.name,
error: message,
});
await msg.complete({ error: message }).catch(() => {});
}
return Loop.continue(undefined);
});
}

View file

@ -2,6 +2,7 @@ import { actor } from "rivetkit";
import { e2b, sandboxActor } from "rivetkit/sandbox";
import { existsSync } from "node:fs";
import Dockerode from "dockerode";
import { DEFAULT_WORKSPACE_MODEL_GROUPS, workspaceModelGroupsFromSandboxAgents, type WorkspaceModelGroup } from "@sandbox-agent/foundry-shared";
import { SandboxAgent } from "sandbox-agent";
import { getActorRuntimeContext } from "../context.js";
import { organizationKey } from "../keys.js";
@ -258,6 +259,26 @@ async function providerForConnection(c: any): Promise<any | null> {
return provider;
}
async function listWorkspaceModelGroupsForSandbox(c: any): Promise<WorkspaceModelGroup[]> {
const provider = await providerForConnection(c);
if (!provider || !c.state.sandboxId || typeof provider.connectAgent !== "function") {
return DEFAULT_WORKSPACE_MODEL_GROUPS;
}
try {
const client = await provider.connectAgent(c.state.sandboxId, {
waitForHealth: {
timeoutMs: 15_000,
},
});
const listed = await client.listAgents({ config: true });
const groups = workspaceModelGroupsFromSandboxAgents(Array.isArray(listed?.agents) ? listed.agents : []);
return groups.length > 0 ? groups : DEFAULT_WORKSPACE_MODEL_GROUPS;
} catch {
return DEFAULT_WORKSPACE_MODEL_GROUPS;
}
}
const baseActions = baseTaskSandbox.config.actions as Record<string, (c: any, ...args: any[]) => Promise<any>>;
export const taskSandbox = actor({
@ -360,6 +381,10 @@ export const taskSandbox = actor({
}
},
async listWorkspaceModelGroups(c: any): Promise<WorkspaceModelGroup[]> {
return await listWorkspaceModelGroupsForSandbox(c);
},
async providerState(c: any): Promise<{ sandboxProviderId: "e2b" | "local"; sandboxId: string; state: string; at: number }> {
const { config } = getActorRuntimeContext();
const { taskId } = parseTaskSandboxKey(c.key);

View file

@ -5,8 +5,7 @@ CREATE TABLE `task` (
`task` text NOT NULL,
`sandbox_provider_id` text NOT NULL,
`status` text NOT NULL,
`agent_type` text DEFAULT 'claude',
`pr_submitted` integer DEFAULT 0,
`pull_request_json` text,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
CONSTRAINT "task_singleton_id_check" CHECK("task"."id" = 1)
@ -15,14 +14,10 @@ CREATE TABLE `task` (
CREATE TABLE `task_runtime` (
`id` integer PRIMARY KEY NOT NULL,
`active_sandbox_id` text,
`active_session_id` text,
`active_switch_target` text,
`active_cwd` text,
`status_message` text,
`git_state_json` text,
`git_state_updated_at` integer,
`provision_stage` text,
`provision_stage_updated_at` integer,
`updated_at` integer NOT NULL,
CONSTRAINT "task_runtime_singleton_id_check" CHECK("task_runtime"."id" = 1)
);
@ -33,7 +28,6 @@ CREATE TABLE `task_sandboxes` (
`sandbox_actor_id` text,
`switch_target` text NOT NULL,
`cwd` text,
`status_message` text,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL
);
@ -47,10 +41,6 @@ CREATE TABLE `task_workspace_sessions` (
`error_message` text,
`transcript_json` text DEFAULT '[]' NOT NULL,
`transcript_updated_at` integer,
`unread` integer DEFAULT 0 NOT NULL,
`draft_text` text DEFAULT '' NOT NULL,
`draft_attachments_json` text DEFAULT '[]' NOT NULL,
`draft_updated_at` integer,
`created` integer DEFAULT 1 NOT NULL,
`closed` integer DEFAULT 0 NOT NULL,
`thinking_since_ms` integer,

View file

@ -35,8 +35,8 @@
"notNull": true,
"autoincrement": false
},
"provider_id": {
"name": "provider_id",
"sandbox_provider_id": {
"name": "sandbox_provider_id",
"type": "text",
"primaryKey": false,
"notNull": true,
@ -49,21 +49,12 @@
"notNull": true,
"autoincrement": false
},
"agent_type": {
"name": "agent_type",
"pull_request_json": {
"name": "pull_request_json",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'claude'"
},
"pr_submitted": {
"name": "pr_submitted",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
"autoincrement": false
},
"created_at": {
"name": "created_at",
@ -108,13 +99,6 @@
"notNull": false,
"autoincrement": false
},
"active_session_id": {
"name": "active_session_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"active_switch_target": {
"name": "active_switch_target",
"type": "text",
@ -129,13 +113,20 @@
"notNull": false,
"autoincrement": false
},
"status_message": {
"name": "status_message",
"git_state_json": {
"name": "git_state_json",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"git_state_updated_at": {
"name": "git_state_updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
@ -165,8 +156,8 @@
"notNull": true,
"autoincrement": false
},
"provider_id": {
"name": "provider_id",
"sandbox_provider_id": {
"name": "sandbox_provider_id",
"type": "text",
"primaryKey": false,
"notNull": true,
@ -193,13 +184,6 @@
"notNull": false,
"autoincrement": false
},
"status_message": {
"name": "status_message",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
@ -231,6 +215,13 @@
"notNull": true,
"autoincrement": false
},
"sandbox_session_id": {
"name": "sandbox_session_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"session_name": {
"name": "session_name",
"type": "text",
@ -245,32 +236,31 @@
"notNull": true,
"autoincrement": false
},
"unread": {
"name": "unread",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"draft_text": {
"name": "draft_text",
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
"default": "'ready'"
},
"draft_attachments_json": {
"name": "draft_attachments_json",
"error_message": {
"name": "error_message",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"transcript_json": {
"name": "transcript_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"draft_updated_at": {
"name": "draft_updated_at",
"transcript_updated_at": {
"name": "transcript_updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,

View file

@ -11,6 +11,7 @@ export const task = sqliteTable(
task: text("task").notNull(),
sandboxProviderId: text("sandbox_provider_id").notNull(),
status: text("status").notNull(),
pullRequestJson: text("pull_request_json"),
createdAt: integer("created_at").notNull(),
updatedAt: integer("updated_at").notNull(),
},
@ -42,7 +43,6 @@ export const taskSandboxes = sqliteTable("task_sandboxes", {
sandboxActorId: text("sandbox_actor_id"),
switchTarget: text("switch_target").notNull(),
cwd: text("cwd"),
statusMessage: text("status_message"),
createdAt: integer("created_at").notNull(),
updatedAt: integer("updated_at").notNull(),
});

View file

@ -1,122 +1,15 @@
import { actor, queue } from "rivetkit";
import { workflow } from "rivetkit/workflow";
import type {
TaskRecord,
TaskWorkspaceChangeModelInput,
TaskWorkspaceRenameInput,
TaskWorkspaceRenameSessionInput,
TaskWorkspaceSetSessionUnreadInput,
TaskWorkspaceSendMessageInput,
TaskWorkspaceUpdateDraftInput,
SandboxProviderId,
} from "@sandbox-agent/foundry-shared";
import { expectQueueResponse } from "../../services/queue.js";
import { selfTask } from "../handles.js";
import type { TaskRecord } from "@sandbox-agent/foundry-shared";
import { taskDb } from "./db/db.js";
import { getCurrentRecord } from "./workflow/common.js";
import {
changeWorkspaceModel,
closeWorkspaceSession,
createWorkspaceSession,
getSessionDetail,
getTaskDetail,
getTaskSummary,
markWorkspaceUnread,
publishWorkspacePr,
renameWorkspaceTask,
renameWorkspaceSession,
revertWorkspaceFile,
sendWorkspaceMessage,
syncWorkspaceSessionStatus,
setWorkspaceSessionUnread,
stopWorkspaceSession,
updateWorkspaceDraft,
} from "./workspace.js";
import { TASK_QUEUE_NAMES, taskWorkflowQueueName, runTaskWorkflow } from "./workflow/index.js";
import { getSessionDetail, getTaskDetail, getTaskSummary } from "./workspace.js";
import { TASK_QUEUE_NAMES, runTaskWorkflow } from "./workflow/index.js";
export interface TaskInput {
organizationId: string;
repoId: string;
taskId: string;
repoRemote: string;
branchName: string | null;
title: string | null;
task: string;
sandboxProviderId: SandboxProviderId;
explicitTitle: string | null;
explicitBranchName: string | null;
}
interface InitializeCommand {
sandboxProviderId?: SandboxProviderId;
}
interface TaskActionCommand {
reason?: string;
}
interface TaskSessionCommand {
sessionId: string;
authSessionId?: string;
}
interface TaskStatusSyncCommand {
sessionId: string;
status: "running" | "idle" | "error";
at: number;
}
interface TaskWorkspaceValueCommand {
value: string;
authSessionId?: string;
}
interface TaskWorkspaceSessionTitleCommand {
sessionId: string;
title: string;
authSessionId?: string;
}
interface TaskWorkspaceSessionUnreadCommand {
sessionId: string;
unread: boolean;
authSessionId?: string;
}
interface TaskWorkspaceUpdateDraftCommand {
sessionId: string;
text: string;
attachments: Array<any>;
authSessionId?: string;
}
interface TaskWorkspaceChangeModelCommand {
sessionId: string;
model: string;
authSessionId?: string;
}
interface TaskWorkspaceSendMessageCommand {
sessionId: string;
text: string;
attachments: Array<any>;
authSessionId?: string;
}
interface TaskWorkspaceCreateSessionCommand {
model?: string;
authSessionId?: string;
}
interface TaskWorkspaceCreateSessionAndSendCommand {
model?: string;
text: string;
authSessionId?: string;
}
interface TaskWorkspaceSessionCommand {
sessionId: string;
authSessionId?: string;
}
export const task = actor({
@ -131,85 +24,10 @@ export const task = actor({
organizationId: input.organizationId,
repoId: input.repoId,
taskId: input.taskId,
repoRemote: input.repoRemote,
}),
actions: {
async initialize(c, cmd: InitializeCommand): Promise<TaskRecord> {
const self = selfTask(c);
const result = await self.send(taskWorkflowQueueName("task.command.initialize"), cmd ?? {}, {
wait: true,
timeout: 10_000,
});
return expectQueueResponse<TaskRecord>(result);
},
async provision(c, cmd: InitializeCommand): Promise<{ ok: true }> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.provision"), cmd ?? {}, {
wait: false,
});
return { ok: true };
},
async attach(c, cmd?: TaskActionCommand): Promise<{ target: string; sessionId: string | null }> {
const self = selfTask(c);
const result = await self.send(taskWorkflowQueueName("task.command.attach"), cmd ?? {}, {
wait: true,
timeout: 10_000,
});
return expectQueueResponse<{ target: string; sessionId: string | null }>(result);
},
async switch(c): Promise<{ switchTarget: string }> {
const self = selfTask(c);
const result = await self.send(
taskWorkflowQueueName("task.command.switch"),
{},
{
wait: true,
timeout: 10_000,
},
);
return expectQueueResponse<{ switchTarget: string }>(result);
},
async push(c, cmd?: TaskActionCommand): Promise<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.push"), cmd ?? {}, {
wait: false,
});
},
async sync(c, cmd?: TaskActionCommand): Promise<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.sync"), cmd ?? {}, {
wait: false,
});
},
async merge(c, cmd?: TaskActionCommand): Promise<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.merge"), cmd ?? {}, {
wait: false,
});
},
async archive(c, cmd?: TaskActionCommand): Promise<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.archive"), cmd ?? {}, {
wait: false,
});
},
async kill(c, cmd?: TaskActionCommand): Promise<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.kill"), cmd ?? {}, {
wait: false,
});
},
async get(c): Promise<TaskRecord> {
return await getCurrentRecord({ db: c.db, state: c.state });
return await getCurrentRecord(c);
},
async getTaskSummary(c) {
@ -223,175 +41,6 @@ export const task = actor({
async getSessionDetail(c, input: { sessionId: string; authSessionId?: string }) {
return await getSessionDetail(c, input.sessionId, input.authSessionId);
},
async markWorkspaceUnread(c, input?: { authSessionId?: string }): Promise<void> {
const self = selfTask(c);
await self.send(
taskWorkflowQueueName("task.command.workspace.mark_unread"),
{ authSessionId: input?.authSessionId },
{
wait: true,
timeout: 10_000,
},
);
},
async renameWorkspaceTask(c, input: TaskWorkspaceRenameInput): Promise<void> {
const self = selfTask(c);
await self.send(
taskWorkflowQueueName("task.command.workspace.rename_task"),
{ value: input.value, authSessionId: input.authSessionId } satisfies TaskWorkspaceValueCommand,
{
wait: true,
timeout: 20_000,
},
);
},
async createWorkspaceSession(c, input?: { model?: string; authSessionId?: string }): Promise<{ sessionId: string }> {
const self = selfTask(c);
const result = await self.send(
taskWorkflowQueueName("task.command.workspace.create_session"),
{
...(input?.model ? { model: input.model } : {}),
...(input?.authSessionId ? { authSessionId: input.authSessionId } : {}),
} satisfies TaskWorkspaceCreateSessionCommand,
{
wait: true,
timeout: 10_000,
},
);
return expectQueueResponse<{ sessionId: string }>(result);
},
/**
* Fire-and-forget: creates a session and sends the initial message.
* Used by createWorkspaceTask so the caller doesn't block on session creation.
*/
async createWorkspaceSessionAndSend(c, input: { model?: string; text: string; authSessionId?: string }): Promise<void> {
const self = selfTask(c);
await self.send(
taskWorkflowQueueName("task.command.workspace.create_session_and_send"),
{ model: input.model, text: input.text, authSessionId: input.authSessionId } satisfies TaskWorkspaceCreateSessionAndSendCommand,
{ wait: false },
);
},
async renameWorkspaceSession(c, input: TaskWorkspaceRenameSessionInput): Promise<void> {
const self = selfTask(c);
await self.send(
taskWorkflowQueueName("task.command.workspace.rename_session"),
{ sessionId: input.sessionId, title: input.title, authSessionId: input.authSessionId } satisfies TaskWorkspaceSessionTitleCommand,
{
wait: true,
timeout: 10_000,
},
);
},
async setWorkspaceSessionUnread(c, input: TaskWorkspaceSetSessionUnreadInput): Promise<void> {
const self = selfTask(c);
await self.send(
taskWorkflowQueueName("task.command.workspace.set_session_unread"),
{ sessionId: input.sessionId, unread: input.unread, authSessionId: input.authSessionId } satisfies TaskWorkspaceSessionUnreadCommand,
{
wait: true,
timeout: 10_000,
},
);
},
async updateWorkspaceDraft(c, input: TaskWorkspaceUpdateDraftInput): Promise<void> {
const self = selfTask(c);
await self.send(
taskWorkflowQueueName("task.command.workspace.update_draft"),
{
sessionId: input.sessionId,
text: input.text,
attachments: input.attachments,
authSessionId: input.authSessionId,
} satisfies TaskWorkspaceUpdateDraftCommand,
{
wait: false,
},
);
},
async changeWorkspaceModel(c, input: TaskWorkspaceChangeModelInput): Promise<void> {
const self = selfTask(c);
await self.send(
taskWorkflowQueueName("task.command.workspace.change_model"),
{ sessionId: input.sessionId, model: input.model, authSessionId: input.authSessionId } satisfies TaskWorkspaceChangeModelCommand,
{
wait: true,
timeout: 10_000,
},
);
},
async sendWorkspaceMessage(c, input: TaskWorkspaceSendMessageInput): Promise<void> {
const self = selfTask(c);
await self.send(
taskWorkflowQueueName("task.command.workspace.send_message"),
{
sessionId: input.sessionId,
text: input.text,
attachments: input.attachments,
authSessionId: input.authSessionId,
} satisfies TaskWorkspaceSendMessageCommand,
{
wait: false,
},
);
},
async stopWorkspaceSession(c, input: TaskSessionCommand): Promise<void> {
const self = selfTask(c);
await self.send(
taskWorkflowQueueName("task.command.workspace.stop_session"),
{ sessionId: input.sessionId, authSessionId: input.authSessionId } satisfies TaskWorkspaceSessionCommand,
{
wait: false,
},
);
},
async syncWorkspaceSessionStatus(c, input: TaskStatusSyncCommand): Promise<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.workspace.sync_session_status"), input, {
wait: true,
timeout: 20_000,
});
},
async closeWorkspaceSession(c, input: TaskSessionCommand): Promise<void> {
const self = selfTask(c);
await self.send(
taskWorkflowQueueName("task.command.workspace.close_session"),
{ sessionId: input.sessionId, authSessionId: input.authSessionId } satisfies TaskWorkspaceSessionCommand,
{
wait: false,
},
);
},
async publishWorkspacePr(c): Promise<void> {
const self = selfTask(c);
await self.send(
taskWorkflowQueueName("task.command.workspace.publish_pr"),
{},
{
wait: false,
},
);
},
async revertWorkspaceFile(c, input: { path: string }): Promise<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.workspace.revert_file"), input, {
wait: false,
});
},
},
run: workflow(runTaskWorkflow),
});

View file

@ -65,7 +65,7 @@ export async function handlePushActivity(loopCtx: any, msg: any): Promise<void>
await msg.complete({ ok: true });
}
export async function handleSimpleCommandActivity(loopCtx: any, msg: any, _statusMessage: string, historyKind: string): Promise<void> {
export async function handleSimpleCommandActivity(loopCtx: any, msg: any, historyKind: string): Promise<void> {
await appendAuditLog(loopCtx, historyKind, { reason: msg.body?.reason ?? null });
await msg.complete({ ok: true });
}

View file

@ -2,7 +2,7 @@
import { eq } from "drizzle-orm";
import type { TaskRecord, TaskStatus } from "@sandbox-agent/foundry-shared";
import { task as taskTable, taskRuntime, taskSandboxes } from "../db/schema.js";
import { getOrCreateAuditLog } from "../../handles.js";
import { getOrCreateAuditLog, getOrCreateRepository } from "../../handles.js";
import { broadcastTaskUpdate } from "../workspace.js";
export const TASK_ROW_ID = 1;
@ -66,6 +66,7 @@ export async function setTaskState(ctx: any, status: TaskStatus): Promise<void>
export async function getCurrentRecord(ctx: any): Promise<TaskRecord> {
const db = ctx.db;
const repository = await getOrCreateRepository(ctx, ctx.state.organizationId, ctx.state.repoId);
const row = await db
.select({
branchName: taskTable.branchName,
@ -73,6 +74,7 @@ export async function getCurrentRecord(ctx: any): Promise<TaskRecord> {
task: taskTable.task,
sandboxProviderId: taskTable.sandboxProviderId,
status: taskTable.status,
pullRequestJson: taskTable.pullRequestJson,
activeSandboxId: taskRuntime.activeSandboxId,
createdAt: taskTable.createdAt,
updatedAt: taskTable.updatedAt,
@ -86,6 +88,16 @@ export async function getCurrentRecord(ctx: any): Promise<TaskRecord> {
throw new Error(`Task not found: ${ctx.state.taskId}`);
}
const repositoryMetadata = await repository.getRepositoryMetadata({});
let pullRequest = null;
if (row.pullRequestJson) {
try {
pullRequest = JSON.parse(row.pullRequestJson);
} catch {
pullRequest = null;
}
}
const sandboxes = await db
.select({
sandboxId: taskSandboxes.sandboxId,
@ -102,7 +114,7 @@ export async function getCurrentRecord(ctx: any): Promise<TaskRecord> {
return {
organizationId: ctx.state.organizationId,
repoId: ctx.state.repoId,
repoRemote: ctx.state.repoRemote,
repoRemote: repositoryMetadata.remoteUrl,
taskId: ctx.state.taskId,
branchName: row.branchName,
title: row.title,
@ -110,6 +122,7 @@ export async function getCurrentRecord(ctx: any): Promise<TaskRecord> {
sandboxProviderId: row.sandboxProviderId,
status: row.status,
activeSandboxId: row.activeSandboxId ?? null,
pullRequest,
sandboxes: sandboxes.map((sb) => ({
sandboxId: sb.sandboxId,
sandboxProviderId: sb.sandboxProviderId,
@ -119,25 +132,20 @@ export async function getCurrentRecord(ctx: any): Promise<TaskRecord> {
createdAt: sb.createdAt,
updatedAt: sb.updatedAt,
})),
diffStat: null,
prUrl: null,
prAuthor: null,
ciStatus: null,
reviewStatus: null,
reviewer: null,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
} as TaskRecord;
}
export async function appendAuditLog(ctx: any, kind: string, payload: Record<string, unknown>): Promise<void> {
const row = await ctx.db.select({ branchName: taskTable.branchName }).from(taskTable).where(eq(taskTable.id, TASK_ROW_ID)).get();
const auditLog = await getOrCreateAuditLog(ctx, ctx.state.organizationId, ctx.state.repoId);
await auditLog.send(
"auditLog.command.append",
{
kind,
taskId: ctx.state.taskId,
branchName: ctx.state.branchName,
branchName: row?.branchName ?? null,
payload,
},
{

View file

@ -24,10 +24,12 @@ import {
publishWorkspacePr,
renameWorkspaceTask,
renameWorkspaceSession,
selectWorkspaceSession,
revertWorkspaceFile,
sendWorkspaceMessage,
setWorkspaceSessionUnread,
stopWorkspaceSession,
syncTaskPullRequest,
syncWorkspaceSessionStatus,
updateWorkspaceDraft,
} from "../workspace.js";
@ -71,7 +73,7 @@ const commandHandlers: Record<TaskQueueName, WorkflowHandler> = {
await loopCtx.step("init-complete", async () => initCompleteActivity(loopCtx, msg.body));
await msg.complete({ ok: true });
} catch (error) {
await loopCtx.step("init-failed-v3", async () => initFailedActivity(loopCtx, error));
await loopCtx.step("init-failed-v3", async () => initFailedActivity(loopCtx, error, msg.body));
await msg.complete({
ok: false,
error: resolveErrorMessage(error),
@ -92,11 +94,11 @@ const commandHandlers: Record<TaskQueueName, WorkflowHandler> = {
},
"task.command.sync": async (loopCtx, msg) => {
await loopCtx.step("handle-sync", async () => handleSimpleCommandActivity(loopCtx, msg, "sync requested", "task.sync"));
await loopCtx.step("handle-sync", async () => handleSimpleCommandActivity(loopCtx, msg, "task.sync"));
},
"task.command.merge": async (loopCtx, msg) => {
await loopCtx.step("handle-merge", async () => handleSimpleCommandActivity(loopCtx, msg, "merge requested", "task.merge"));
await loopCtx.step("handle-merge", async () => handleSimpleCommandActivity(loopCtx, msg, "task.merge"));
},
"task.command.archive": async (loopCtx, msg) => {
@ -112,6 +114,11 @@ const commandHandlers: Record<TaskQueueName, WorkflowHandler> = {
await loopCtx.step("handle-get", async () => handleGetActivity(loopCtx, msg));
},
"task.command.pull_request.sync": async (loopCtx, msg) => {
await loopCtx.step("task-pull-request-sync", async () => syncTaskPullRequest(loopCtx, msg.body?.pullRequest ?? null));
await msg.complete({ ok: true });
},
"task.command.workspace.mark_unread": async (loopCtx, msg) => {
await loopCtx.step("workspace-mark-unread", async () => markWorkspaceUnread(loopCtx, msg.body?.authSessionId));
await msg.complete({ ok: true });
@ -169,22 +176,23 @@ const commandHandlers: Record<TaskQueueName, WorkflowHandler> = {
await msg.complete({ ok: true });
},
"task.command.workspace.select_session": async (loopCtx, msg) => {
await loopCtx.step("workspace-select-session", async () => selectWorkspaceSession(loopCtx, msg.body.sessionId, msg.body?.authSessionId));
await msg.complete({ ok: true });
},
"task.command.workspace.set_session_unread": async (loopCtx, msg) => {
await loopCtx.step("workspace-set-session-unread", async () =>
setWorkspaceSessionUnread(loopCtx, msg.body.sessionId, msg.body.unread, msg.body?.authSessionId),
);
await loopCtx.step("workspace-set-session-unread", async () => setWorkspaceSessionUnread(loopCtx, msg.body.sessionId, msg.body.unread, msg.body?.authSessionId));
await msg.complete({ ok: true });
},
"task.command.workspace.update_draft": async (loopCtx, msg) => {
await loopCtx.step("workspace-update-draft", async () =>
updateWorkspaceDraft(loopCtx, msg.body.sessionId, msg.body.text, msg.body.attachments, msg.body?.authSessionId),
);
await loopCtx.step("workspace-update-draft", async () => updateWorkspaceDraft(loopCtx, msg.body.sessionId, msg.body.text, msg.body.attachments, msg.body?.authSessionId));
await msg.complete({ ok: true });
},
"task.command.workspace.change_model": async (loopCtx, msg) => {
await loopCtx.step("workspace-change-model", async () => changeWorkspaceModel(loopCtx, msg.body.sessionId, msg.body.model));
await loopCtx.step("workspace-change-model", async () => changeWorkspaceModel(loopCtx, msg.body.sessionId, msg.body.model, msg.body?.authSessionId));
await msg.complete({ ok: true });
},

View file

@ -11,28 +11,34 @@ import { taskWorkflowQueueName } from "./queue.js";
export async function initBootstrapDbActivity(loopCtx: any, body: any): Promise<void> {
const { config } = getActorRuntimeContext();
const sandboxProviderId = body?.sandboxProviderId ?? defaultSandboxProviderId(config);
const task = body?.task;
if (typeof task !== "string" || task.trim().length === 0) {
throw new Error("task initialize requires the task prompt");
}
const now = Date.now();
await loopCtx.db
.insert(taskTable)
.values({
id: TASK_ROW_ID,
branchName: loopCtx.state.branchName,
title: loopCtx.state.title,
task: loopCtx.state.task,
branchName: body?.branchName ?? null,
title: body?.title ?? null,
task,
sandboxProviderId,
status: "init_bootstrap_db",
pullRequestJson: null,
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: taskTable.id,
set: {
branchName: loopCtx.state.branchName,
title: loopCtx.state.title,
task: loopCtx.state.task,
branchName: body?.branchName ?? null,
title: body?.title ?? null,
task,
sandboxProviderId,
status: "init_bootstrap_db",
pullRequestJson: null,
updatedAt: now,
},
})
@ -99,33 +105,36 @@ export async function initCompleteActivity(loopCtx: any, body: any): Promise<voi
});
}
export async function initFailedActivity(loopCtx: any, error: unknown): Promise<void> {
export async function initFailedActivity(loopCtx: any, error: unknown, body?: any): Promise<void> {
const now = Date.now();
const detail = resolveErrorDetail(error);
const messages = collectErrorMessages(error);
const { config } = getActorRuntimeContext();
const sandboxProviderId = defaultSandboxProviderId(config);
const task = typeof body?.task === "string" ? body.task : null;
await loopCtx.db
.insert(taskTable)
.values({
id: TASK_ROW_ID,
branchName: loopCtx.state.branchName ?? null,
title: loopCtx.state.title ?? null,
task: loopCtx.state.task,
branchName: body?.branchName ?? null,
title: body?.title ?? null,
task: task ?? detail,
sandboxProviderId,
status: "error",
pullRequestJson: null,
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: taskTable.id,
set: {
branchName: loopCtx.state.branchName ?? null,
title: loopCtx.state.title ?? null,
task: loopCtx.state.task,
branchName: body?.branchName ?? null,
title: body?.title ?? null,
task: task ?? detail,
sandboxProviderId,
status: "error",
pullRequestJson: null,
updatedAt: now,
},
})

View file

@ -1,8 +1,6 @@
// @ts-nocheck
import { eq } from "drizzle-orm";
import { getTaskSandbox } from "../../handles.js";
import { resolveOrganizationGithubAuth } from "../../../services/github-auth.js";
import { taskSandboxes } from "../db/schema.js";
import { appendAuditLog, getCurrentRecord } from "./common.js";
export interface PushActiveBranchOptions {
@ -13,7 +11,7 @@ export interface PushActiveBranchOptions {
export async function pushActiveBranchActivity(loopCtx: any, options: PushActiveBranchOptions = {}): Promise<void> {
const record = await getCurrentRecord(loopCtx);
const activeSandboxId = record.activeSandboxId;
const branchName = loopCtx.state.branchName ?? record.branchName;
const branchName = record.branchName;
if (!activeSandboxId) {
throw new Error("cannot push: no active sandbox");
@ -28,13 +26,6 @@ export async function pushActiveBranchActivity(loopCtx: any, options: PushActive
throw new Error("cannot push: active sandbox cwd is not set");
}
const now = Date.now();
await loopCtx.db
.update(taskSandboxes)
.set({ statusMessage: `pushing branch ${branchName}`, updatedAt: now })
.where(eq(taskSandboxes.sandboxId, activeSandboxId))
.run();
const script = [
"set -euo pipefail",
`cd ${JSON.stringify(cwd)}`,
@ -62,13 +53,6 @@ export async function pushActiveBranchActivity(loopCtx: any, options: PushActive
throw new Error(`git push failed (${result.exitCode ?? 1}): ${[result.stdout, result.stderr].filter(Boolean).join("")}`);
}
const updatedAt = Date.now();
await loopCtx.db
.update(taskSandboxes)
.set({ statusMessage: `push complete for ${branchName}`, updatedAt })
.where(eq(taskSandboxes.sandboxId, activeSandboxId))
.run();
await appendAuditLog(loopCtx, options.historyKind ?? "task.push", {
reason: options.reason ?? null,
branchName,

View file

@ -9,12 +9,14 @@ export const TASK_QUEUE_NAMES = [
"task.command.archive",
"task.command.kill",
"task.command.get",
"task.command.pull_request.sync",
"task.command.workspace.mark_unread",
"task.command.workspace.rename_task",
"task.command.workspace.create_session",
"task.command.workspace.create_session_and_send",
"task.command.workspace.ensure_session",
"task.command.workspace.rename_session",
"task.command.workspace.select_session",
"task.command.workspace.set_session_unread",
"task.command.workspace.update_draft",
"task.command.workspace.change_model",

View file

@ -2,13 +2,17 @@
import { randomUUID } from "node:crypto";
import { basename, dirname } from "node:path";
import { asc, eq } from "drizzle-orm";
import { DEFAULT_WORKSPACE_MODEL_GROUPS, DEFAULT_WORKSPACE_MODEL_ID, workspaceAgentForModel, workspaceSandboxAgentIdForModel } from "@sandbox-agent/foundry-shared";
import { getActorRuntimeContext } from "../context.js";
import { getOrCreateRepository, getOrCreateTaskSandbox, getOrCreateUser, getTaskSandbox, selfTask } from "../handles.js";
import { SANDBOX_REPO_CWD } from "../sandbox/index.js";
import { resolveSandboxProviderId } from "../../sandbox-config.js";
import { getBetterAuthService } from "../../services/better-auth.js";
import { expectQueueResponse } from "../../services/queue.js";
import { resolveOrganizationGithubAuth } from "../../services/github-auth.js";
import { githubRepoFullNameFromRemote } from "../../services/repo.js";
import { repositoryWorkflowQueueName } from "../repository/workflow.js";
import { userWorkflowQueueName } from "../user/workflow.js";
import { task as taskTable, taskRuntime, taskSandboxes, taskWorkspaceSessions } from "./db/schema.js";
import { getCurrentRecord } from "./workflow/common.js";
@ -21,24 +25,29 @@ function emptyGitState() {
};
}
const FALLBACK_MODEL = "claude-sonnet-4";
function isCodexModel(model: string) {
return model.startsWith("gpt-") || model.startsWith("o");
}
const FALLBACK_MODEL = DEFAULT_WORKSPACE_MODEL_ID;
function agentKindForModel(model: string) {
if (isCodexModel(model)) {
return "Codex";
}
return "Claude";
return workspaceAgentForModel(model);
}
export function agentTypeForModel(model: string) {
if (isCodexModel(model)) {
return "codex";
export function sandboxAgentIdForModel(model: string) {
return workspaceSandboxAgentIdForModel(model);
}
async function resolveWorkspaceModelGroups(c: any): Promise<any[]> {
try {
const sandbox = await getOrCreateTaskSandbox(c, c.state.organizationId, stableSandboxId(c));
const groups = await sandbox.listWorkspaceModelGroups();
return Array.isArray(groups) && groups.length > 0 ? groups : DEFAULT_WORKSPACE_MODEL_GROUPS;
} catch {
return DEFAULT_WORKSPACE_MODEL_GROUPS;
}
return "claude";
}
async function resolveSandboxAgentForModel(c: any, model: string): Promise<string> {
const groups = await resolveWorkspaceModelGroups(c);
return workspaceSandboxAgentIdForModel(model, groups);
}
function repoLabelFromRemote(remoteUrl: string): string {
@ -56,6 +65,11 @@ function repoLabelFromRemote(remoteUrl: string): string {
return basename(trimmed.replace(/\.git$/, ""));
}
async function getRepositoryMetadata(c: any): Promise<{ defaultBranch: string | null; fullName: string | null; remoteUrl: string }> {
const repository = await getOrCreateRepository(c, c.state.organizationId, c.state.repoId);
return await repository.getRepositoryMetadata({});
}
function parseDraftAttachments(value: string | null | undefined): Array<any> {
if (!value) {
return [];
@ -220,11 +234,17 @@ async function upsertUserTaskState(c: any, authSessionId: string | null | undefi
}
const user = await getOrCreateUser(c, userId);
await user.upsertTaskState({
taskId: c.state.taskId,
sessionId,
patch,
});
expectQueueResponse(
await user.send(
userWorkflowQueueName("user.command.task_state.upsert"),
{
taskId: c.state.taskId,
sessionId,
patch,
},
{ wait: true, timeout: 60_000 },
),
);
}
async function deleteUserTaskState(c: any, authSessionId: string | null | undefined, sessionId: string): Promise<void> {
@ -239,10 +259,16 @@ async function deleteUserTaskState(c: any, authSessionId: string | null | undefi
}
const user = await getOrCreateUser(c, userId);
await user.deleteTaskState({
taskId: c.state.taskId,
sessionId,
});
expectQueueResponse(
await user.send(
userWorkflowQueueName("user.command.task_state.delete"),
{
taskId: c.state.taskId,
sessionId,
},
{ wait: true, timeout: 60_000 },
),
);
}
async function resolveDefaultModel(c: any, authSessionId?: string | null): Promise<string> {
@ -367,7 +393,7 @@ async function getTaskSandboxRuntime(
}> {
const { config } = getActorRuntimeContext();
const sandboxId = stableSandboxId(c);
const sandboxProviderId = resolveSandboxProviderId(config, record.sandboxProviderId ?? c.state.sandboxProviderId ?? null);
const sandboxProviderId = resolveSandboxProviderId(config, record.sandboxProviderId ?? null);
const sandbox = await getOrCreateTaskSandbox(c, c.state.organizationId, sandboxId, {});
const actorId = typeof sandbox.resolve === "function" ? await sandbox.resolve().catch(() => null) : null;
const switchTarget = sandboxProviderId === "local" ? `sandbox://local/${sandboxId}` : `sandbox://e2b/${sandboxId}`;
@ -381,7 +407,6 @@ async function getTaskSandboxRuntime(
sandboxActorId: typeof actorId === "string" ? actorId : null,
switchTarget,
cwd: SANDBOX_REPO_CWD,
statusMessage: "sandbox ready",
createdAt: now,
updatedAt: now,
})
@ -436,8 +461,7 @@ async function ensureSandboxRepo(c: any, sandbox: any, record: any, opts?: { ski
}
const auth = await resolveOrganizationGithubAuth(c, c.state.organizationId);
const repository = await getOrCreateRepository(c, c.state.organizationId, c.state.repoId, c.state.repoRemote);
const metadata = await repository.getRepositoryMetadata({});
const metadata = await getRepositoryMetadata(c);
const baseRef = metadata.defaultBranch ?? "main";
const sandboxRepoRoot = dirname(SANDBOX_REPO_CWD);
const script = [
@ -445,7 +469,7 @@ async function ensureSandboxRepo(c: any, sandbox: any, record: any, opts?: { ski
`mkdir -p ${JSON.stringify(sandboxRepoRoot)}`,
"git config --global credential.helper '!f() { echo username=x-access-token; echo password=${GH_TOKEN:-$GITHUB_TOKEN}; }; f'",
`if [ ! -d ${JSON.stringify(`${SANDBOX_REPO_CWD}/.git`)} ]; then rm -rf ${JSON.stringify(SANDBOX_REPO_CWD)} && git clone ${JSON.stringify(
c.state.repoRemote,
metadata.remoteUrl,
)} ${JSON.stringify(SANDBOX_REPO_CWD)}; fi`,
`cd ${JSON.stringify(SANDBOX_REPO_CWD)}`,
"git fetch origin --prune",
@ -774,21 +798,8 @@ function computeWorkspaceTaskStatus(record: any, sessions: Array<any>) {
return "idle";
}
async function readPullRequestSummary(c: any, branchName: string | null) {
if (!branchName) {
return null;
}
try {
const repository = await getOrCreateRepository(c, c.state.organizationId, c.state.repoId, c.state.repoRemote);
return await repository.getPullRequestForBranch({ branchName });
} catch {
return null;
}
}
export async function ensureWorkspaceSeeded(c: any): Promise<any> {
return await getCurrentRecord({ db: c.db, state: c.state });
return await getCurrentRecord(c);
}
function buildSessionSummary(meta: any, userState?: any): any {
@ -853,20 +864,24 @@ function buildSessionDetailFromMeta(meta: any, userState?: any): any {
*/
export async function buildTaskSummary(c: any, authSessionId?: string | null): Promise<any> {
const record = await ensureWorkspaceSeeded(c);
const repositoryMetadata = await getRepositoryMetadata(c);
const sessions = await listSessionMetaRows(c);
await maybeScheduleWorkspaceRefreshes(c, record, sessions);
const userTaskState = await getUserTaskState(c, authSessionId);
const taskStatus = computeWorkspaceTaskStatus(record, sessions);
const activeSessionId =
userTaskState.activeSessionId && sessions.some((meta) => meta.sessionId === userTaskState.activeSessionId) ? userTaskState.activeSessionId : null;
return {
id: c.state.taskId,
repoId: c.state.repoId,
title: record.title ?? "New Task",
status: taskStatus ?? "new",
repoName: repoLabelFromRemote(c.state.repoRemote),
status: taskStatus,
repoName: repoLabelFromRemote(repositoryMetadata.remoteUrl),
updatedAtMs: record.updatedAt,
branch: record.branchName,
pullRequest: await readPullRequestSummary(c, record.branchName),
pullRequest: record.pullRequest ?? null,
activeSessionId,
sessionsSummary: sessions.map((meta) => buildSessionSummary(meta, userTaskState.bySessionId.get(meta.sessionId))),
};
}
@ -885,10 +900,6 @@ export async function buildTaskDetail(c: any, authSessionId?: string | null): Pr
return {
...summary,
task: record.task,
runtimeStatus: summary.status,
diffStat: record.diffStat ?? null,
prUrl: record.prUrl ?? null,
reviewStatus: record.reviewStatus ?? null,
fileChanges: gitState.fileChanges,
diffs: gitState.diffs,
fileTree: gitState.fileTree,
@ -959,8 +970,14 @@ export async function getSessionDetail(c: any, sessionId: string, authSessionId?
* - Broadcast full detail/session payloads down to direct task subscribers.
*/
export async function broadcastTaskUpdate(c: any, options?: { sessionId?: string }): Promise<void> {
const repository = await getOrCreateRepository(c, c.state.organizationId, c.state.repoId, c.state.repoRemote);
await repository.applyTaskSummaryUpdate({ taskSummary: await buildTaskSummary(c) });
const repository = await getOrCreateRepository(c, c.state.organizationId, c.state.repoId);
await expectQueueResponse<{ ok: true }>(
await repository.send(
repositoryWorkflowQueueName("repository.command.applyTaskSummaryUpdate"),
{ taskSummary: await buildTaskSummary(c) },
{ wait: true, timeout: 10_000 },
),
);
c.broadcast("taskUpdated", {
type: "taskUpdated",
detail: await buildTaskDetail(c),
@ -1010,6 +1027,19 @@ export async function renameWorkspaceTask(c: any, value: string): Promise<void>
await broadcastTaskUpdate(c);
}
export async function syncTaskPullRequest(c: any, pullRequest: any): Promise<void> {
const now = pullRequest?.updatedAtMs ?? Date.now();
await c.db
.update(taskTable)
.set({
pullRequestJson: pullRequest ? JSON.stringify(pullRequest) : null,
updatedAt: now,
})
.where(eq(taskTable.id, 1))
.run();
await broadcastTaskUpdate(c);
}
export async function createWorkspaceSession(c: any, model?: string, authSessionId?: string): Promise<{ sessionId: string }> {
const sessionId = `session-${randomUUID()}`;
const record = await ensureWorkspaceSeeded(c);
@ -1055,9 +1085,10 @@ export async function ensureWorkspaceSession(c: any, sessionId: string, model?:
const runtime = await getTaskSandboxRuntime(c, record);
await ensureSandboxRepo(c, runtime.sandbox, record);
const resolvedModel = model ?? meta.model ?? (await resolveDefaultModel(c, authSessionId));
const resolvedAgent = await resolveSandboxAgentForModel(c, resolvedModel);
await runtime.sandbox.createSession({
id: meta.sandboxSessionId ?? sessionId,
agent: agentTypeForModel(resolvedModel),
agent: resolvedAgent,
model: resolvedModel,
sessionInit: {
cwd: runtime.cwd,
@ -1113,6 +1144,17 @@ export async function renameWorkspaceSession(c: any, sessionId: string, title: s
await broadcastTaskUpdate(c, { sessionId });
}
export async function selectWorkspaceSession(c: any, sessionId: string, authSessionId?: string): Promise<void> {
const meta = await readSessionMeta(c, sessionId);
if (!meta || meta.closed) {
return;
}
await upsertUserTaskState(c, authSessionId, sessionId, {
activeSessionId: sessionId,
});
await broadcastTaskUpdate(c, { sessionId });
}
export async function setWorkspaceSessionUnread(c: any, sessionId: string, unread: boolean, authSessionId?: string): Promise<void> {
await upsertUserTaskState(c, authSessionId, sessionId, {
unread,
@ -1129,7 +1171,7 @@ export async function updateWorkspaceDraft(c: any, sessionId: string, text: stri
await broadcastTaskUpdate(c, { sessionId });
}
export async function changeWorkspaceModel(c: any, sessionId: string, model: string): Promise<void> {
export async function changeWorkspaceModel(c: any, sessionId: string, model: string, _authSessionId?: string): Promise<void> {
const meta = await readSessionMeta(c, sessionId);
if (!meta || meta.closed) {
return;
@ -1295,6 +1337,13 @@ export async function closeWorkspaceSession(c: any, sessionId: string, authSessi
closed: 1,
thinkingSinceMs: null,
});
const remainingSessions = sessions.filter((candidate) => candidate.sessionId !== sessionId && candidate.closed !== true);
const userTaskState = await getUserTaskState(c, authSessionId);
if (userTaskState.activeSessionId === sessionId && remainingSessions[0]) {
await upsertUserTaskState(c, authSessionId, remainingSessions[0].sessionId, {
activeSessionId: remainingSessions[0].sessionId,
});
}
await deleteUserTaskState(c, authSessionId, sessionId);
await broadcastTaskUpdate(c);
}
@ -1316,19 +1365,30 @@ export async function publishWorkspacePr(c: any): Promise<void> {
if (!record.branchName) {
throw new Error("cannot publish PR without a branch");
}
const repository = await getOrCreateRepository(c, c.state.organizationId, c.state.repoId, c.state.repoRemote);
const metadata = await repository.getRepositoryMetadata({});
const repoFullName = metadata.fullName ?? githubRepoFullNameFromRemote(c.state.repoRemote);
const metadata = await getRepositoryMetadata(c);
const repoFullName = metadata.fullName ?? githubRepoFullNameFromRemote(metadata.remoteUrl);
if (!repoFullName) {
throw new Error(`Unable to resolve GitHub repository for ${c.state.repoRemote}`);
throw new Error(`Unable to resolve GitHub repository for ${metadata.remoteUrl}`);
}
const { driver } = getActorRuntimeContext();
const auth = await resolveOrganizationGithubAuth(c, c.state.organizationId);
await driver.github.createPr(repoFullName, record.branchName, record.title ?? c.state.task, undefined, {
const created = await driver.github.createPr(repoFullName, record.branchName, record.title ?? record.task, undefined, {
githubToken: auth?.githubToken ?? null,
baseBranch: metadata.defaultBranch ?? undefined,
});
await broadcastTaskUpdate(c);
await syncTaskPullRequest(c, {
number: created.number,
title: record.title ?? record.task,
body: null,
state: "open",
url: created.url,
headRefName: record.branchName,
baseRefName: metadata.defaultBranch ?? "main",
authorLogin: null,
isDraft: false,
merged: false,
updatedAtMs: Date.now(),
});
}
export async function revertWorkspaceFile(c: any, path: string): Promise<void> {

View file

@ -0,0 +1,47 @@
import { asc, count as sqlCount, desc } from "drizzle-orm";
import { applyJoinToRow, applyJoinToRows, buildWhere, columnFor, tableFor } from "../query-helpers.js";
export const betterAuthActions = {
// Better Auth adapter action — called by the Better Auth adapter in better-auth.ts.
// Schema and behavior are constrained by Better Auth.
async betterAuthFindOneRecord(c, input: { model: string; where: any[]; join?: any }) {
const table = tableFor(input.model);
const predicate = buildWhere(table, input.where);
const row = predicate ? await c.db.select().from(table).where(predicate).get() : await c.db.select().from(table).get();
return await applyJoinToRow(c, input.model, row ?? null, input.join);
},
// Better Auth adapter action — called by the Better Auth adapter in better-auth.ts.
// Schema and behavior are constrained by Better Auth.
async betterAuthFindManyRecords(c, input: { model: string; where?: any[]; limit?: number; offset?: number; sortBy?: any; join?: any }) {
const table = tableFor(input.model);
const predicate = buildWhere(table, input.where);
let query: any = c.db.select().from(table);
if (predicate) {
query = query.where(predicate);
}
if (input.sortBy?.field) {
const column = columnFor(input.model, table, input.sortBy.field);
query = query.orderBy(input.sortBy.direction === "asc" ? asc(column) : desc(column));
}
if (typeof input.limit === "number") {
query = query.limit(input.limit);
}
if (typeof input.offset === "number") {
query = query.offset(input.offset);
}
const rows = await query.all();
return await applyJoinToRows(c, input.model, rows, input.join);
},
// Better Auth adapter action — called by the Better Auth adapter in better-auth.ts.
// Schema and behavior are constrained by Better Auth.
async betterAuthCountRecords(c, input: { model: string; where?: any[] }) {
const table = tableFor(input.model);
const predicate = buildWhere(table, input.where);
const row = predicate
? await c.db.select({ value: sqlCount() }).from(table).where(predicate).get()
: await c.db.select({ value: sqlCount() }).from(table).get();
return row?.value ?? 0;
},
};

View file

@ -0,0 +1,44 @@
import { eq } from "drizzle-orm";
import { authAccounts, authSessions, authUsers, sessionState, userProfiles, userTaskState } from "../db/schema.js";
import { materializeRow } from "../query-helpers.js";
export const userActions = {
// Custom Foundry action — not part of Better Auth.
async getAppAuthState(c, input: { sessionId: string }) {
const session = await c.db.select().from(authSessions).where(eq(authSessions.id, input.sessionId)).get();
if (!session) {
return null;
}
const [user, profile, currentSessionState, accounts] = await Promise.all([
c.db.select().from(authUsers).where(eq(authUsers.authUserId, session.userId)).get(),
c.db.select().from(userProfiles).where(eq(userProfiles.userId, session.userId)).get(),
c.db.select().from(sessionState).where(eq(sessionState.sessionId, input.sessionId)).get(),
c.db.select().from(authAccounts).where(eq(authAccounts.userId, session.userId)).all(),
]);
return {
session,
user: materializeRow("user", user),
profile: profile ?? null,
sessionState: currentSessionState ?? null,
accounts,
};
},
// Custom Foundry action — not part of Better Auth.
async getTaskState(c, input: { taskId: string }) {
const rows = await c.db.select().from(userTaskState).where(eq(userTaskState.taskId, input.taskId)).all();
const activeSessionId = rows.find((row) => typeof row.activeSessionId === "string" && row.activeSessionId.length > 0)?.activeSessionId ?? null;
return {
taskId: input.taskId,
activeSessionId,
sessions: rows.map((row) => ({
sessionId: row.sessionId,
unread: row.unread === 1,
draftText: row.draftText,
draftAttachmentsJson: row.draftAttachmentsJson,
draftUpdatedAt: row.draftUpdatedAt ?? null,
updatedAt: row.updatedAt,
})),
};
},
};

View file

@ -23,15 +23,19 @@ export default {
journal,
migrations: {
m0000: `CREATE TABLE \`user\` (
\`id\` text PRIMARY KEY NOT NULL,
\`id\` integer PRIMARY KEY NOT NULL,
\`auth_user_id\` text NOT NULL,
\`name\` text NOT NULL,
\`email\` text NOT NULL,
\`email_verified\` integer NOT NULL,
\`image\` text,
\`created_at\` integer NOT NULL,
\`updated_at\` integer NOT NULL
\`updated_at\` integer NOT NULL,
CONSTRAINT \`user_singleton_id_check\` CHECK(\`id\` = 1)
);
--> statement-breakpoint
CREATE UNIQUE INDEX \`user_auth_user_id_idx\` ON \`user\` (\`auth_user_id\`);
--> statement-breakpoint
CREATE TABLE \`session\` (
\`id\` text PRIMARY KEY NOT NULL,
\`token\` text NOT NULL,
@ -69,7 +73,7 @@ CREATE TABLE \`user_profiles\` (
\`github_account_id\` text,
\`github_login\` text,
\`role_label\` text NOT NULL,
\`default_model\` text DEFAULT 'claude-sonnet-4' NOT NULL,
\`default_model\` text DEFAULT 'gpt-5.3-codex' NOT NULL,
\`eligible_organization_ids_json\` text NOT NULL,
\`starter_repo_status\` text NOT NULL,
\`starter_repo_starred_at\` integer,

View file

@ -1,16 +1,25 @@
import { check, integer, primaryKey, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
import { sql } from "drizzle-orm";
import { DEFAULT_WORKSPACE_MODEL_ID } from "@sandbox-agent/foundry-shared";
/** Better Auth core model — schema defined at https://better-auth.com/docs/concepts/database */
export const authUsers = sqliteTable("user", {
id: text("id").notNull().primaryKey(),
name: text("name").notNull(),
email: text("email").notNull(),
emailVerified: integer("email_verified").notNull(),
image: text("image"),
createdAt: integer("created_at").notNull(),
updatedAt: integer("updated_at").notNull(),
});
export const authUsers = sqliteTable(
"user",
{
id: integer("id").primaryKey(),
authUserId: text("auth_user_id").notNull(),
name: text("name").notNull(),
email: text("email").notNull(),
emailVerified: integer("email_verified").notNull(),
image: text("image"),
createdAt: integer("created_at").notNull(),
updatedAt: integer("updated_at").notNull(),
},
(table) => ({
authUserIdIdx: uniqueIndex("user_auth_user_id_idx").on(table.authUserId),
singletonCheck: check("user_singleton_id_check", sql`${table.id} = 1`),
}),
);
/** Better Auth core model — schema defined at https://better-auth.com/docs/concepts/database */
export const authSessions = sqliteTable(
@ -62,7 +71,7 @@ export const userProfiles = sqliteTable(
githubAccountId: text("github_account_id"),
githubLogin: text("github_login"),
roleLabel: text("role_label").notNull(),
defaultModel: text("default_model").notNull().default("claude-sonnet-4"),
defaultModel: text("default_model").notNull().default(DEFAULT_WORKSPACE_MODEL_ID),
eligibleOrganizationIdsJson: text("eligible_organization_ids_json").notNull(),
starterRepoStatus: text("starter_repo_status").notNull(),
starterRepoStarredAt: integer("starter_repo_starred_at"),

View file

@ -1,158 +1,13 @@
import { and, asc, count as sqlCount, desc, eq, gt, gte, inArray, isNotNull, isNull, like, lt, lte, ne, notInArray, or } from "drizzle-orm";
import { actor } from "rivetkit";
import { actor, queue } from "rivetkit";
import { workflow } from "rivetkit/workflow";
import { userDb } from "./db/db.js";
import { authAccounts, authSessions, authUsers, sessionState, userProfiles, userTaskState } from "./db/schema.js";
const tables = {
user: authUsers,
session: authSessions,
account: authAccounts,
userProfiles,
sessionState,
userTaskState,
} as const;
function tableFor(model: string) {
const table = tables[model as keyof typeof tables];
if (!table) {
throw new Error(`Unsupported user model: ${model}`);
}
return table as any;
}
function columnFor(table: any, field: string) {
const column = table[field];
if (!column) {
throw new Error(`Unsupported user field: ${field}`);
}
return column;
}
function normalizeValue(value: unknown): unknown {
if (value instanceof Date) {
return value.getTime();
}
if (Array.isArray(value)) {
return value.map((entry) => normalizeValue(entry));
}
return value;
}
function clauseToExpr(table: any, clause: any) {
const column = columnFor(table, clause.field);
const value = normalizeValue(clause.value);
switch (clause.operator) {
case "ne":
return value === null ? isNotNull(column) : ne(column, value as any);
case "lt":
return lt(column, value as any);
case "lte":
return lte(column, value as any);
case "gt":
return gt(column, value as any);
case "gte":
return gte(column, value as any);
case "in":
return inArray(column, Array.isArray(value) ? (value as any[]) : [value as any]);
case "not_in":
return notInArray(column, Array.isArray(value) ? (value as any[]) : [value as any]);
case "contains":
return like(column, `%${String(value ?? "")}%`);
case "starts_with":
return like(column, `${String(value ?? "")}%`);
case "ends_with":
return like(column, `%${String(value ?? "")}`);
case "eq":
default:
return value === null ? isNull(column) : eq(column, value as any);
}
}
function buildWhere(table: any, where: any[] | undefined) {
if (!where || where.length === 0) {
return undefined;
}
let expr = clauseToExpr(table, where[0]);
for (const clause of where.slice(1)) {
const next = clauseToExpr(table, clause);
expr = clause.connector === "OR" ? or(expr, next) : and(expr, next);
}
return expr;
}
function applyJoinToRow(c: any, model: string, row: any, join: any) {
if (!row || !join) {
return row;
}
if (model === "session" && join.user) {
return c.db
.select()
.from(authUsers)
.where(eq(authUsers.id, row.userId))
.get()
.then((user: any) => ({ ...row, user: user ?? null }));
}
if (model === "account" && join.user) {
return c.db
.select()
.from(authUsers)
.where(eq(authUsers.id, row.userId))
.get()
.then((user: any) => ({ ...row, user: user ?? null }));
}
if (model === "user" && join.account) {
return c.db
.select()
.from(authAccounts)
.where(eq(authAccounts.userId, row.id))
.all()
.then((accounts: any[]) => ({ ...row, account: accounts }));
}
return Promise.resolve(row);
}
async function applyJoinToRows(c: any, model: string, rows: any[], join: any) {
if (!join || rows.length === 0) {
return rows;
}
if (model === "session" && join.user) {
const userIds = [...new Set(rows.map((row) => row.userId).filter(Boolean))];
const users = userIds.length > 0 ? await c.db.select().from(authUsers).where(inArray(authUsers.id, userIds)).all() : [];
const userMap = new Map(users.map((user: any) => [user.id, user]));
return rows.map((row) => ({ ...row, user: userMap.get(row.userId) ?? null }));
}
if (model === "account" && join.user) {
const userIds = [...new Set(rows.map((row) => row.userId).filter(Boolean))];
const users = userIds.length > 0 ? await c.db.select().from(authUsers).where(inArray(authUsers.id, userIds)).all() : [];
const userMap = new Map(users.map((user: any) => [user.id, user]));
return rows.map((row) => ({ ...row, user: userMap.get(row.userId) ?? null }));
}
if (model === "user" && join.account) {
const userIds = rows.map((row) => row.id);
const accounts = userIds.length > 0 ? await c.db.select().from(authAccounts).where(inArray(authAccounts.userId, userIds)).all() : [];
const accountsByUserId = new Map<string, any[]>();
for (const account of accounts) {
const entries = accountsByUserId.get(account.userId) ?? [];
entries.push(account);
accountsByUserId.set(account.userId, entries);
}
return rows.map((row) => ({ ...row, account: accountsByUserId.get(row.id) ?? [] }));
}
return rows;
}
import { betterAuthActions } from "./actions/better-auth.js";
import { userActions } from "./actions/user.js";
import { USER_QUEUE_NAMES, runUserWorkflow } from "./workflow.js";
export const user = actor({
db: userDb,
queues: Object.fromEntries(USER_QUEUE_NAMES.map((name) => [name, queue()])),
options: {
name: "User",
icon: "shield",
@ -162,312 +17,8 @@ export const user = actor({
userId: input.userId,
}),
actions: {
// Better Auth adapter action — called by the Better Auth adapter in better-auth.ts.
// Schema and behavior are constrained by Better Auth.
async createAuthRecord(c, input: { model: string; data: Record<string, unknown> }) {
const table = tableFor(input.model);
await c.db
.insert(table)
.values(input.data as any)
.run();
return await c.db
.select()
.from(table)
.where(eq(columnFor(table, "id"), input.data.id as any))
.get();
},
// Better Auth adapter action — called by the Better Auth adapter in better-auth.ts.
// Schema and behavior are constrained by Better Auth.
async findOneAuthRecord(c, input: { model: string; where: any[]; join?: any }) {
const table = tableFor(input.model);
const predicate = buildWhere(table, input.where);
const row = predicate ? await c.db.select().from(table).where(predicate).get() : await c.db.select().from(table).get();
return await applyJoinToRow(c, input.model, row ?? null, input.join);
},
// Better Auth adapter action — called by the Better Auth adapter in better-auth.ts.
// Schema and behavior are constrained by Better Auth.
async findManyAuthRecords(c, input: { model: string; where?: any[]; limit?: number; offset?: number; sortBy?: any; join?: any }) {
const table = tableFor(input.model);
const predicate = buildWhere(table, input.where);
let query: any = c.db.select().from(table);
if (predicate) {
query = query.where(predicate);
}
if (input.sortBy?.field) {
const column = columnFor(table, input.sortBy.field);
query = query.orderBy(input.sortBy.direction === "asc" ? asc(column) : desc(column));
}
if (typeof input.limit === "number") {
query = query.limit(input.limit);
}
if (typeof input.offset === "number") {
query = query.offset(input.offset);
}
const rows = await query.all();
return await applyJoinToRows(c, input.model, rows, input.join);
},
// Better Auth adapter action — called by the Better Auth adapter in better-auth.ts.
// Schema and behavior are constrained by Better Auth.
async updateAuthRecord(c, input: { model: string; where: any[]; update: Record<string, unknown> }) {
const table = tableFor(input.model);
const predicate = buildWhere(table, input.where);
if (!predicate) {
throw new Error("updateAuthRecord requires a where clause");
}
await c.db
.update(table)
.set(input.update as any)
.where(predicate)
.run();
return await c.db.select().from(table).where(predicate).get();
},
// Better Auth adapter action — called by the Better Auth adapter in better-auth.ts.
// Schema and behavior are constrained by Better Auth.
async updateManyAuthRecords(c, input: { model: string; where: any[]; update: Record<string, unknown> }) {
const table = tableFor(input.model);
const predicate = buildWhere(table, input.where);
if (!predicate) {
throw new Error("updateManyAuthRecords requires a where clause");
}
await c.db
.update(table)
.set(input.update as any)
.where(predicate)
.run();
const row = await c.db.select({ value: sqlCount() }).from(table).where(predicate).get();
return row?.value ?? 0;
},
// Better Auth adapter action — called by the Better Auth adapter in better-auth.ts.
// Schema and behavior are constrained by Better Auth.
async deleteAuthRecord(c, input: { model: string; where: any[] }) {
const table = tableFor(input.model);
const predicate = buildWhere(table, input.where);
if (!predicate) {
throw new Error("deleteAuthRecord requires a where clause");
}
await c.db.delete(table).where(predicate).run();
},
// Better Auth adapter action — called by the Better Auth adapter in better-auth.ts.
// Schema and behavior are constrained by Better Auth.
async deleteManyAuthRecords(c, input: { model: string; where: any[] }) {
const table = tableFor(input.model);
const predicate = buildWhere(table, input.where);
if (!predicate) {
throw new Error("deleteManyAuthRecords requires a where clause");
}
const rows = await c.db.select().from(table).where(predicate).all();
await c.db.delete(table).where(predicate).run();
return rows.length;
},
// Better Auth adapter action — called by the Better Auth adapter in better-auth.ts.
// Schema and behavior are constrained by Better Auth.
async countAuthRecords(c, input: { model: string; where?: any[] }) {
const table = tableFor(input.model);
const predicate = buildWhere(table, input.where);
const row = predicate
? await c.db.select({ value: sqlCount() }).from(table).where(predicate).get()
: await c.db.select({ value: sqlCount() }).from(table).get();
return row?.value ?? 0;
},
// Custom Foundry action — not part of Better Auth.
async getAppAuthState(c, input: { sessionId: string }) {
const session = await c.db.select().from(authSessions).where(eq(authSessions.id, input.sessionId)).get();
if (!session) {
return null;
}
const [user, profile, currentSessionState, accounts] = await Promise.all([
c.db.select().from(authUsers).where(eq(authUsers.id, session.userId)).get(),
c.db.select().from(userProfiles).where(eq(userProfiles.userId, session.userId)).get(),
c.db.select().from(sessionState).where(eq(sessionState.sessionId, input.sessionId)).get(),
c.db.select().from(authAccounts).where(eq(authAccounts.userId, session.userId)).all(),
]);
return {
session,
user,
profile: profile ?? null,
sessionState: currentSessionState ?? null,
accounts,
};
},
// Custom Foundry action — not part of Better Auth.
async upsertUserProfile(
c,
input: {
userId: string;
patch: {
githubAccountId?: string | null;
githubLogin?: string | null;
roleLabel?: string;
defaultModel?: string;
eligibleOrganizationIdsJson?: string;
starterRepoStatus?: string;
starterRepoStarredAt?: number | null;
starterRepoSkippedAt?: number | null;
};
},
) {
const now = Date.now();
await c.db
.insert(userProfiles)
.values({
id: 1,
userId: input.userId,
githubAccountId: input.patch.githubAccountId ?? null,
githubLogin: input.patch.githubLogin ?? null,
roleLabel: input.patch.roleLabel ?? "GitHub user",
defaultModel: input.patch.defaultModel ?? "claude-sonnet-4",
eligibleOrganizationIdsJson: input.patch.eligibleOrganizationIdsJson ?? "[]",
starterRepoStatus: input.patch.starterRepoStatus ?? "pending",
starterRepoStarredAt: input.patch.starterRepoStarredAt ?? null,
starterRepoSkippedAt: input.patch.starterRepoSkippedAt ?? null,
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: userProfiles.userId,
set: {
...(input.patch.githubAccountId !== undefined ? { githubAccountId: input.patch.githubAccountId } : {}),
...(input.patch.githubLogin !== undefined ? { githubLogin: input.patch.githubLogin } : {}),
...(input.patch.roleLabel !== undefined ? { roleLabel: input.patch.roleLabel } : {}),
...(input.patch.defaultModel !== undefined ? { defaultModel: input.patch.defaultModel } : {}),
...(input.patch.eligibleOrganizationIdsJson !== undefined ? { eligibleOrganizationIdsJson: input.patch.eligibleOrganizationIdsJson } : {}),
...(input.patch.starterRepoStatus !== undefined ? { starterRepoStatus: input.patch.starterRepoStatus } : {}),
...(input.patch.starterRepoStarredAt !== undefined ? { starterRepoStarredAt: input.patch.starterRepoStarredAt } : {}),
...(input.patch.starterRepoSkippedAt !== undefined ? { starterRepoSkippedAt: input.patch.starterRepoSkippedAt } : {}),
updatedAt: now,
},
})
.run();
return await c.db.select().from(userProfiles).where(eq(userProfiles.userId, input.userId)).get();
},
// Custom Foundry action — not part of Better Auth.
async upsertSessionState(c, input: { sessionId: string; activeOrganizationId: string | null }) {
const now = Date.now();
await c.db
.insert(sessionState)
.values({
sessionId: input.sessionId,
activeOrganizationId: input.activeOrganizationId,
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: sessionState.sessionId,
set: {
activeOrganizationId: input.activeOrganizationId,
updatedAt: now,
},
})
.run();
return await c.db.select().from(sessionState).where(eq(sessionState.sessionId, input.sessionId)).get();
},
// Custom Foundry action — not part of Better Auth.
async getTaskState(c, input: { taskId: string }) {
const rows = await c.db.select().from(userTaskState).where(eq(userTaskState.taskId, input.taskId)).all();
const activeSessionId = rows.find((row) => typeof row.activeSessionId === "string" && row.activeSessionId.length > 0)?.activeSessionId ?? null;
return {
taskId: input.taskId,
activeSessionId,
sessions: rows.map((row) => ({
sessionId: row.sessionId,
unread: row.unread === 1,
draftText: row.draftText,
draftAttachmentsJson: row.draftAttachmentsJson,
draftUpdatedAt: row.draftUpdatedAt ?? null,
updatedAt: row.updatedAt,
})),
};
},
// Custom Foundry action — not part of Better Auth.
async upsertTaskState(
c,
input: {
taskId: string;
sessionId: string;
patch: {
activeSessionId?: string | null;
unread?: boolean;
draftText?: string;
draftAttachmentsJson?: string;
draftUpdatedAt?: number | null;
};
},
) {
const now = Date.now();
const existing = await c.db
.select()
.from(userTaskState)
.where(and(eq(userTaskState.taskId, input.taskId), eq(userTaskState.sessionId, input.sessionId)))
.get();
if (input.patch.activeSessionId !== undefined) {
await c.db
.update(userTaskState)
.set({
activeSessionId: input.patch.activeSessionId,
updatedAt: now,
})
.where(eq(userTaskState.taskId, input.taskId))
.run();
}
await c.db
.insert(userTaskState)
.values({
taskId: input.taskId,
sessionId: input.sessionId,
activeSessionId: input.patch.activeSessionId ?? existing?.activeSessionId ?? null,
unread: input.patch.unread !== undefined ? (input.patch.unread ? 1 : 0) : (existing?.unread ?? 0),
draftText: input.patch.draftText ?? existing?.draftText ?? "",
draftAttachmentsJson: input.patch.draftAttachmentsJson ?? existing?.draftAttachmentsJson ?? "[]",
draftUpdatedAt: input.patch.draftUpdatedAt === undefined ? (existing?.draftUpdatedAt ?? null) : input.patch.draftUpdatedAt,
updatedAt: now,
})
.onConflictDoUpdate({
target: [userTaskState.taskId, userTaskState.sessionId],
set: {
...(input.patch.activeSessionId !== undefined ? { activeSessionId: input.patch.activeSessionId } : {}),
...(input.patch.unread !== undefined ? { unread: input.patch.unread ? 1 : 0 } : {}),
...(input.patch.draftText !== undefined ? { draftText: input.patch.draftText } : {}),
...(input.patch.draftAttachmentsJson !== undefined ? { draftAttachmentsJson: input.patch.draftAttachmentsJson } : {}),
...(input.patch.draftUpdatedAt !== undefined ? { draftUpdatedAt: input.patch.draftUpdatedAt } : {}),
updatedAt: now,
},
})
.run();
return await c.db
.select()
.from(userTaskState)
.where(and(eq(userTaskState.taskId, input.taskId), eq(userTaskState.sessionId, input.sessionId)))
.get();
},
// Custom Foundry action — not part of Better Auth.
async deleteTaskState(c, input: { taskId: string; sessionId?: string }) {
if (input.sessionId) {
await c.db
.delete(userTaskState)
.where(and(eq(userTaskState.taskId, input.taskId), eq(userTaskState.sessionId, input.sessionId)))
.run();
return;
}
await c.db.delete(userTaskState).where(eq(userTaskState.taskId, input.taskId)).run();
},
...betterAuthActions,
...userActions,
},
run: workflow(runUserWorkflow),
});

View file

@ -0,0 +1,197 @@
import { and, eq, inArray, isNotNull, isNull, like, lt, lte, gt, gte, ne, notInArray, or } from "drizzle-orm";
import { authAccounts, authSessions, authUsers, sessionState, userProfiles, userTaskState } from "./db/schema.js";
export const userTables = {
user: authUsers,
session: authSessions,
account: authAccounts,
userProfiles,
sessionState,
userTaskState,
} as const;
export function tableFor(model: string) {
const table = userTables[model as keyof typeof userTables];
if (!table) {
throw new Error(`Unsupported user model: ${model}`);
}
return table as any;
}
function dbFieldFor(model: string, field: string): string {
if (model === "user" && field === "id") {
return "authUserId";
}
return field;
}
export function materializeRow(model: string, row: any) {
if (!row || model !== "user") {
return row;
}
const { id: _singletonId, authUserId, ...rest } = row;
return {
id: authUserId,
...rest,
};
}
export function persistInput(model: string, data: Record<string, unknown>) {
if (model !== "user") {
return data;
}
const { id, ...rest } = data;
return {
id: 1,
authUserId: id,
...rest,
};
}
export function persistPatch(model: string, data: Record<string, unknown>) {
if (model !== "user") {
return data;
}
const { id, ...rest } = data;
return {
...(id !== undefined ? { authUserId: id } : {}),
...rest,
};
}
export function columnFor(model: string, table: any, field: string) {
const column = table[dbFieldFor(model, field)];
if (!column) {
throw new Error(`Unsupported user field: ${model}.${field}`);
}
return column;
}
export function normalizeValue(value: unknown): unknown {
if (value instanceof Date) {
return value.getTime();
}
if (Array.isArray(value)) {
return value.map((entry) => normalizeValue(entry));
}
return value;
}
export function clauseToExpr(table: any, clause: any) {
const model = table === authUsers ? "user" : table === authSessions ? "session" : table === authAccounts ? "account" : "";
const column = columnFor(model, table, clause.field);
const value = normalizeValue(clause.value);
switch (clause.operator) {
case "ne":
return value === null ? isNotNull(column) : ne(column, value as any);
case "lt":
return lt(column, value as any);
case "lte":
return lte(column, value as any);
case "gt":
return gt(column, value as any);
case "gte":
return gte(column, value as any);
case "in":
return inArray(column, Array.isArray(value) ? (value as any[]) : [value as any]);
case "not_in":
return notInArray(column, Array.isArray(value) ? (value as any[]) : [value as any]);
case "contains":
return like(column, `%${String(value ?? "")}%`);
case "starts_with":
return like(column, `${String(value ?? "")}%`);
case "ends_with":
return like(column, `%${String(value ?? "")}`);
case "eq":
default:
return value === null ? isNull(column) : eq(column, value as any);
}
}
export function buildWhere(table: any, where: any[] | undefined) {
if (!where || where.length === 0) {
return undefined;
}
let expr = clauseToExpr(table, where[0]);
for (const clause of where.slice(1)) {
const next = clauseToExpr(table, clause);
expr = clause.connector === "OR" ? or(expr, next) : and(expr, next);
}
return expr;
}
export function applyJoinToRow(c: any, model: string, row: any, join: any) {
const materialized = materializeRow(model, row);
if (!materialized || !join) {
return materialized;
}
if (model === "session" && join.user) {
return c.db
.select()
.from(authUsers)
.where(eq(authUsers.authUserId, materialized.userId))
.get()
.then((user: any) => ({ ...materialized, user: materializeRow("user", user) ?? null }));
}
if (model === "account" && join.user) {
return c.db
.select()
.from(authUsers)
.where(eq(authUsers.authUserId, materialized.userId))
.get()
.then((user: any) => ({ ...materialized, user: materializeRow("user", user) ?? null }));
}
if (model === "user" && join.account) {
return c.db
.select()
.from(authAccounts)
.where(eq(authAccounts.userId, materialized.id))
.all()
.then((accounts: any[]) => ({ ...materialized, account: accounts }));
}
return Promise.resolve(materialized);
}
export async function applyJoinToRows(c: any, model: string, rows: any[], join: any) {
if (!join || rows.length === 0) {
return rows.map((row) => materializeRow(model, row));
}
if (model === "session" && join.user) {
const userIds = [...new Set(rows.map((row) => row.userId).filter(Boolean))];
const users = userIds.length > 0 ? await c.db.select().from(authUsers).where(inArray(authUsers.authUserId, userIds)).all() : [];
const userMap = new Map(users.map((user: any) => [user.authUserId, materializeRow("user", user)]));
return rows.map((row) => ({ ...row, user: userMap.get(row.userId) ?? null }));
}
if (model === "account" && join.user) {
const userIds = [...new Set(rows.map((row) => row.userId).filter(Boolean))];
const users = userIds.length > 0 ? await c.db.select().from(authUsers).where(inArray(authUsers.authUserId, userIds)).all() : [];
const userMap = new Map(users.map((user: any) => [user.authUserId, materializeRow("user", user)]));
return rows.map((row) => ({ ...row, user: userMap.get(row.userId) ?? null }));
}
if (model === "user" && join.account) {
const materializedRows = rows.map((row) => materializeRow("user", row));
const userIds = materializedRows.map((row) => row.id);
const accounts = userIds.length > 0 ? await c.db.select().from(authAccounts).where(inArray(authAccounts.userId, userIds)).all() : [];
const accountsByUserId = new Map<string, any[]>();
for (const account of accounts) {
const entries = accountsByUserId.get(account.userId) ?? [];
entries.push(account);
accountsByUserId.set(account.userId, entries);
}
return materializedRows.map((row) => ({ ...row, account: accountsByUserId.get(row.id) ?? [] }));
}
return rows.map((row) => materializeRow(model, row));
}

View file

@ -0,0 +1,281 @@
import { eq, count as sqlCount, and } from "drizzle-orm";
import { Loop } from "rivetkit/workflow";
import { DEFAULT_WORKSPACE_MODEL_ID } from "@sandbox-agent/foundry-shared";
import { logActorWarning, resolveErrorMessage } from "../logging.js";
import { authUsers, sessionState, userProfiles, userTaskState } from "./db/schema.js";
import { buildWhere, columnFor, materializeRow, persistInput, persistPatch, tableFor } from "./query-helpers.js";
export const USER_QUEUE_NAMES = [
"user.command.auth.create",
"user.command.auth.update",
"user.command.auth.update_many",
"user.command.auth.delete",
"user.command.auth.delete_many",
"user.command.profile.upsert",
"user.command.session_state.upsert",
"user.command.task_state.upsert",
"user.command.task_state.delete",
] as const;
export type UserQueueName = (typeof USER_QUEUE_NAMES)[number];
export function userWorkflowQueueName(name: UserQueueName): UserQueueName {
return name;
}
async function createAuthRecordMutation(c: any, input: { model: string; data: Record<string, unknown> }) {
const table = tableFor(input.model);
const persisted = persistInput(input.model, input.data);
await c.db.insert(table).values(persisted as any).run();
const row = await c.db.select().from(table).where(eq(columnFor(input.model, table, "id"), input.data.id as any)).get();
return materializeRow(input.model, row);
}
async function updateAuthRecordMutation(c: any, input: { model: string; where: any[]; update: Record<string, unknown> }) {
const table = tableFor(input.model);
const predicate = buildWhere(table, input.where);
if (!predicate) {
throw new Error("updateAuthRecord requires a where clause");
}
await c.db.update(table).set(persistPatch(input.model, input.update) as any).where(predicate).run();
return materializeRow(input.model, await c.db.select().from(table).where(predicate).get());
}
async function updateManyAuthRecordsMutation(c: any, input: { model: string; where: any[]; update: Record<string, unknown> }) {
const table = tableFor(input.model);
const predicate = buildWhere(table, input.where);
if (!predicate) {
throw new Error("updateManyAuthRecords requires a where clause");
}
await c.db.update(table).set(persistPatch(input.model, input.update) as any).where(predicate).run();
const row = await c.db.select({ value: sqlCount() }).from(table).where(predicate).get();
return row?.value ?? 0;
}
async function deleteAuthRecordMutation(c: any, input: { model: string; where: any[] }) {
const table = tableFor(input.model);
const predicate = buildWhere(table, input.where);
if (!predicate) {
throw new Error("deleteAuthRecord requires a where clause");
}
await c.db.delete(table).where(predicate).run();
}
async function deleteManyAuthRecordsMutation(c: any, input: { model: string; where: any[] }) {
const table = tableFor(input.model);
const predicate = buildWhere(table, input.where);
if (!predicate) {
throw new Error("deleteManyAuthRecords requires a where clause");
}
const rows = await c.db.select().from(table).where(predicate).all();
await c.db.delete(table).where(predicate).run();
return rows.length;
}
async function upsertUserProfileMutation(
c: any,
input: {
userId: string;
patch: {
githubAccountId?: string | null;
githubLogin?: string | null;
roleLabel?: string;
defaultModel?: string;
eligibleOrganizationIdsJson?: string;
starterRepoStatus?: string;
starterRepoStarredAt?: number | null;
starterRepoSkippedAt?: number | null;
};
},
) {
const now = Date.now();
await c.db
.insert(userProfiles)
.values({
id: 1,
userId: input.userId,
githubAccountId: input.patch.githubAccountId ?? null,
githubLogin: input.patch.githubLogin ?? null,
roleLabel: input.patch.roleLabel ?? "GitHub user",
defaultModel: input.patch.defaultModel ?? DEFAULT_WORKSPACE_MODEL_ID,
eligibleOrganizationIdsJson: input.patch.eligibleOrganizationIdsJson ?? "[]",
starterRepoStatus: input.patch.starterRepoStatus ?? "pending",
starterRepoStarredAt: input.patch.starterRepoStarredAt ?? null,
starterRepoSkippedAt: input.patch.starterRepoSkippedAt ?? null,
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: userProfiles.userId,
set: {
...(input.patch.githubAccountId !== undefined ? { githubAccountId: input.patch.githubAccountId } : {}),
...(input.patch.githubLogin !== undefined ? { githubLogin: input.patch.githubLogin } : {}),
...(input.patch.roleLabel !== undefined ? { roleLabel: input.patch.roleLabel } : {}),
...(input.patch.defaultModel !== undefined ? { defaultModel: input.patch.defaultModel } : {}),
...(input.patch.eligibleOrganizationIdsJson !== undefined ? { eligibleOrganizationIdsJson: input.patch.eligibleOrganizationIdsJson } : {}),
...(input.patch.starterRepoStatus !== undefined ? { starterRepoStatus: input.patch.starterRepoStatus } : {}),
...(input.patch.starterRepoStarredAt !== undefined ? { starterRepoStarredAt: input.patch.starterRepoStarredAt } : {}),
...(input.patch.starterRepoSkippedAt !== undefined ? { starterRepoSkippedAt: input.patch.starterRepoSkippedAt } : {}),
updatedAt: now,
},
})
.run();
return await c.db.select().from(userProfiles).where(eq(userProfiles.userId, input.userId)).get();
}
async function upsertSessionStateMutation(c: any, input: { sessionId: string; activeOrganizationId: string | null }) {
const now = Date.now();
await c.db
.insert(sessionState)
.values({
sessionId: input.sessionId,
activeOrganizationId: input.activeOrganizationId,
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: sessionState.sessionId,
set: {
activeOrganizationId: input.activeOrganizationId,
updatedAt: now,
},
})
.run();
return await c.db.select().from(sessionState).where(eq(sessionState.sessionId, input.sessionId)).get();
}
async function upsertTaskStateMutation(
c: any,
input: {
taskId: string;
sessionId: string;
patch: {
activeSessionId?: string | null;
unread?: boolean;
draftText?: string;
draftAttachmentsJson?: string;
draftUpdatedAt?: number | null;
};
},
) {
const now = Date.now();
const existing = await c.db
.select()
.from(userTaskState)
.where(and(eq(userTaskState.taskId, input.taskId), eq(userTaskState.sessionId, input.sessionId)))
.get();
if (input.patch.activeSessionId !== undefined) {
await c.db
.update(userTaskState)
.set({
activeSessionId: input.patch.activeSessionId,
updatedAt: now,
})
.where(eq(userTaskState.taskId, input.taskId))
.run();
}
await c.db
.insert(userTaskState)
.values({
taskId: input.taskId,
sessionId: input.sessionId,
activeSessionId: input.patch.activeSessionId ?? existing?.activeSessionId ?? null,
unread: input.patch.unread !== undefined ? (input.patch.unread ? 1 : 0) : (existing?.unread ?? 0),
draftText: input.patch.draftText ?? existing?.draftText ?? "",
draftAttachmentsJson: input.patch.draftAttachmentsJson ?? existing?.draftAttachmentsJson ?? "[]",
draftUpdatedAt: input.patch.draftUpdatedAt === undefined ? (existing?.draftUpdatedAt ?? null) : input.patch.draftUpdatedAt,
updatedAt: now,
})
.onConflictDoUpdate({
target: [userTaskState.taskId, userTaskState.sessionId],
set: {
...(input.patch.activeSessionId !== undefined ? { activeSessionId: input.patch.activeSessionId } : {}),
...(input.patch.unread !== undefined ? { unread: input.patch.unread ? 1 : 0 } : {}),
...(input.patch.draftText !== undefined ? { draftText: input.patch.draftText } : {}),
...(input.patch.draftAttachmentsJson !== undefined ? { draftAttachmentsJson: input.patch.draftAttachmentsJson } : {}),
...(input.patch.draftUpdatedAt !== undefined ? { draftUpdatedAt: input.patch.draftUpdatedAt } : {}),
updatedAt: now,
},
})
.run();
return await c.db
.select()
.from(userTaskState)
.where(and(eq(userTaskState.taskId, input.taskId), eq(userTaskState.sessionId, input.sessionId)))
.get();
}
async function deleteTaskStateMutation(c: any, input: { taskId: string; sessionId?: string }) {
if (input.sessionId) {
await c.db
.delete(userTaskState)
.where(and(eq(userTaskState.taskId, input.taskId), eq(userTaskState.sessionId, input.sessionId)))
.run();
return;
}
await c.db.delete(userTaskState).where(eq(userTaskState.taskId, input.taskId)).run();
}
export async function runUserWorkflow(ctx: any): Promise<void> {
await ctx.loop("user-command-loop", async (loopCtx: any) => {
const msg = await loopCtx.queue.next("next-user-command", {
names: [...USER_QUEUE_NAMES],
completable: true,
});
if (!msg) {
return Loop.continue(undefined);
}
try {
let result: unknown;
switch (msg.name) {
case "user.command.auth.create":
result = await loopCtx.step({ name: "user-auth-create", timeout: 60_000, run: async () => createAuthRecordMutation(loopCtx, msg.body) });
break;
case "user.command.auth.update":
result = await loopCtx.step({ name: "user-auth-update", timeout: 60_000, run: async () => updateAuthRecordMutation(loopCtx, msg.body) });
break;
case "user.command.auth.update_many":
result = await loopCtx.step({ name: "user-auth-update-many", timeout: 60_000, run: async () => updateManyAuthRecordsMutation(loopCtx, msg.body) });
break;
case "user.command.auth.delete":
result = await loopCtx.step({ name: "user-auth-delete", timeout: 60_000, run: async () => deleteAuthRecordMutation(loopCtx, msg.body) });
break;
case "user.command.auth.delete_many":
result = await loopCtx.step({ name: "user-auth-delete-many", timeout: 60_000, run: async () => deleteManyAuthRecordsMutation(loopCtx, msg.body) });
break;
case "user.command.profile.upsert":
result = await loopCtx.step({ name: "user-profile-upsert", timeout: 60_000, run: async () => upsertUserProfileMutation(loopCtx, msg.body) });
break;
case "user.command.session_state.upsert":
result = await loopCtx.step({ name: "user-session-state-upsert", timeout: 60_000, run: async () => upsertSessionStateMutation(loopCtx, msg.body) });
break;
case "user.command.task_state.upsert":
result = await loopCtx.step({ name: "user-task-state-upsert", timeout: 60_000, run: async () => upsertTaskStateMutation(loopCtx, msg.body) });
break;
case "user.command.task_state.delete":
result = await loopCtx.step({ name: "user-task-state-delete", timeout: 60_000, run: async () => deleteTaskStateMutation(loopCtx, msg.body) });
break;
default:
return Loop.continue(undefined);
}
await msg.complete(result);
} catch (error) {
const message = resolveErrorMessage(error);
logActorWarning("user", "user workflow command failed", {
queueName: msg.name,
error: message,
});
await msg.complete({ error: message }).catch(() => {});
}
return Loop.continue(undefined);
});
}

View file

@ -10,7 +10,7 @@ import { createDefaultDriver } from "./driver.js";
import { createClient } from "rivetkit/client";
import { initBetterAuthService } from "./services/better-auth.js";
import { createDefaultAppShellServices } from "./services/app-shell-runtime.js";
import { APP_SHELL_ORGANIZATION_ID } from "./actors/organization/app-shell.js";
import { APP_SHELL_ORGANIZATION_ID } from "./actors/organization/constants.js";
import { logger } from "./logging.js";
export interface BackendStartOptions {

View file

@ -1,8 +1,11 @@
import { betterAuth } from "better-auth";
import { createAdapterFactory } from "better-auth/adapters";
import { APP_SHELL_ORGANIZATION_ID } from "../actors/organization/app-shell.js";
import { APP_SHELL_ORGANIZATION_ID } from "../actors/organization/constants.js";
import { organizationWorkflowQueueName } from "../actors/organization/queues.js";
import { userWorkflowQueueName } from "../actors/user/workflow.js";
import { organizationKey, userKey } from "../actors/keys.js";
import { logger } from "../logging.js";
import { expectQueueResponse } from "./queue.js";
const AUTH_BASE_PATH = "/v1/auth";
const SESSION_COOKIE = "better-auth.session_token";
@ -59,6 +62,12 @@ function resolveRouteUserId(organization: any, resolved: any): string | null {
return null;
}
async function sendOrganizationCommand<TResponse>(organization: any, name: Parameters<typeof organizationWorkflowQueueName>[0], body: unknown): Promise<TResponse> {
return expectQueueResponse<TResponse>(
await organization.send(organizationWorkflowQueueName(name), body, { wait: true, timeout: 60_000 }),
);
}
export interface BetterAuthService {
auth: any;
resolveSession(headers: Headers): Promise<{ session: any; user: any } | null>;
@ -110,7 +119,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
const email = direct("email");
if (typeof email === "string" && email.length > 0) {
const organization = await appOrganization();
const resolved = await organization.authFindEmailIndex({ email: email.toLowerCase() });
const resolved = await organization.betterAuthFindEmailIndex({ email: email.toLowerCase() });
return resolveRouteUserId(organization, resolved);
}
return null;
@ -125,7 +134,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
const sessionToken = direct("token") ?? data?.token;
if (typeof sessionId === "string" || typeof sessionToken === "string") {
const organization = await appOrganization();
const resolved = await organization.authFindSessionIndex({
const resolved = await organization.betterAuthFindSessionIndex({
...(typeof sessionId === "string" ? { sessionId } : {}),
...(typeof sessionToken === "string" ? { sessionToken } : {}),
});
@ -144,11 +153,11 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
const accountId = direct("accountId") ?? data?.accountId;
const organization = await appOrganization();
if (typeof accountRecordId === "string" && accountRecordId.length > 0) {
const resolved = await organization.authFindAccountIndex({ id: accountRecordId });
const resolved = await organization.betterAuthFindAccountIndex({ id: accountRecordId });
return resolveRouteUserId(organization, resolved);
}
if (typeof providerId === "string" && providerId.length > 0 && typeof accountId === "string" && accountId.length > 0) {
const resolved = await organization.authFindAccountIndex({ providerId, accountId });
const resolved = await organization.betterAuthFindAccountIndex({ providerId, accountId });
return resolveRouteUserId(organization, resolved);
}
return null;
@ -157,9 +166,9 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
return null;
};
const ensureOrganizationVerification = async (method: string, payload: Record<string, unknown>) => {
const ensureOrganizationVerification = async <TResponse>(method: Parameters<typeof organizationWorkflowQueueName>[0], payload: Record<string, unknown>) => {
const organization = await appOrganization();
return await organization[method](payload);
return await sendOrganizationCommand<TResponse>(organization, method, payload);
};
return {
@ -170,7 +179,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
create: async ({ model, data }) => {
const transformed = await transformInput(data, model, "create", true);
if (model === "verification") {
return await ensureOrganizationVerification("authCreateVerification", { data: transformed });
return await ensureOrganizationVerification<any>("organization.command.better_auth.verification.create", { data: transformed });
}
const userId = await resolveUserIdForQuery(model, undefined, transformed);
@ -179,18 +188,20 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
}
const userActor = await getUser(userId);
const created = await userActor.createAuthRecord({ model, data: transformed });
const created = expectQueueResponse<any>(
await userActor.send(userWorkflowQueueName("user.command.auth.create"), { model, data: transformed }, { wait: true, timeout: 60_000 }),
);
const organization = await appOrganization();
if (model === "user" && typeof transformed.email === "string" && transformed.email.length > 0) {
await organization.authUpsertEmailIndex({
await sendOrganizationCommand(organization, "organization.command.better_auth.email_index.upsert", {
email: transformed.email.toLowerCase(),
userId,
});
}
if (model === "session") {
await organization.authUpsertSessionIndex({
await sendOrganizationCommand(organization, "organization.command.better_auth.session_index.upsert", {
sessionId: String(created.id),
sessionToken: String(created.token),
userId,
@ -198,7 +209,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
}
if (model === "account") {
await organization.authUpsertAccountIndex({
await sendOrganizationCommand(organization, "organization.command.better_auth.account_index.upsert", {
id: String(created.id),
providerId: String(created.providerId),
accountId: String(created.accountId),
@ -212,7 +223,8 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
findOne: async ({ model, where, join }) => {
const transformedWhere = transformWhereClause({ model, where, action: "findOne" });
if (model === "verification") {
return await ensureOrganizationVerification("authFindOneVerification", { where: transformedWhere, join });
const organization = await appOrganization();
return await organization.betterAuthFindOneVerification({ where: transformedWhere, join });
}
const userId = await resolveUserIdForQuery(model, transformedWhere);
@ -221,14 +233,15 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
}
const userActor = await getUser(userId);
const found = await userActor.findOneAuthRecord({ model, where: transformedWhere, join });
const found = await userActor.betterAuthFindOneRecord({ model, where: transformedWhere, join });
return found ? ((await transformOutput(found, model, undefined, join)) as any) : null;
},
findMany: async ({ model, where, limit, sortBy, offset, join }) => {
const transformedWhere = transformWhereClause({ model, where, action: "findMany" });
if (model === "verification") {
return await ensureOrganizationVerification("authFindManyVerification", {
const organization = await appOrganization();
return await organization.betterAuthFindManyVerification({
where: transformedWhere,
limit,
sortBy,
@ -244,7 +257,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
const resolved = await Promise.all(
(tokenClause.value as string[]).map(async (sessionToken: string) => ({
sessionToken,
route: await organization.authFindSessionIndex({ sessionToken }),
route: await organization.betterAuthFindSessionIndex({ sessionToken }),
})),
);
const byUser = new Map<string, string[]>();
@ -263,7 +276,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
const scopedWhere = transformedWhere.map((entry: any) =>
entry.field === "token" && entry.operator === "in" ? { ...entry, value: tokens } : entry,
);
const found = await userActor.findManyAuthRecords({ model, where: scopedWhere, limit, sortBy, offset, join });
const found = await userActor.betterAuthFindManyRecords({ model, where: scopedWhere, limit, sortBy, offset, join });
rows.push(...found);
}
return await Promise.all(rows.map(async (row: any) => await transformOutput(row, model, undefined, join)));
@ -276,7 +289,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
}
const userActor = await getUser(userId);
const found = await userActor.findManyAuthRecords({ model, where: transformedWhere, limit, sortBy, offset, join });
const found = await userActor.betterAuthFindManyRecords({ model, where: transformedWhere, limit, sortBy, offset, join });
return await Promise.all(found.map(async (row: any) => await transformOutput(row, model, undefined, join)));
},
@ -284,7 +297,10 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
const transformedWhere = transformWhereClause({ model, where, action: "update" });
const transformedUpdate = (await transformInput(update as Record<string, unknown>, model, "update", true)) as Record<string, unknown>;
if (model === "verification") {
return await ensureOrganizationVerification("authUpdateVerification", { where: transformedWhere, update: transformedUpdate });
return await ensureOrganizationVerification<any>("organization.command.better_auth.verification.update", {
where: transformedWhere,
update: transformedUpdate,
});
}
const userId = await resolveUserIdForQuery(model, transformedWhere, transformedUpdate);
@ -295,26 +311,37 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
const userActor = await getUser(userId);
const before =
model === "user"
? await userActor.findOneAuthRecord({ model, where: transformedWhere })
? await userActor.betterAuthFindOneRecord({ model, where: transformedWhere })
: model === "account"
? await userActor.findOneAuthRecord({ model, where: transformedWhere })
? await userActor.betterAuthFindOneRecord({ model, where: transformedWhere })
: model === "session"
? await userActor.findOneAuthRecord({ model, where: transformedWhere })
? await userActor.betterAuthFindOneRecord({ model, where: transformedWhere })
: null;
const updated = await userActor.updateAuthRecord({ model, where: transformedWhere, update: transformedUpdate });
const updated = expectQueueResponse<any>(
await userActor.send(
userWorkflowQueueName("user.command.auth.update"),
{ model, where: transformedWhere, update: transformedUpdate },
{ wait: true, timeout: 60_000 },
),
);
const organization = await appOrganization();
if (model === "user" && updated) {
if (before?.email && before.email !== updated.email) {
await organization.authDeleteEmailIndex({ email: before.email.toLowerCase() });
await sendOrganizationCommand(organization, "organization.command.better_auth.email_index.delete", {
email: before.email.toLowerCase(),
});
}
if (updated.email) {
await organization.authUpsertEmailIndex({ email: updated.email.toLowerCase(), userId });
await sendOrganizationCommand(organization, "organization.command.better_auth.email_index.upsert", {
email: updated.email.toLowerCase(),
userId,
});
}
}
if (model === "session" && updated) {
await organization.authUpsertSessionIndex({
await sendOrganizationCommand(organization, "organization.command.better_auth.session_index.upsert", {
sessionId: String(updated.id),
sessionToken: String(updated.token),
userId,
@ -322,7 +349,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
}
if (model === "account" && updated) {
await organization.authUpsertAccountIndex({
await sendOrganizationCommand(organization, "organization.command.better_auth.account_index.upsert", {
id: String(updated.id),
providerId: String(updated.providerId),
accountId: String(updated.accountId),
@ -337,7 +364,10 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
const transformedWhere = transformWhereClause({ model, where, action: "updateMany" });
const transformedUpdate = (await transformInput(update as Record<string, unknown>, model, "update", true)) as Record<string, unknown>;
if (model === "verification") {
return await ensureOrganizationVerification("authUpdateManyVerification", { where: transformedWhere, update: transformedUpdate });
return await ensureOrganizationVerification<number>("organization.command.better_auth.verification.update_many", {
where: transformedWhere,
update: transformedUpdate,
});
}
const userId = await resolveUserIdForQuery(model, transformedWhere, transformedUpdate);
@ -346,13 +376,20 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
}
const userActor = await getUser(userId);
return await userActor.updateManyAuthRecords({ model, where: transformedWhere, update: transformedUpdate });
return expectQueueResponse<number>(
await userActor.send(
userWorkflowQueueName("user.command.auth.update_many"),
{ model, where: transformedWhere, update: transformedUpdate },
{ wait: true, timeout: 60_000 },
),
);
},
delete: async ({ model, where }) => {
const transformedWhere = transformWhereClause({ model, where, action: "delete" });
if (model === "verification") {
await ensureOrganizationVerification("authDeleteVerification", { where: transformedWhere });
const organization = await appOrganization();
await sendOrganizationCommand(organization, "organization.command.better_auth.verification.delete", { where: transformedWhere });
return;
}
@ -363,18 +400,20 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
const userActor = await getUser(userId);
const organization = await appOrganization();
const before = await userActor.findOneAuthRecord({ model, where: transformedWhere });
await userActor.deleteAuthRecord({ model, where: transformedWhere });
const before = await userActor.betterAuthFindOneRecord({ model, where: transformedWhere });
expectQueueResponse<void>(
await userActor.send(userWorkflowQueueName("user.command.auth.delete"), { model, where: transformedWhere }, { wait: true, timeout: 60_000 }),
);
if (model === "session" && before) {
await organization.authDeleteSessionIndex({
await sendOrganizationCommand(organization, "organization.command.better_auth.session_index.delete", {
sessionId: before.id,
sessionToken: before.token,
});
}
if (model === "account" && before) {
await organization.authDeleteAccountIndex({
await sendOrganizationCommand(organization, "organization.command.better_auth.account_index.delete", {
id: before.id,
providerId: before.providerId,
accountId: before.accountId,
@ -382,14 +421,16 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
}
if (model === "user" && before?.email) {
await organization.authDeleteEmailIndex({ email: before.email.toLowerCase() });
await sendOrganizationCommand(organization, "organization.command.better_auth.email_index.delete", {
email: before.email.toLowerCase(),
});
}
},
deleteMany: async ({ model, where }) => {
const transformedWhere = transformWhereClause({ model, where, action: "deleteMany" });
if (model === "verification") {
return await ensureOrganizationVerification("authDeleteManyVerification", { where: transformedWhere });
return await ensureOrganizationVerification<number>("organization.command.better_auth.verification.delete_many", { where: transformedWhere });
}
if (model === "session") {
@ -399,10 +440,12 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
}
const userActor = await getUser(userId);
const organization = await appOrganization();
const sessions = await userActor.findManyAuthRecords({ model, where: transformedWhere, limit: 5000 });
const deleted = await userActor.deleteManyAuthRecords({ model, where: transformedWhere });
const sessions = await userActor.betterAuthFindManyRecords({ model, where: transformedWhere, limit: 5000 });
const deleted = expectQueueResponse<number>(
await userActor.send(userWorkflowQueueName("user.command.auth.delete_many"), { model, where: transformedWhere }, { wait: true, timeout: 60_000 }),
);
for (const session of sessions) {
await organization.authDeleteSessionIndex({
await sendOrganizationCommand(organization, "organization.command.better_auth.session_index.delete", {
sessionId: session.id,
sessionToken: session.token,
});
@ -416,14 +459,17 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
}
const userActor = await getUser(userId);
const deleted = await userActor.deleteManyAuthRecords({ model, where: transformedWhere });
const deleted = expectQueueResponse<number>(
await userActor.send(userWorkflowQueueName("user.command.auth.delete_many"), { model, where: transformedWhere }, { wait: true, timeout: 60_000 }),
);
return deleted;
},
count: async ({ model, where }) => {
const transformedWhere = transformWhereClause({ model, where, action: "count" });
if (model === "verification") {
return await ensureOrganizationVerification("authCountVerification", { where: transformedWhere });
const organization = await appOrganization();
return await organization.betterAuthCountVerification({ where: transformedWhere });
}
const userId = await resolveUserIdForQuery(model, transformedWhere);
@ -432,7 +478,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
}
const userActor = await getUser(userId);
return await userActor.countAuthRecords({ model, where: transformedWhere });
return await userActor.betterAuthCountRecords({ model, where: transformedWhere });
},
};
},
@ -477,7 +523,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
async getAuthState(sessionId: string) {
const organization = await appOrganization();
const route = await organization.authFindSessionIndex({ sessionId });
const route = await organization.betterAuthFindSessionIndex({ sessionId });
if (!route?.userId) {
return null;
}
@ -487,7 +533,9 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
async upsertUserProfile(userId: string, patch: Record<string, unknown>) {
const userActor = await getUser(userId);
return await userActor.upsertUserProfile({ userId, patch });
return expectQueueResponse(
await userActor.send(userWorkflowQueueName("user.command.profile.upsert"), { userId, patch }, { wait: true, timeout: 60_000 }),
);
},
async setActiveOrganization(sessionId: string, activeOrganizationId: string | null) {
@ -496,7 +544,13 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
throw new Error(`Unknown auth session ${sessionId}`);
}
const userActor = await getUser(authState.user.id);
return await userActor.upsertSessionState({ sessionId, activeOrganizationId });
return expectQueueResponse(
await userActor.send(
userWorkflowQueueName("user.command.session_state.upsert"),
{ sessionId, activeOrganizationId },
{ wait: true, timeout: 60_000 },
),
);
},
async getAccessTokenForSession(sessionId: string) {

View file

@ -1,5 +1,5 @@
import { getOrCreateOrganization } from "../actors/handles.js";
import { APP_SHELL_ORGANIZATION_ID } from "../actors/organization/app-shell.js";
import { APP_SHELL_ORGANIZATION_ID } from "../actors/organization/constants.js";
export interface ResolvedGithubAuth {
githubToken: string;

View file

@ -8,6 +8,7 @@ import { describe, expect, it } from "vitest";
import { setupTest } from "rivetkit/test";
import { organizationKey } from "../src/actors/keys.js";
import { registry } from "../src/actors/index.js";
import { organizationWorkflowQueueName } from "../src/actors/organization/queues.js";
import { repoIdFromRemote } from "../src/services/repo.js";
import { createTestDriver } from "./helpers/test-driver.js";
import { createTestRuntimeContext } from "./helpers/test-context.js";
@ -51,8 +52,8 @@ describe("organization isolation", () => {
const { repoPath } = createRepo();
const repoId = repoIdFromRemote(repoPath);
await wsA.applyGithubRepositoryProjection({ repoId, remoteUrl: repoPath });
await wsB.applyGithubRepositoryProjection({ repoId, remoteUrl: repoPath });
await wsA.send(organizationWorkflowQueueName("organization.command.github.repository_projection.apply"), { repoId, remoteUrl: repoPath }, { wait: true });
await wsB.send(organizationWorkflowQueueName("organization.command.github.repository_projection.apply"), { repoId, remoteUrl: repoPath }, { wait: true });
await wsA.createTask({
organizationId: "alpha",

View file

@ -1,4 +1,4 @@
import type { AppConfig, TaskRecord } from "@sandbox-agent/foundry-shared";
import type { AppConfig, TaskRecord, WorkspaceTaskDetail } from "@sandbox-agent/foundry-shared";
import { spawnSync } from "node:child_process";
import { createBackendClientFromConfig, filterTasks, formatRelativeAge, groupTaskStatus } from "@sandbox-agent/foundry-client";
import { CLI_BUILD_ID } from "./build-id.js";
@ -51,14 +51,28 @@ interface DisplayRow {
age: string;
}
type TuiTaskRow = TaskRecord & Pick<WorkspaceTaskDetail, "pullRequest"> & { activeSessionId?: string | null };
interface RenderOptions {
width?: number;
height?: number;
}
async function listDetailedTasks(client: ReturnType<typeof createBackendClientFromConfig>, organizationId: string): Promise<TaskRecord[]> {
async function listDetailedTasks(client: ReturnType<typeof createBackendClientFromConfig>, organizationId: string): Promise<TuiTaskRow[]> {
const rows = await client.listTasks(organizationId);
return await Promise.all(rows.map(async (row) => await client.getTask(organizationId, row.taskId)));
return await Promise.all(
rows.map(async (row) => {
const [task, detail] = await Promise.all([
client.getTask(organizationId, row.repoId, row.taskId),
client.getTaskDetail(organizationId, row.repoId, row.taskId).catch(() => null),
]);
return {
...task,
pullRequest: detail?.pullRequest ?? null,
activeSessionId: detail?.activeSessionId ?? null,
};
}),
);
}
function pad(input: string, width: number): string {
@ -143,29 +157,17 @@ function agentSymbol(status: TaskRecord["status"]): string {
return "-";
}
function toDisplayRow(row: TaskRecord): DisplayRow {
const conflictPrefix = row.conflictsWithMain === "true" ? "\u26A0 " : "";
const prLabel = row.prUrl ? `#${row.prUrl.match(/\/pull\/(\d+)/)?.[1] ?? "?"}` : row.prSubmitted ? "sub" : "-";
const ciLabel = row.ciStatus ?? "-";
const reviewLabel = row.reviewStatus
? row.reviewStatus === "approved"
? "ok"
: row.reviewStatus === "changes_requested"
? "chg"
: row.reviewStatus === "pending"
? "..."
: row.reviewStatus
: "-";
function toDisplayRow(row: TuiTaskRow): DisplayRow {
const prLabel = row.pullRequest ? `#${row.pullRequest.number}` : "-";
const reviewLabel = row.pullRequest ? (row.pullRequest.isDraft ? "draft" : row.pullRequest.state.toLowerCase()) : "-";
return {
name: `${conflictPrefix}${row.title || row.branchName}`,
diff: row.diffStat ?? "-",
name: row.title || row.branchName || row.taskId,
diff: "-",
agent: agentSymbol(row.status),
pr: prLabel,
author: row.prAuthor ?? "-",
ci: ciLabel,
author: row.pullRequest?.authorLogin ?? "-",
ci: "-",
review: reviewLabel,
age: formatRelativeAge(row.updatedAt),
};
@ -186,7 +188,7 @@ function helpLines(width: number): string[] {
}
export function formatRows(
rows: TaskRecord[],
rows: TuiTaskRow[],
selected: number,
organizationId: string,
status: string,
@ -336,8 +338,8 @@ export async function runTui(config: AppConfig, organizationId: string): Promise
renderer.root.add(text);
renderer.start();
let allRows: TaskRecord[] = [];
let filteredRows: TaskRecord[] = [];
let allRows: TuiTaskRow[] = [];
let filteredRows: TuiTaskRow[] = [];
let selected = 0;
let searchQuery = "";
let showHelp = false;
@ -393,7 +395,7 @@ export async function runTui(config: AppConfig, organizationId: string): Promise
render();
};
const selectedRow = (): TaskRecord | null => {
const selectedRow = (): TuiTaskRow | null => {
if (filteredRows.length === 0) {
return null;
}
@ -522,7 +524,7 @@ export async function runTui(config: AppConfig, organizationId: string): Promise
render();
void (async () => {
try {
const result = await client.switchTask(organizationId, row.taskId);
const result = await client.switchTask(organizationId, row.repoId, row.taskId);
close(`cd ${result.switchTarget}`);
} catch (err) {
busy = false;
@ -543,7 +545,7 @@ export async function runTui(config: AppConfig, organizationId: string): Promise
render();
void (async () => {
try {
const result = await client.attachTask(organizationId, row.taskId);
const result = await client.attachTask(organizationId, row.repoId, row.taskId);
close(`target=${result.target} session=${result.sessionId ?? "none"}`);
} catch (err) {
busy = false;
@ -559,7 +561,11 @@ export async function runTui(config: AppConfig, organizationId: string): Promise
if (!row) {
return;
}
void runActionWithRefresh(`archiving ${row.taskId}`, async () => client.runAction(organizationId, row.taskId, "archive"), `archived ${row.taskId}`);
void runActionWithRefresh(
`archiving ${row.taskId}`,
async () => client.runAction(organizationId, row.repoId, row.taskId, "archive"),
`archived ${row.taskId}`,
);
return;
}
@ -568,7 +574,11 @@ export async function runTui(config: AppConfig, organizationId: string): Promise
if (!row) {
return;
}
void runActionWithRefresh(`syncing ${row.taskId}`, async () => client.runAction(organizationId, row.taskId, "sync"), `synced ${row.taskId}`);
void runActionWithRefresh(
`syncing ${row.taskId}`,
async () => client.runAction(organizationId, row.repoId, row.taskId, "sync"),
`synced ${row.taskId}`,
);
return;
}
@ -580,8 +590,8 @@ export async function runTui(config: AppConfig, organizationId: string): Promise
void runActionWithRefresh(
`merging ${row.taskId}`,
async () => {
await client.runAction(organizationId, row.taskId, "merge");
await client.runAction(organizationId, row.taskId, "archive");
await client.runAction(organizationId, row.repoId, row.taskId, "merge");
await client.runAction(organizationId, row.repoId, row.taskId, "archive");
},
`merged+archived ${row.taskId}`,
);
@ -590,14 +600,15 @@ export async function runTui(config: AppConfig, organizationId: string): Promise
if (ctrl && name === "o") {
const row = selectedRow();
if (!row?.prUrl) {
const prUrl = row?.pullRequest?.url ?? null;
if (!prUrl) {
status = "no PR URL available for this task";
render();
return;
}
const openCmd = process.platform === "darwin" ? "open" : "xdg-open";
spawnSync(openCmd, [row.prUrl], { stdio: "ignore" });
status = `opened ${row.prUrl}`;
spawnSync(openCmd, [prUrl], { stdio: "ignore" });
status = `opened ${prUrl}`;
render();
return;
}

View file

@ -3,7 +3,7 @@ import type { TaskRecord } from "@sandbox-agent/foundry-shared";
import { filterTasks, fuzzyMatch } from "@sandbox-agent/foundry-client";
import { formatRows } from "../src/tui.js";
const sample: TaskRecord = {
const sample = {
organizationId: "default",
repoId: "repo-a",
repoRemote: "https://example.com/repo-a.git",
@ -13,33 +13,22 @@ const sample: TaskRecord = {
task: "Do test",
sandboxProviderId: "local",
status: "running",
statusMessage: null,
activeSandboxId: "sandbox-1",
activeSessionId: "session-1",
pullRequest: null,
sandboxes: [
{
sandboxId: "sandbox-1",
sandboxProviderId: "local",
sandboxActorId: null,
switchTarget: "sandbox://local/sandbox-1",
cwd: null,
createdAt: 1,
updatedAt: 1,
},
],
agentType: null,
prSubmitted: false,
diffStat: null,
prUrl: null,
prAuthor: null,
ciStatus: null,
reviewStatus: null,
reviewer: null,
conflictsWithMain: null,
hasUnpushed: null,
parentBranch: null,
createdAt: 1,
updatedAt: 1,
};
} satisfies TaskRecord & { pullRequest: null; activeSessionId?: null };
describe("formatRows", () => {
it("renders rust-style table header and empty state", () => {

View file

@ -37,6 +37,7 @@ import type {
StarSandboxAgentRepoResult,
SwitchResult,
UpdateFoundryOrganizationProfileInput,
WorkspaceModelGroup,
WorkspaceModelId,
} from "@sandbox-agent/foundry-shared";
import type { ProcessCreateRequest, ProcessInfo, ProcessLogFollowQuery, ProcessLogsResponse, ProcessSignalQuery } from "sandbox-agent";
@ -73,6 +74,10 @@ export interface ActorConn {
dispose(): Promise<void>;
}
interface AuthSessionScopedInput {
authSessionId?: string;
}
interface OrganizationHandle {
connect(): ActorConn;
listRepos(input: { organizationId: string }): Promise<RepoRecord[]>;
@ -91,24 +96,22 @@ interface OrganizationHandle {
useOrganization(input: { organizationId: string }): Promise<{ organizationId: string }>;
starSandboxAgentRepo(input: StarSandboxAgentRepoInput): Promise<StarSandboxAgentRepoResult>;
getOrganizationSummary(input: { organizationId: string }): Promise<OrganizationSummarySnapshot>;
adminReconcileWorkspaceState(input: { organizationId: string }): Promise<OrganizationSummarySnapshot>;
createWorkspaceTask(input: TaskWorkspaceCreateTaskInput): Promise<TaskWorkspaceCreateTaskResponse>;
markWorkspaceUnread(input: TaskWorkspaceSelectInput): Promise<void>;
renameWorkspaceTask(input: TaskWorkspaceRenameInput): Promise<void>;
createWorkspaceSession(input: TaskWorkspaceSelectInput & { model?: string }): Promise<{ sessionId: string }>;
renameWorkspaceSession(input: TaskWorkspaceRenameSessionInput): Promise<void>;
setWorkspaceSessionUnread(input: TaskWorkspaceSetSessionUnreadInput): Promise<void>;
updateWorkspaceDraft(input: TaskWorkspaceUpdateDraftInput): Promise<void>;
changeWorkspaceModel(input: TaskWorkspaceChangeModelInput): Promise<void>;
sendWorkspaceMessage(input: TaskWorkspaceSendMessageInput): Promise<void>;
stopWorkspaceSession(input: TaskWorkspaceSessionInput): Promise<void>;
closeWorkspaceSession(input: TaskWorkspaceSessionInput): Promise<void>;
publishWorkspacePr(input: TaskWorkspaceSelectInput): Promise<void>;
revertWorkspaceFile(input: TaskWorkspaceDiffInput): Promise<void>;
createWorkspaceTask(input: TaskWorkspaceCreateTaskInput & AuthSessionScopedInput): Promise<TaskWorkspaceCreateTaskResponse>;
markWorkspaceUnread(input: TaskWorkspaceSelectInput & AuthSessionScopedInput): Promise<void>;
renameWorkspaceTask(input: TaskWorkspaceRenameInput & AuthSessionScopedInput): Promise<void>;
createWorkspaceSession(input: TaskWorkspaceSelectInput & { model?: string } & AuthSessionScopedInput): Promise<{ sessionId: string }>;
renameWorkspaceSession(input: TaskWorkspaceRenameSessionInput & AuthSessionScopedInput): Promise<void>;
selectWorkspaceSession(input: TaskWorkspaceSessionInput & AuthSessionScopedInput): Promise<void>;
setWorkspaceSessionUnread(input: TaskWorkspaceSetSessionUnreadInput & AuthSessionScopedInput): Promise<void>;
updateWorkspaceDraft(input: TaskWorkspaceUpdateDraftInput & AuthSessionScopedInput): Promise<void>;
changeWorkspaceModel(input: TaskWorkspaceChangeModelInput & AuthSessionScopedInput): Promise<void>;
sendWorkspaceMessage(input: TaskWorkspaceSendMessageInput & AuthSessionScopedInput): Promise<void>;
stopWorkspaceSession(input: TaskWorkspaceSessionInput & AuthSessionScopedInput): Promise<void>;
closeWorkspaceSession(input: TaskWorkspaceSessionInput & AuthSessionScopedInput): Promise<void>;
publishWorkspacePr(input: TaskWorkspaceSelectInput & AuthSessionScopedInput): Promise<void>;
revertWorkspaceFile(input: TaskWorkspaceDiffInput & AuthSessionScopedInput): Promise<void>;
adminReloadGithubOrganization(): Promise<void>;
adminReloadGithubPullRequests(): Promise<void>;
adminReloadGithubRepository(input: { repoId: string }): Promise<void>;
adminReloadGithubPullRequest(input: { repoId: string; prNumber: number }): Promise<void>;
}
interface AppOrganizationHandle {
@ -130,8 +133,8 @@ interface AppOrganizationHandle {
interface TaskHandle {
getTaskSummary(): Promise<WorkspaceTaskSummary>;
getTaskDetail(): Promise<WorkspaceTaskDetail>;
getSessionDetail(input: { sessionId: string }): Promise<WorkspaceSessionDetail>;
getTaskDetail(input?: AuthSessionScopedInput): Promise<WorkspaceTaskDetail>;
getSessionDetail(input: { sessionId: string } & AuthSessionScopedInput): Promise<WorkspaceSessionDetail>;
connect(): ActorConn;
}
@ -156,6 +159,7 @@ interface TaskSandboxHandle {
rawSendSessionMethod(sessionId: string, method: string, params: Record<string, unknown>): Promise<unknown>;
destroySession(sessionId: string): Promise<void>;
sandboxAgentConnection(): Promise<{ endpoint: string; token?: string }>;
listWorkspaceModelGroups(): Promise<WorkspaceModelGroup[]>;
providerState(): Promise<{ sandboxProviderId: SandboxProviderId; sandboxId: string; state: string; at: number }>;
}
@ -279,6 +283,7 @@ export interface BackendClient {
sandboxId: string,
): Promise<{ sandboxProviderId: SandboxProviderId; sandboxId: string; state: string; at: number }>;
getSandboxAgentConnection(organizationId: string, sandboxProviderId: SandboxProviderId, sandboxId: string): Promise<{ endpoint: string; token?: string }>;
getSandboxWorkspaceModelGroups(organizationId: string, sandboxProviderId: SandboxProviderId, sandboxId: string): Promise<WorkspaceModelGroup[]>;
getOrganizationSummary(organizationId: string): Promise<OrganizationSummarySnapshot>;
getTaskDetail(organizationId: string, repoId: string, taskId: string): Promise<WorkspaceTaskDetail>;
getSessionDetail(organizationId: string, repoId: string, taskId: string, sessionId: string): Promise<WorkspaceSessionDetail>;
@ -289,6 +294,7 @@ export interface BackendClient {
renameWorkspaceTask(organizationId: string, input: TaskWorkspaceRenameInput): Promise<void>;
createWorkspaceSession(organizationId: string, input: TaskWorkspaceSelectInput & { model?: string }): Promise<{ sessionId: string }>;
renameWorkspaceSession(organizationId: string, input: TaskWorkspaceRenameSessionInput): Promise<void>;
selectWorkspaceSession(organizationId: string, input: TaskWorkspaceSessionInput): Promise<void>;
setWorkspaceSessionUnread(organizationId: string, input: TaskWorkspaceSetSessionUnreadInput): Promise<void>;
updateWorkspaceDraft(organizationId: string, input: TaskWorkspaceUpdateDraftInput): Promise<void>;
changeWorkspaceModel(organizationId: string, input: TaskWorkspaceChangeModelInput): Promise<void>;
@ -298,9 +304,7 @@ export interface BackendClient {
publishWorkspacePr(organizationId: string, input: TaskWorkspaceSelectInput): Promise<void>;
revertWorkspaceFile(organizationId: string, input: TaskWorkspaceDiffInput): Promise<void>;
adminReloadGithubOrganization(organizationId: string): Promise<void>;
adminReloadGithubPullRequests(organizationId: string): Promise<void>;
adminReloadGithubRepository(organizationId: string, repoId: string): Promise<void>;
adminReloadGithubPullRequest(organizationId: string, repoId: string, prNumber: number): Promise<void>;
health(): Promise<{ ok: true }>;
useOrganization(organizationId: string): Promise<{ organizationId: string }>;
starSandboxAgentRepo(organizationId: string): Promise<StarSandboxAgentRepoResult>;
@ -460,6 +464,16 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
return typeof sessionId === "string" && sessionId.length > 0 ? sessionId : null;
};
const getAuthSessionInput = async (): Promise<AuthSessionScopedInput | undefined> => {
const authSessionId = await getSessionId();
return authSessionId ? { authSessionId } : undefined;
};
const withAuthSessionInput = async <TInput extends object>(input: TInput): Promise<TInput & AuthSessionScopedInput> => {
const authSessionInput = await getAuthSessionInput();
return authSessionInput ? { ...input, ...authSessionInput } : input;
};
const organization = async (organizationId: string): Promise<OrganizationHandle> =>
client.organization.getOrCreate(organizationKey(organizationId), {
createWithInput: organizationId,
@ -492,17 +506,18 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
for (const row of candidates) {
try {
const detail = await ws.getTask({ organizationId, taskId: row.taskId });
const detail = await ws.getTask({ organizationId, repoId: row.repoId, taskId: row.taskId });
if (detail.sandboxProviderId !== sandboxProviderId) {
continue;
}
const sandbox = detail.sandboxes.find(
const sandboxes = detail.sandboxes as Array<(typeof detail.sandboxes)[number] & { sandboxActorId?: string }>;
const sandbox = sandboxes.find(
(sb) =>
sb.sandboxId === sandboxId &&
sb.sandboxProviderId === sandboxProviderId &&
typeof (sb as any).sandboxActorId === "string" &&
(sb as any).sandboxActorId.length > 0,
) as { sandboxActorId?: string } | undefined;
typeof sb.sandboxActorId === "string" &&
sb.sandboxActorId.length > 0,
);
if (sandbox?.sandboxActorId) {
return (client as any).taskSandbox.getForId(sandbox.sandboxActorId);
}
@ -562,14 +577,28 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
}
};
const getTaskDetailWithAuth = async (organizationId: string, repoId: string, taskIdValue: string): Promise<WorkspaceTaskDetail> => {
return (await task(organizationId, repoId, taskIdValue)).getTaskDetail(await getAuthSessionInput());
};
const getSessionDetailWithAuth = async (
organizationId: string,
repoId: string,
taskIdValue: string,
sessionId: string,
): Promise<WorkspaceSessionDetail> => {
return (await task(organizationId, repoId, taskIdValue)).getSessionDetail(await withAuthSessionInput({ sessionId }));
};
const getWorkspaceCompat = async (organizationId: string): Promise<TaskWorkspaceSnapshot> => {
const authSessionInput = await getAuthSessionInput();
const summary = await (await organization(organizationId)).getOrganizationSummary({ organizationId });
const tasks = (
await Promise.all(
summary.taskSummaries.map(async (taskSummary) => {
const resolvedTasks = await Promise.all(
summary.taskSummaries.map(async (taskSummary) => {
let detail;
try {
detail = await (await task(organizationId, taskSummary.repoId, taskSummary.id)).getTaskDetail();
const taskHandle = await task(organizationId, taskSummary.repoId, taskSummary.id);
detail = await taskHandle.getTaskDetail(authSessionInput);
} catch (error) {
if (isActorNotFoundError(error)) {
return null;
@ -579,7 +608,10 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
const sessionDetails = await Promise.all(
detail.sessionsSummary.map(async (session) => {
try {
const full = await (await task(organizationId, detail.repoId, detail.id)).getSessionDetail({ sessionId: session.id });
const full = await (await task(organizationId, detail.repoId, detail.id)).getSessionDetail({
sessionId: session.id,
...(authSessionInput ?? {}),
});
return [session.id, full] as const;
} catch (error) {
if (isActorNotFoundError(error)) {
@ -599,6 +631,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
updatedAtMs: detail.updatedAtMs,
branch: detail.branch,
pullRequest: detail.pullRequest,
activeSessionId: detail.activeSessionId ?? null,
sessions: detail.sessionsSummary.map((session) => {
const full = sessionDetailsById.get(session.id);
return {
@ -619,10 +652,11 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
diffs: detail.diffs,
fileTree: detail.fileTree,
minutesUsed: detail.minutesUsed,
activeSandboxId: detail.activeSandboxId ?? null,
};
}),
)
).filter((task): task is TaskWorkspaceSnapshot["tasks"][number] => task !== null);
);
const tasks = resolvedTasks.filter((task): task is Exclude<(typeof resolvedTasks)[number], null> => task !== null);
const repositories = summary.repos
.map((repo) => ({
@ -1170,16 +1204,24 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
return await withSandboxHandle(organizationId, sandboxProviderId, sandboxId, async (handle) => handle.sandboxAgentConnection());
},
async getSandboxWorkspaceModelGroups(
organizationId: string,
sandboxProviderId: SandboxProviderId,
sandboxId: string,
): Promise<WorkspaceModelGroup[]> {
return await withSandboxHandle(organizationId, sandboxProviderId, sandboxId, async (handle) => handle.listWorkspaceModelGroups());
},
async getOrganizationSummary(organizationId: string): Promise<OrganizationSummarySnapshot> {
return (await organization(organizationId)).getOrganizationSummary({ organizationId });
},
async getTaskDetail(organizationId: string, repoId: string, taskIdValue: string): Promise<WorkspaceTaskDetail> {
return (await task(organizationId, repoId, taskIdValue)).getTaskDetail();
return await getTaskDetailWithAuth(organizationId, repoId, taskIdValue);
},
async getSessionDetail(organizationId: string, repoId: string, taskIdValue: string, sessionId: string): Promise<WorkspaceSessionDetail> {
return (await task(organizationId, repoId, taskIdValue)).getSessionDetail({ sessionId });
return await getSessionDetailWithAuth(organizationId, repoId, taskIdValue, sessionId);
},
async getWorkspace(organizationId: string): Promise<TaskWorkspaceSnapshot> {
@ -1191,73 +1233,69 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
},
async createWorkspaceTask(organizationId: string, input: TaskWorkspaceCreateTaskInput): Promise<TaskWorkspaceCreateTaskResponse> {
return (await organization(organizationId)).createWorkspaceTask(input);
return (await organization(organizationId)).createWorkspaceTask(await withAuthSessionInput(input));
},
async markWorkspaceUnread(organizationId: string, input: TaskWorkspaceSelectInput): Promise<void> {
await (await organization(organizationId)).markWorkspaceUnread(input);
await (await organization(organizationId)).markWorkspaceUnread(await withAuthSessionInput(input));
},
async renameWorkspaceTask(organizationId: string, input: TaskWorkspaceRenameInput): Promise<void> {
await (await organization(organizationId)).renameWorkspaceTask(input);
await (await organization(organizationId)).renameWorkspaceTask(await withAuthSessionInput(input));
},
async createWorkspaceSession(organizationId: string, input: TaskWorkspaceSelectInput & { model?: string }): Promise<{ sessionId: string }> {
return await (await organization(organizationId)).createWorkspaceSession(input);
return await (await organization(organizationId)).createWorkspaceSession(await withAuthSessionInput(input));
},
async renameWorkspaceSession(organizationId: string, input: TaskWorkspaceRenameSessionInput): Promise<void> {
await (await organization(organizationId)).renameWorkspaceSession(input);
await (await organization(organizationId)).renameWorkspaceSession(await withAuthSessionInput(input));
},
async selectWorkspaceSession(organizationId: string, input: TaskWorkspaceSessionInput): Promise<void> {
await (await organization(organizationId)).selectWorkspaceSession(await withAuthSessionInput(input));
},
async setWorkspaceSessionUnread(organizationId: string, input: TaskWorkspaceSetSessionUnreadInput): Promise<void> {
await (await organization(organizationId)).setWorkspaceSessionUnread(input);
await (await organization(organizationId)).setWorkspaceSessionUnread(await withAuthSessionInput(input));
},
async updateWorkspaceDraft(organizationId: string, input: TaskWorkspaceUpdateDraftInput): Promise<void> {
await (await organization(organizationId)).updateWorkspaceDraft(input);
await (await organization(organizationId)).updateWorkspaceDraft(await withAuthSessionInput(input));
},
async changeWorkspaceModel(organizationId: string, input: TaskWorkspaceChangeModelInput): Promise<void> {
await (await organization(organizationId)).changeWorkspaceModel(input);
await (await organization(organizationId)).changeWorkspaceModel(await withAuthSessionInput(input));
},
async sendWorkspaceMessage(organizationId: string, input: TaskWorkspaceSendMessageInput): Promise<void> {
await (await organization(organizationId)).sendWorkspaceMessage(input);
await (await organization(organizationId)).sendWorkspaceMessage(await withAuthSessionInput(input));
},
async stopWorkspaceSession(organizationId: string, input: TaskWorkspaceSessionInput): Promise<void> {
await (await organization(organizationId)).stopWorkspaceSession(input);
await (await organization(organizationId)).stopWorkspaceSession(await withAuthSessionInput(input));
},
async closeWorkspaceSession(organizationId: string, input: TaskWorkspaceSessionInput): Promise<void> {
await (await organization(organizationId)).closeWorkspaceSession(input);
await (await organization(organizationId)).closeWorkspaceSession(await withAuthSessionInput(input));
},
async publishWorkspacePr(organizationId: string, input: TaskWorkspaceSelectInput): Promise<void> {
await (await organization(organizationId)).publishWorkspacePr(input);
await (await organization(organizationId)).publishWorkspacePr(await withAuthSessionInput(input));
},
async revertWorkspaceFile(organizationId: string, input: TaskWorkspaceDiffInput): Promise<void> {
await (await organization(organizationId)).revertWorkspaceFile(input);
await (await organization(organizationId)).revertWorkspaceFile(await withAuthSessionInput(input));
},
async adminReloadGithubOrganization(organizationId: string): Promise<void> {
await (await organization(organizationId)).adminReloadGithubOrganization();
},
async adminReloadGithubPullRequests(organizationId: string): Promise<void> {
await (await organization(organizationId)).adminReloadGithubPullRequests();
},
async adminReloadGithubRepository(organizationId: string, repoId: string): Promise<void> {
await (await organization(organizationId)).adminReloadGithubRepository({ repoId });
},
async adminReloadGithubPullRequest(organizationId: string, repoId: string, prNumber: number): Promise<void> {
await (await organization(organizationId)).adminReloadGithubPullRequest({ repoId, prNumber });
},
async health(): Promise<{ ok: true }> {
const organizationId = options.defaultOrganizationId;
if (!organizationId) {

View file

@ -1,4 +1,8 @@
import type { WorkspaceModelId } from "@sandbox-agent/foundry-shared";
import { DEFAULT_WORKSPACE_MODEL_GROUPS, DEFAULT_WORKSPACE_MODEL_ID, type WorkspaceModelId } from "@sandbox-agent/foundry-shared";
const claudeModels = DEFAULT_WORKSPACE_MODEL_GROUPS.find((group) => group.agentKind === "Claude")?.models ?? [];
const CLAUDE_SECONDARY_MODEL_ID = claudeModels[1]?.id ?? claudeModels[0]?.id ?? DEFAULT_WORKSPACE_MODEL_ID;
const CLAUDE_TERTIARY_MODEL_ID = claudeModels[2]?.id ?? CLAUDE_SECONDARY_MODEL_ID;
import { injectMockLatency } from "./mock/latency.js";
import rivetDevFixture from "../../../scripts/data/rivet-dev.json" with { type: "json" };
@ -233,7 +237,7 @@ function buildDefaultSnapshot(): MockFoundryAppSnapshot {
githubLogin: "nathan",
roleLabel: "Founder",
eligibleOrganizationIds: ["personal-nathan", "acme", "rivet"],
defaultModel: "gpt-5.3-codex",
defaultModel: DEFAULT_WORKSPACE_MODEL_ID,
},
{
id: "user-maya",
@ -242,7 +246,7 @@ function buildDefaultSnapshot(): MockFoundryAppSnapshot {
githubLogin: "maya",
roleLabel: "Staff Engineer",
eligibleOrganizationIds: ["acme"],
defaultModel: "claude-sonnet-4",
defaultModel: CLAUDE_SECONDARY_MODEL_ID,
},
{
id: "user-jamie",
@ -251,7 +255,7 @@ function buildDefaultSnapshot(): MockFoundryAppSnapshot {
githubLogin: "jamie",
roleLabel: "Platform Lead",
eligibleOrganizationIds: ["personal-jamie", "rivet"],
defaultModel: "claude-opus-4",
defaultModel: CLAUDE_TERTIARY_MODEL_ID,
},
],
organizations: [

View file

@ -20,6 +20,7 @@ import type {
TaskWorkspaceUpdateDraftInput,
TaskEvent,
WorkspaceSessionDetail,
WorkspaceModelGroup,
WorkspaceTaskDetail,
WorkspaceTaskSummary,
OrganizationEvent,
@ -32,6 +33,7 @@ import type {
StarSandboxAgentRepoResult,
SwitchResult,
} from "@sandbox-agent/foundry-shared";
import { DEFAULT_WORKSPACE_MODEL_GROUPS } from "@sandbox-agent/foundry-shared";
import type { ProcessCreateRequest, ProcessLogFollowQuery, ProcessLogsResponse, ProcessSignalQuery } from "sandbox-agent";
import type { ActorConn, BackendClient, SandboxProcessRecord, SandboxSessionEventRecord, SandboxSessionRecord } from "../backend-client.js";
import { getSharedMockWorkspaceClient } from "./workspace-client.js";
@ -173,6 +175,7 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back
updatedAtMs: task.updatedAtMs,
branch: task.branch,
pullRequest: task.pullRequest,
activeSessionId: task.activeSessionId ?? task.sessions[0]?.id ?? null,
sessionsSummary: task.sessions.map((tab) => ({
id: tab.id,
sessionId: tab.sessionId,
@ -190,13 +193,6 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back
const buildTaskDetail = (task: TaskWorkspaceSnapshot["tasks"][number]): WorkspaceTaskDetail => ({
...buildTaskSummary(task),
task: task.title,
agentType: task.sessions[0]?.agent === "Codex" ? "codex" : "claude",
runtimeStatus: toTaskStatus(task.status === "archived" ? "archived" : "running", task.status === "archived"),
statusMessage: task.status === "archived" ? "archived" : "mock sandbox ready",
activeSessionId: task.sessions[0]?.sessionId ?? null,
diffStat: task.fileChanges.length > 0 ? `+${task.fileChanges.length}/-${task.fileChanges.length}` : "+0/-0",
prUrl: task.pullRequest ? `https://example.test/pr/${task.pullRequest.number}` : null,
reviewStatus: null,
fileChanges: task.fileChanges,
diffs: task.diffs,
fileTree: task.fileTree,
@ -236,6 +232,20 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back
const taskSummaries = snapshot.tasks.map(buildTaskSummary);
return {
organizationId: defaultOrganizationId,
github: {
connectedAccount: "mock",
installationStatus: "connected",
syncStatus: "synced",
importedRepoCount: snapshot.repos.length,
lastSyncLabel: "Synced just now",
lastSyncAt: nowMs(),
lastWebhookAt: null,
lastWebhookEvent: "",
syncGeneration: 1,
syncPhase: null,
processedRepositoryCount: snapshot.repos.length,
totalRepositoryCount: snapshot.repos.length,
},
repos: snapshot.repos.map((repo) => {
const repoTasks = taskSummaries.filter((task) => task.repoId === repo.id);
return {
@ -298,9 +308,7 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back
task: task.title,
sandboxProviderId: "local",
status: toTaskStatus(archived ? "archived" : "running", archived),
statusMessage: archived ? "archived" : "mock sandbox ready",
activeSandboxId: task.id,
activeSessionId: task.sessions[0]?.sessionId ?? null,
sandboxes: [
{
sandboxId: task.id,
@ -312,17 +320,6 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back
updatedAt: task.updatedAtMs,
},
],
agentType: task.sessions[0]?.agent === "Codex" ? "codex" : "claude",
prSubmitted: Boolean(task.pullRequest),
diffStat: task.fileChanges.length > 0 ? `+${task.fileChanges.length}/-${task.fileChanges.length}` : "+0/-0",
prUrl: task.pullRequest ? `https://example.test/pr/${task.pullRequest.number}` : null,
prAuthor: task.pullRequest ? "mock" : null,
ciStatus: null,
reviewStatus: null,
reviewer: null,
conflictsWithMain: "0",
hasUnpushed: task.fileChanges.length > 0 ? "1" : "0",
parentBranch: null,
createdAt: task.updatedAtMs,
updatedAt: task.updatedAtMs,
};
@ -636,6 +633,14 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back
return { endpoint: "mock://terminal-unavailable" };
},
async getSandboxWorkspaceModelGroups(
_organizationId: string,
_sandboxProviderId: SandboxProviderId,
_sandboxId: string,
): Promise<WorkspaceModelGroup[]> {
return DEFAULT_WORKSPACE_MODEL_GROUPS;
},
async getOrganizationSummary(): Promise<OrganizationSummarySnapshot> {
return buildOrganizationSummary();
},
@ -693,6 +698,13 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back
emitSessionUpdate(input.taskId, input.sessionId);
},
async selectWorkspaceSession(_organizationId: string, input: TaskWorkspaceSessionInput): Promise<void> {
await workspace.selectSession(input);
emitOrganizationSnapshot();
emitTaskUpdate(input.taskId);
emitSessionUpdate(input.taskId, input.sessionId);
},
async setWorkspaceSessionUnread(_organizationId: string, input: TaskWorkspaceSetSessionUnreadInput): Promise<void> {
await workspace.setSessionUnread(input);
emitOrganizationSnapshot();
@ -747,13 +759,8 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back
},
async adminReloadGithubOrganization(): Promise<void> {},
async adminReloadGithubPullRequests(): Promise<void> {},
async adminReloadGithubRepository(): Promise<void> {},
async adminReloadGithubPullRequest(): Promise<void> {},
async health(): Promise<{ ok: true }> {
return { ok: true };
},

View file

@ -9,6 +9,7 @@ import {
slugify,
uid,
} from "../workspace-model.js";
import { DEFAULT_WORKSPACE_MODEL_ID, workspaceAgentForModel } from "@sandbox-agent/foundry-shared";
import type {
TaskWorkspaceAddSessionResponse,
TaskWorkspaceChangeModelInput,
@ -74,20 +75,19 @@ class MockWorkspaceStore implements TaskWorkspaceClient {
id,
repoId: repo.id,
title: input.title?.trim() || "New Task",
status: "new",
status: "init_enqueue_provision",
repoName: repo.label,
updatedAtMs: nowMs(),
branch: input.branch?.trim() || null,
pullRequest: null,
activeSessionId: sessionId,
sessions: [
{
id: sessionId,
sessionId: sessionId,
sessionName: "Session 1",
agent: providerAgent(
MODEL_GROUPS.find((group) => group.models.some((model) => model.id === (input.model ?? "claude-sonnet-4")))?.provider ?? "Claude",
),
model: input.model ?? "claude-sonnet-4",
agent: workspaceAgentForModel(input.model ?? DEFAULT_WORKSPACE_MODEL_ID, MODEL_GROUPS),
model: input.model ?? DEFAULT_WORKSPACE_MODEL_ID,
status: "idle",
thinkingSinceMs: null,
unread: false,
@ -140,7 +140,18 @@ class MockWorkspaceStore implements TaskWorkspaceClient {
this.updateTask(input.taskId, (task) => ({
...task,
updatedAtMs: nowMs(),
pullRequest: { number: nextPrNumber, status: "ready" },
pullRequest: {
number: nextPrNumber,
title: task.title,
state: "open",
url: `https://example.test/pr/${nextPrNumber}`,
headRefName: task.branch ?? `task/${task.id}`,
baseRefName: "main",
repoFullName: task.repoName,
authorLogin: "mock",
isDraft: false,
updatedAtMs: nowMs(),
},
}));
}
@ -189,7 +200,7 @@ class MockWorkspaceStore implements TaskWorkspaceClient {
const startedAtMs = nowMs();
this.updateTask(input.taskId, (currentTask) => {
const isFirstOnTask = currentTask.status === "new";
const isFirstOnTask = String(currentTask.status).startsWith("init_");
const newTitle = isFirstOnTask ? (text.length > 50 ? `${text.slice(0, 47)}...` : text) : currentTask.title;
const newBranch = isFirstOnTask ? `feat/${slugify(newTitle)}` : currentTask.branch;
const userMessageLines = [text, ...input.attachments.map((attachment) => `@ ${attachment.filePath}:${attachment.lineNumber}`)];
@ -303,6 +314,14 @@ class MockWorkspaceStore implements TaskWorkspaceClient {
});
}
async selectSession(input: TaskWorkspaceSessionInput): Promise<void> {
this.assertSession(input.taskId, input.sessionId);
this.updateTask(input.taskId, (currentTask) => ({
...currentTask,
activeSessionId: input.sessionId,
}));
}
async setSessionUnread(input: TaskWorkspaceSetSessionUnreadInput): Promise<void> {
this.updateTask(input.taskId, (currentTask) => ({
...currentTask,
@ -329,6 +348,7 @@ class MockWorkspaceStore implements TaskWorkspaceClient {
return {
...currentTask,
activeSessionId: currentTask.activeSessionId === input.sessionId ? (currentTask.sessions.find((candidate) => candidate.id !== input.sessionId)?.id ?? null) : currentTask.activeSessionId,
sessions: currentTask.sessions.filter((candidate) => candidate.id !== input.sessionId),
};
});
@ -342,8 +362,8 @@ class MockWorkspaceStore implements TaskWorkspaceClient {
sessionId: nextSessionId,
sandboxSessionId: null,
sessionName: `Session ${this.requireTask(input.taskId).sessions.length + 1}`,
agent: "Claude",
model: "claude-sonnet-4",
agent: workspaceAgentForModel(DEFAULT_WORKSPACE_MODEL_ID, MODEL_GROUPS),
model: DEFAULT_WORKSPACE_MODEL_ID,
status: "idle",
thinkingSinceMs: null,
unread: false,
@ -355,6 +375,7 @@ class MockWorkspaceStore implements TaskWorkspaceClient {
this.updateTask(input.taskId, (currentTask) => ({
...currentTask,
updatedAtMs: nowMs(),
activeSessionId: nextSession.id,
sessions: [...currentTask.sessions, nextSession],
}));
return { sessionId: nextSession.id };
@ -369,7 +390,7 @@ class MockWorkspaceStore implements TaskWorkspaceClient {
this.updateTask(input.taskId, (currentTask) => ({
...currentTask,
sessions: currentTask.sessions.map((candidate) =>
candidate.id === input.sessionId ? { ...candidate, model: input.model, agent: providerAgent(group.provider) } : candidate,
candidate.id === input.sessionId ? { ...candidate, model: input.model, agent: workspaceAgentForModel(input.model, MODEL_GROUPS) } : candidate,
),
}));
}

View file

@ -109,6 +109,11 @@ class RemoteWorkspaceStore implements TaskWorkspaceClient {
await this.refresh();
}
async selectSession(input: TaskWorkspaceSessionInput): Promise<void> {
await this.backend.selectWorkspaceSession(this.organizationId, input);
await this.refresh();
}
async setSessionUnread(input: TaskWorkspaceSetSessionUnreadInput): Promise<void> {
await this.backend.setWorkspaceSessionUnread(this.organizationId, input);
await this.refresh();

View file

@ -81,6 +81,7 @@ class TopicEntry<TData, TParams, TEvent> {
private unsubscribeError: (() => void) | null = null;
private teardownTimer: ReturnType<typeof setTimeout> | null = null;
private startPromise: Promise<void> | null = null;
private eventPromise: Promise<void> = Promise.resolve();
private started = false;
constructor(
@ -157,12 +158,7 @@ class TopicEntry<TData, TParams, TEvent> {
try {
this.conn = await this.definition.connect(this.backend, this.params);
this.unsubscribeEvent = this.conn.on(this.definition.event, (event: TEvent) => {
if (this.data === undefined) {
return;
}
this.data = this.definition.applyEvent(this.data, event);
this.lastRefreshAt = Date.now();
this.notify();
void this.applyEvent(event);
});
this.unsubscribeError = this.conn.onError((error: unknown) => {
this.status = "error";
@ -182,6 +178,33 @@ class TopicEntry<TData, TParams, TEvent> {
}
}
private applyEvent(event: TEvent): Promise<void> {
this.eventPromise = this.eventPromise
.then(async () => {
if (!this.started || this.data === undefined) {
return;
}
const nextData = await this.definition.applyEvent(this.backend, this.params, this.data, event);
if (!this.started) {
return;
}
this.data = nextData;
this.status = "connected";
this.error = null;
this.lastRefreshAt = Date.now();
this.notify();
})
.catch((error) => {
this.status = "error";
this.error = error instanceof Error ? error : new Error(String(error));
this.notify();
});
return this.eventPromise;
}
private notify(): void {
for (const listener of [...this.listeners]) {
listener();

View file

@ -16,15 +16,15 @@ import type { ActorConn, BackendClient, SandboxProcessRecord } from "../backend-
* Topic definitions for the subscription manager.
*
* Each topic describes one actor connection plus one materialized read model.
* Events always carry full replacement payloads for the changed entity so the
* client can replace cached state directly instead of reconstructing patches.
* Some topics can apply broadcast payloads directly, while others refetch
* through BackendClient so auth-scoped state stays user-specific.
*/
export interface TopicDefinition<TData, TParams, TEvent> {
key: (params: TParams) => string;
event: string;
connect: (backend: BackendClient, params: TParams) => Promise<ActorConn>;
fetchInitial: (backend: BackendClient, params: TParams) => Promise<TData>;
applyEvent: (current: TData, event: TEvent) => TData;
applyEvent: (backend: BackendClient, params: TParams, current: TData, event: TEvent) => Promise<TData> | TData;
}
export interface AppTopicParams {}
@ -54,7 +54,7 @@ export const topicDefinitions = {
event: "appUpdated",
connect: (backend: BackendClient, _params: AppTopicParams) => backend.connectOrganization("app"),
fetchInitial: (backend: BackendClient, _params: AppTopicParams) => backend.getAppSnapshot(),
applyEvent: (_current: FoundryAppSnapshot, event: AppEvent) => event.snapshot,
applyEvent: (_backend: BackendClient, _params: AppTopicParams, _current: FoundryAppSnapshot, event: AppEvent) => event.snapshot,
} satisfies TopicDefinition<FoundryAppSnapshot, AppTopicParams, AppEvent>,
organization: {
@ -62,7 +62,8 @@ export const topicDefinitions = {
event: "organizationUpdated",
connect: (backend: BackendClient, params: OrganizationTopicParams) => backend.connectOrganization(params.organizationId),
fetchInitial: (backend: BackendClient, params: OrganizationTopicParams) => backend.getOrganizationSummary(params.organizationId),
applyEvent: (_current: OrganizationSummarySnapshot, event: OrganizationEvent) => event.snapshot,
applyEvent: (_backend: BackendClient, _params: OrganizationTopicParams, _current: OrganizationSummarySnapshot, event: OrganizationEvent) =>
event.snapshot,
} satisfies TopicDefinition<OrganizationSummarySnapshot, OrganizationTopicParams, OrganizationEvent>,
task: {
@ -70,7 +71,8 @@ export const topicDefinitions = {
event: "taskUpdated",
connect: (backend: BackendClient, params: TaskTopicParams) => backend.connectTask(params.organizationId, params.repoId, params.taskId),
fetchInitial: (backend: BackendClient, params: TaskTopicParams) => backend.getTaskDetail(params.organizationId, params.repoId, params.taskId),
applyEvent: (_current: WorkspaceTaskDetail, event: TaskEvent) => event.detail,
applyEvent: (backend: BackendClient, params: TaskTopicParams, _current: WorkspaceTaskDetail, _event: TaskEvent) =>
backend.getTaskDetail(params.organizationId, params.repoId, params.taskId),
} satisfies TopicDefinition<WorkspaceTaskDetail, TaskTopicParams, TaskEvent>,
session: {
@ -79,11 +81,11 @@ export const topicDefinitions = {
connect: (backend: BackendClient, params: SessionTopicParams) => backend.connectTask(params.organizationId, params.repoId, params.taskId),
fetchInitial: (backend: BackendClient, params: SessionTopicParams) =>
backend.getSessionDetail(params.organizationId, params.repoId, params.taskId, params.sessionId),
applyEvent: (current: WorkspaceSessionDetail, event: SessionEvent) => {
if (event.session.sessionId !== current.sessionId) {
applyEvent: async (backend: BackendClient, params: SessionTopicParams, current: WorkspaceSessionDetail, event: SessionEvent) => {
if (event.session.sessionId !== params.sessionId) {
return current;
}
return event.session;
return await backend.getSessionDetail(params.organizationId, params.repoId, params.taskId, params.sessionId);
},
} satisfies TopicDefinition<WorkspaceSessionDetail, SessionTopicParams, SessionEvent>,
@ -94,7 +96,8 @@ export const topicDefinitions = {
backend.connectSandbox(params.organizationId, params.sandboxProviderId, params.sandboxId),
fetchInitial: async (backend: BackendClient, params: SandboxProcessesTopicParams) =>
(await backend.listSandboxProcesses(params.organizationId, params.sandboxProviderId, params.sandboxId)).processes,
applyEvent: (_current: SandboxProcessRecord[], event: SandboxProcessesEvent) => event.processes,
applyEvent: (_backend: BackendClient, _params: SandboxProcessesTopicParams, _current: SandboxProcessRecord[], event: SandboxProcessesEvent) =>
event.processes,
} satisfies TopicDefinition<SandboxProcessRecord[], SandboxProcessesTopicParams, SandboxProcessesEvent>,
} as const;

View file

@ -65,7 +65,7 @@ export function filterTasks(rows: TaskRecord[], query: string): TaskRecord[] {
}
return rows.filter((row) => {
const fields = [row.branchName ?? "", row.title ?? "", row.taskId, row.task, row.prAuthor ?? "", row.reviewer ?? ""];
const fields = [row.branchName ?? "", row.title ?? "", row.taskId, row.task];
return fields.some((field) => fuzzyMatch(field, q));
});
}

View file

@ -37,6 +37,7 @@ export interface TaskWorkspaceClient {
updateDraft(input: TaskWorkspaceUpdateDraftInput): Promise<void>;
sendMessage(input: TaskWorkspaceSendMessageInput): Promise<void>;
stopAgent(input: TaskWorkspaceSessionInput): Promise<void>;
selectSession(input: TaskWorkspaceSessionInput): Promise<void>;
setSessionUnread(input: TaskWorkspaceSetSessionUnreadInput): Promise<void>;
renameSession(input: TaskWorkspaceRenameSessionInput): Promise<void>;
closeSession(input: TaskWorkspaceSessionInput): Promise<void>;

View file

@ -1,3 +1,9 @@
import {
DEFAULT_WORKSPACE_MODEL_ID,
DEFAULT_WORKSPACE_MODEL_GROUPS as SharedModelGroups,
workspaceModelLabel as sharedWorkspaceModelLabel,
workspaceProviderAgent as sharedWorkspaceProviderAgent,
} from "@sandbox-agent/foundry-shared";
import type {
WorkspaceAgentKind as AgentKind,
WorkspaceSession as AgentSession,
@ -15,26 +21,8 @@ import type {
} from "@sandbox-agent/foundry-shared";
import rivetDevFixture from "../../../scripts/data/rivet-dev.json" with { type: "json" };
export const MODEL_GROUPS: ModelGroup[] = [
{
provider: "Claude",
models: [
{ id: "claude-sonnet-4", label: "Sonnet 4" },
{ id: "claude-opus-4", label: "Opus 4" },
],
},
{
provider: "OpenAI",
models: [
{ id: "gpt-5.3-codex", label: "GPT-5.3 Codex" },
{ id: "gpt-5.4", label: "GPT-5.4" },
{ id: "gpt-5.2-codex", label: "GPT-5.2 Codex" },
{ id: "gpt-5.1-codex-max", label: "GPT-5.1 Codex Max" },
{ id: "gpt-5.2", label: "GPT-5.2" },
{ id: "gpt-5.1-codex-mini", label: "GPT-5.1 Codex Mini" },
],
},
];
export const MODEL_GROUPS: ModelGroup[] = SharedModelGroups;
export const DEFAULT_MODEL_ID: ModelId = DEFAULT_WORKSPACE_MODEL_ID;
const MOCK_REPLIES = [
"Got it. I'll work on that now. Let me start by examining the relevant files...",
@ -73,15 +61,11 @@ export function formatMessageDuration(durationMs: number): string {
}
export function modelLabel(id: ModelId): string {
const group = MODEL_GROUPS.find((candidate) => candidate.models.some((model) => model.id === id));
const model = group?.models.find((candidate) => candidate.id === id);
return model && group ? `${group.provider} ${model.label}` : id;
return sharedWorkspaceModelLabel(id, MODEL_GROUPS);
}
export function providerAgent(provider: string): AgentKind {
if (provider === "Claude") return "Claude";
if (provider === "OpenAI") return "Codex";
return "Cursor";
return sharedWorkspaceProviderAgent(provider);
}
export function slugify(text: string): string {
@ -204,6 +188,28 @@ export function buildHistoryEvents(sessions: AgentSession[]): HistoryEvent[] {
.sort((left, right) => messageOrder(left.messageId) - messageOrder(right.messageId));
}
function buildPullRequestSummary(params: {
number: number;
title: string;
branch: string;
repoName: string;
updatedAtMs: number;
status: "ready" | "draft";
}) {
return {
number: params.number,
title: params.title,
state: "open",
url: `https://github.com/${params.repoName}/pull/${params.number}`,
headRefName: params.branch,
baseRefName: "main",
repoFullName: params.repoName,
authorLogin: "mock",
isDraft: params.status === "draft",
updatedAtMs: params.updatedAtMs,
};
}
function transcriptFromLegacyMessages(sessionId: string, messages: LegacyMessage[]): TranscriptEvent[] {
return messages.map((message, index) => ({
id: message.id,
@ -315,14 +321,21 @@ export function buildInitialTasks(): Task[] {
repoName: "rivet-dev/sandbox-agent",
updatedAtMs: minutesAgo(8),
branch: "NathanFlurry/pi-bootstrap-fix",
pullRequest: { number: 227, status: "ready" },
pullRequest: buildPullRequestSummary({
number: 227,
title: "Normalize Pi ACP bootstrap payloads",
branch: "NathanFlurry/pi-bootstrap-fix",
repoName: "rivet-dev/sandbox-agent",
updatedAtMs: minutesAgo(8),
status: "ready",
}),
sessions: [
{
id: "t1",
sessionId: "t1",
sessionName: "Pi payload fix",
agent: "Claude",
model: "claude-sonnet-4",
model: "sonnet",
status: "idle",
thinkingSinceMs: null,
unread: false,
@ -484,14 +497,21 @@ export function buildInitialTasks(): Task[] {
repoName: "rivet-dev/sandbox-agent",
updatedAtMs: minutesAgo(3),
branch: "feat/builtin-agent-skills",
pullRequest: { number: 223, status: "draft" },
pullRequest: buildPullRequestSummary({
number: 223,
title: "Auto-inject builtin agent skills at startup",
branch: "feat/builtin-agent-skills",
repoName: "rivet-dev/sandbox-agent",
updatedAtMs: minutesAgo(3),
status: "draft",
}),
sessions: [
{
id: "t3",
sessionId: "t3",
sessionName: "Skills injection",
agent: "Claude",
model: "claude-opus-4",
model: "opus",
status: "running",
thinkingSinceMs: NOW_MS - 45_000,
unread: false,
@ -584,14 +604,21 @@ export function buildInitialTasks(): Task[] {
repoName: "rivet-dev/sandbox-agent",
updatedAtMs: minutesAgo(45),
branch: "hooks-example",
pullRequest: { number: 225, status: "ready" },
pullRequest: buildPullRequestSummary({
number: 225,
title: "Add hooks example for Claude, Codex, and OpenCode",
branch: "hooks-example",
repoName: "rivet-dev/sandbox-agent",
updatedAtMs: minutesAgo(45),
status: "ready",
}),
sessions: [
{
id: "t4",
sessionId: "t4",
sessionName: "Example docs",
agent: "Claude",
model: "claude-sonnet-4",
model: "sonnet",
status: "idle",
thinkingSinceMs: null,
unread: false,
@ -659,14 +686,21 @@ export function buildInitialTasks(): Task[] {
repoName: "rivet-dev/rivet",
updatedAtMs: minutesAgo(15),
branch: "actor-reschedule-endpoint",
pullRequest: { number: 4400, status: "ready" },
pullRequest: buildPullRequestSummary({
number: 4400,
title: "Add actor reschedule endpoint",
branch: "actor-reschedule-endpoint",
repoName: "rivet-dev/rivet",
updatedAtMs: minutesAgo(15),
status: "ready",
}),
sessions: [
{
id: "t5",
sessionId: "t5",
sessionName: "Reschedule API",
agent: "Claude",
model: "claude-sonnet-4",
model: "sonnet",
status: "idle",
thinkingSinceMs: null,
unread: false,
@ -793,14 +827,21 @@ export function buildInitialTasks(): Task[] {
repoName: "rivet-dev/rivet",
updatedAtMs: minutesAgo(35),
branch: "feat/dynamic-actors",
pullRequest: { number: 4395, status: "draft" },
pullRequest: buildPullRequestSummary({
number: 4395,
title: "Dynamic actors",
branch: "feat/dynamic-actors",
repoName: "rivet-dev/rivet",
updatedAtMs: minutesAgo(35),
status: "draft",
}),
sessions: [
{
id: "t6",
sessionId: "t6",
sessionName: "Dynamic actors impl",
agent: "Claude",
model: "claude-opus-4",
model: "opus",
status: "idle",
thinkingSinceMs: null,
unread: true,
@ -850,14 +891,21 @@ export function buildInitialTasks(): Task[] {
repoName: "rivet-dev/vbare",
updatedAtMs: minutesAgo(25),
branch: "fix-use-full-cloud-run-pool-name",
pullRequest: { number: 235, status: "ready" },
pullRequest: buildPullRequestSummary({
number: 235,
title: "Use full cloud run pool name for routing",
branch: "fix-use-full-cloud-run-pool-name",
repoName: "rivet-dev/vbare",
updatedAtMs: minutesAgo(25),
status: "ready",
}),
sessions: [
{
id: "t7",
sessionId: "t7",
sessionName: "Pool routing fix",
agent: "Claude",
model: "claude-sonnet-4",
model: "sonnet",
status: "idle",
thinkingSinceMs: null,
unread: false,
@ -959,14 +1007,21 @@ export function buildInitialTasks(): Task[] {
repoName: "rivet-dev/skills",
updatedAtMs: minutesAgo(50),
branch: "fix-guard-support-https-targets",
pullRequest: { number: 125, status: "ready" },
pullRequest: buildPullRequestSummary({
number: 125,
title: "Route compute gateway path correctly",
branch: "fix-guard-support-https-targets",
repoName: "rivet-dev/skills",
updatedAtMs: minutesAgo(50),
status: "ready",
}),
sessions: [
{
id: "t8",
sessionId: "t8",
sessionName: "Guard routing",
agent: "Claude",
model: "claude-sonnet-4",
model: "sonnet",
status: "idle",
thinkingSinceMs: null,
unread: false,
@ -1073,14 +1128,21 @@ export function buildInitialTasks(): Task[] {
repoName: "rivet-dev/skills",
updatedAtMs: minutesAgo(2 * 24 * 60),
branch: "chore-move-compute-gateway-to",
pullRequest: { number: 123, status: "ready" },
pullRequest: buildPullRequestSummary({
number: 123,
title: "Move compute gateway to guard",
branch: "chore-move-compute-gateway-to",
repoName: "rivet-dev/skills",
updatedAtMs: minutesAgo(2 * 24 * 60),
status: "ready",
}),
sessions: [
{
id: "t9",
sessionId: "t9",
sessionName: "Gateway migration",
agent: "Claude",
model: "claude-sonnet-4",
model: "sonnet",
status: "idle",
thinkingSinceMs: null,
unread: false,
@ -1166,8 +1228,6 @@ export function buildInitialTasks(): Task[] {
repoId: "sandbox-agent",
title: "Fix broken auth middleware (error demo)",
status: "error",
runtimeStatus: "error",
statusMessage: "session:error",
repoName: "rivet-dev/sandbox-agent",
updatedAtMs: minutesAgo(2),
branch: "fix/auth-middleware",
@ -1178,7 +1238,7 @@ export function buildInitialTasks(): Task[] {
sessionId: "status-error-session",
sessionName: "Auth fix",
agent: "Claude",
model: "claude-sonnet-4",
model: "sonnet",
status: "error",
thinkingSinceMs: null,
unread: false,
@ -1197,9 +1257,7 @@ export function buildInitialTasks(): Task[] {
id: "status-provisioning",
repoId: "sandbox-agent",
title: "Add rate limiting to API gateway (provisioning demo)",
status: "new",
runtimeStatus: "init_enqueue_provision",
statusMessage: "Queueing sandbox provisioning.",
status: "init_enqueue_provision",
repoName: "rivet-dev/sandbox-agent",
updatedAtMs: minutesAgo(0),
branch: null,
@ -1211,7 +1269,7 @@ export function buildInitialTasks(): Task[] {
sandboxSessionId: null,
sessionName: "Session 1",
agent: "Claude",
model: "claude-sonnet-4",
model: "sonnet",
status: "pending_provision",
thinkingSinceMs: null,
unread: false,
@ -1259,7 +1317,6 @@ export function buildInitialTasks(): Task[] {
repoId: "sandbox-agent",
title: "Refactor WebSocket handler (running demo)",
status: "running",
runtimeStatus: "running",
repoName: "rivet-dev/sandbox-agent",
updatedAtMs: minutesAgo(1),
branch: "refactor/ws-handler",
@ -1313,45 +1370,9 @@ function repoIdFromFullName(fullName: string): string {
return parts[parts.length - 1] ?? fullName;
}
/**
* Build task entries from open PR fixture data.
* Maps to the backend's PR sync behavior (RepositoryPrSyncActor) where PRs
* appear as first-class sidebar items even without an associated task.
* Each open PR gets a lightweight task entry so it shows in the sidebar.
*/
function buildPrTasks(): Task[] {
// Collect branch names already claimed by hand-written tasks so we don't duplicate
const existingBranches = new Set(
buildInitialTasks()
.map((t) => t.branch)
.filter(Boolean),
);
return rivetDevFixture.openPullRequests
.filter((pr) => !existingBranches.has(pr.headRefName))
.map((pr) => {
const repoId = repoIdFromFullName(pr.repoFullName);
return {
id: `pr-${repoId}-${pr.number}`,
repoId,
title: pr.title,
status: "idle" as const,
repoName: pr.repoFullName,
updatedAtMs: new Date(pr.updatedAt).getTime(),
branch: pr.headRefName,
pullRequest: { number: pr.number, status: pr.draft ? ("draft" as const) : ("ready" as const) },
sessions: [],
fileChanges: [],
diffs: {},
fileTree: [],
minutesUsed: 0,
};
});
}
export function buildInitialMockLayoutViewModel(): TaskWorkspaceSnapshot {
const repos = buildMockRepos();
const tasks = [...buildInitialTasks(), ...buildPrTasks()];
const tasks = buildInitialTasks();
return {
organizationId: "default",
repos,

View file

@ -80,9 +80,10 @@ function parseHistoryPayload(event: HistoryEvent): Record<string, unknown> {
}
}
async function debugDump(client: ReturnType<typeof createBackendClient>, organizationId: string, taskId: string): Promise<string> {
async function debugDump(client: ReturnType<typeof createBackendClient>, organizationId: string, repoId: string, taskId: string): Promise<string> {
try {
const task = await client.getTask(organizationId, taskId);
const task = await client.getTask(organizationId, repoId, taskId);
const detail = await client.getTaskDetail(organizationId, repoId, taskId).catch(() => null);
const history = await client.listHistory({ organizationId, taskId, limit: 80 }).catch(() => []);
const historySummary = history
.slice(0, 20)
@ -90,10 +91,11 @@ async function debugDump(client: ReturnType<typeof createBackendClient>, organiz
.join("\n");
let sessionEventsSummary = "";
if (task.activeSandboxId && task.activeSessionId) {
const activeSessionId = detail?.activeSessionId ?? null;
if (task.activeSandboxId && activeSessionId) {
const events = await client
.listSandboxSessionEvents(organizationId, task.sandboxProviderId, task.activeSandboxId, {
sessionId: task.activeSessionId,
sessionId: activeSessionId,
limit: 50,
})
.then((r) => r.items)
@ -109,13 +111,11 @@ async function debugDump(client: ReturnType<typeof createBackendClient>, organiz
JSON.stringify(
{
status: task.status,
statusMessage: task.statusMessage,
title: task.title,
branchName: task.branchName,
activeSandboxId: task.activeSandboxId,
activeSessionId: task.activeSessionId,
prUrl: task.prUrl,
prSubmitted: task.prSubmitted,
activeSessionId,
pullRequestUrl: detail?.pullRequest?.url ?? null,
},
null,
2,
@ -189,7 +189,7 @@ describe("e2e: backend -> sandbox-agent -> git -> PR", () => {
// Cold local sandbox startup can exceed a few minutes on first run.
8 * 60_000,
1_000,
async () => client.getTask(organizationId, created.taskId),
async () => client.getTask(organizationId, repo.repoId, created.taskId),
(h) => Boolean(h.title && h.branchName && h.activeSandboxId),
(h) => {
if (h.status !== lastStatus) {
@ -200,18 +200,18 @@ describe("e2e: backend -> sandbox-agent -> git -> PR", () => {
}
},
).catch(async (err) => {
const dump = await debugDump(client, organizationId, created.taskId);
const dump = await debugDump(client, organizationId, repo.repoId, created.taskId);
throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`);
});
branchName = namedAndProvisioned.branchName!;
sandboxId = namedAndProvisioned.activeSandboxId!;
const withSession = await poll<TaskRecord>(
const withSession = await poll<Awaited<ReturnType<typeof client.getTaskDetail>>>(
"task to create active session",
3 * 60_000,
1_500,
async () => client.getTask(organizationId, created.taskId),
async () => client.getTaskDetail(organizationId, repo.repoId, created.taskId),
(h) => Boolean(h.activeSessionId),
(h) => {
if (h.status === "error") {
@ -219,7 +219,7 @@ describe("e2e: backend -> sandbox-agent -> git -> PR", () => {
}
},
).catch(async (err) => {
const dump = await debugDump(client, organizationId, created.taskId);
const dump = await debugDump(client, organizationId, repo.repoId, created.taskId);
throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`);
});
@ -231,14 +231,14 @@ describe("e2e: backend -> sandbox-agent -> git -> PR", () => {
2_000,
async () =>
(
await client.listSandboxSessionEvents(organizationId, withSession.sandboxProviderId, sandboxId!, {
await client.listSandboxSessionEvents(organizationId, namedAndProvisioned.sandboxProviderId, sandboxId!, {
sessionId: sessionId!,
limit: 40,
})
).items,
(events) => events.length > 0,
).catch(async (err) => {
const dump = await debugDump(client, organizationId, created.taskId);
const dump = await debugDump(client, organizationId, repo.repoId, created.taskId);
throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`);
});
@ -246,7 +246,7 @@ describe("e2e: backend -> sandbox-agent -> git -> PR", () => {
"task to reach idle state",
8 * 60_000,
2_000,
async () => client.getTask(organizationId, created.taskId),
async () => client.getTask(organizationId, repo.repoId, created.taskId),
(h) => h.status === "idle",
(h) => {
if (h.status === "error") {
@ -254,7 +254,7 @@ describe("e2e: backend -> sandbox-agent -> git -> PR", () => {
}
},
).catch(async (err) => {
const dump = await debugDump(client, organizationId, created.taskId);
const dump = await debugDump(client, organizationId, repo.repoId, created.taskId);
throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`);
});
@ -266,7 +266,7 @@ describe("e2e: backend -> sandbox-agent -> git -> PR", () => {
(events) => events.some((e) => e.kind === "task.pr_created"),
)
.catch(async (err) => {
const dump = await debugDump(client, organizationId, created.taskId);
const dump = await debugDump(client, organizationId, repo.repoId, created.taskId);
throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`);
})
.then((events) => events.find((e) => e.kind === "task.pr_created")!);
@ -287,16 +287,16 @@ describe("e2e: backend -> sandbox-agent -> git -> PR", () => {
expect(prFiles.some((f) => f.filename === expectedFile)).toBe(true);
// Close the task and assert the sandbox is released (stopped).
await client.runAction(organizationId, created.taskId, "archive");
await client.runAction(organizationId, repo.repoId, created.taskId, "archive");
await poll<TaskRecord>(
await poll<Awaited<ReturnType<typeof client.getTaskDetail>>>(
"task to become archived (session released)",
60_000,
1_000,
async () => client.getTask(organizationId, created.taskId),
async () => client.getTaskDetail(organizationId, repo.repoId, created.taskId),
(h) => h.status === "archived" && h.activeSessionId === null,
).catch(async (err) => {
const dump = await debugDump(client, organizationId, created.taskId);
const dump = await debugDump(client, organizationId, repo.repoId, created.taskId);
throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`);
});
@ -311,7 +311,7 @@ describe("e2e: backend -> sandbox-agent -> git -> PR", () => {
return st.includes("destroyed") || st.includes("stopped") || st.includes("suspended") || st.includes("paused");
},
).catch(async (err) => {
const dump = await debugDump(client, organizationId, created.taskId);
const dump = await debugDump(client, organizationId, repo.repoId, created.taskId);
const state = await client.sandboxProviderState(organizationId, "local", sandboxId!).catch(() => null);
throw new Error(`${err instanceof Error ? err.message : String(err)}\n` + `sandbox state: ${state ? state.state : "unknown"}\n` + `${dump}`);
});

View file

@ -15,19 +15,7 @@ function requiredEnv(name: string): string {
function workspaceModelEnv(name: string, fallback: WorkspaceModelId): WorkspaceModelId {
const value = process.env[name]?.trim();
switch (value) {
case "claude-sonnet-4":
case "claude-opus-4":
case "gpt-5.3-codex":
case "gpt-5.4":
case "gpt-5.2-codex":
case "gpt-5.1-codex-max":
case "gpt-5.2":
case "gpt-5.1-codex-mini":
return value;
default:
return fallback;
}
return value && value.length > 0 ? value : fallback;
}
async function sleep(ms: number): Promise<void> {

View file

@ -28,19 +28,7 @@ function requiredEnv(name: string): string {
function workspaceModelEnv(name: string, fallback: WorkspaceModelId): WorkspaceModelId {
const value = process.env[name]?.trim();
switch (value) {
case "claude-sonnet-4":
case "claude-opus-4":
case "gpt-5.3-codex":
case "gpt-5.4":
case "gpt-5.2-codex":
case "gpt-5.1-codex-max":
case "gpt-5.2":
case "gpt-5.1-codex-mini":
return value;
default:
return fallback;
}
return value && value.length > 0 ? value : fallback;
}
function intEnv(name: string, fallback: number): number {

View file

@ -50,6 +50,20 @@ class FakeActorConn implements ActorConn {
function organizationSnapshot(): OrganizationSummarySnapshot {
return {
organizationId: "org-1",
github: {
connectedAccount: "octocat",
installationStatus: "connected",
syncStatus: "synced",
importedRepoCount: 1,
lastSyncLabel: "Synced just now",
lastSyncAt: 10,
lastWebhookAt: null,
lastWebhookEvent: "",
syncGeneration: 1,
syncPhase: null,
processedRepositoryCount: 1,
totalRepositoryCount: 1,
},
repos: [{ id: "repo-1", label: "repo-1", taskCount: 1, latestActivityMs: 10 }],
taskSummaries: [
{
@ -61,10 +75,10 @@ function organizationSnapshot(): OrganizationSummarySnapshot {
updatedAtMs: 10,
branch: "main",
pullRequest: null,
activeSessionId: null,
sessionsSummary: [],
},
],
openPullRequests: [],
};
}
@ -118,6 +132,20 @@ describe("RemoteSubscriptionManager", () => {
type: "organizationUpdated",
snapshot: {
organizationId: "org-1",
github: {
connectedAccount: "octocat",
installationStatus: "connected",
syncStatus: "syncing",
importedRepoCount: 1,
lastSyncLabel: "Syncing repositories...",
lastSyncAt: 10,
lastWebhookAt: null,
lastWebhookEvent: "",
syncGeneration: 2,
syncPhase: "syncing_branches",
processedRepositoryCount: 1,
totalRepositoryCount: 3,
},
repos: [],
taskSummaries: [
{
@ -129,10 +157,10 @@ describe("RemoteSubscriptionManager", () => {
updatedAtMs: 20,
branch: "feature/live",
pullRequest: null,
activeSessionId: null,
sessionsSummary: [],
},
],
openPullRequests: [],
},
} satisfies OrganizationEvent);

View file

@ -12,9 +12,8 @@ const sample: TaskRecord = {
task: "Do test",
sandboxProviderId: "local",
status: "running",
statusMessage: null,
activeSandboxId: "sandbox-1",
activeSessionId: "session-1",
pullRequest: null,
sandboxes: [
{
sandboxId: "sandbox-1",
@ -26,17 +25,6 @@ const sample: TaskRecord = {
updatedAt: 1,
},
],
agentType: null,
prSubmitted: false,
diffStat: null,
prUrl: null,
prAuthor: null,
ciStatus: null,
reviewStatus: null,
reviewer: null,
conflictsWithMain: null,
hasUnpushed: null,
parentBranch: null,
createdAt: 1,
updatedAt: 1,
};

View file

@ -6,7 +6,6 @@ import { subscriptionManager } from "../lib/subscription";
import type {
FoundryAppSnapshot,
FoundryOrganization,
TaskStatus,
TaskWorkspaceSnapshot,
WorkspaceSandboxSummary,
WorkspaceSessionSummary,
@ -28,8 +27,6 @@ export interface DevPanelFocusedTask {
repoId: string;
title: string | null;
status: WorkspaceTaskStatus;
runtimeStatus?: TaskStatus | null;
statusMessage?: string | null;
branch?: string | null;
activeSandboxId?: string | null;
activeSessionId?: string | null;
@ -80,7 +77,7 @@ function timeAgo(ts: number | null): string {
}
function statusColor(status: string, t: ReturnType<typeof useFoundryTokens>): string {
if (status === "new" || status.startsWith("init_") || status.startsWith("archive_") || status.startsWith("kill_") || status.startsWith("pending_")) {
if (status.startsWith("init_") || status.startsWith("archive_") || status.startsWith("kill_") || status.startsWith("pending_")) {
return t.statusWarning;
}
switch (status) {
@ -159,14 +156,16 @@ export const DevPanel = memo(function DevPanel({ organizationId, snapshot, organ
}, [now]);
const appState = useSubscription(subscriptionManager, "app", {});
const organizationState = useSubscription(subscriptionManager, "organization", { organizationId });
const appSnapshot: FoundryAppSnapshot | null = appState.data ?? null;
const liveGithub = organizationState.data?.github ?? organization?.github ?? null;
const repos = snapshot.repos ?? [];
const tasks = snapshot.tasks ?? [];
const prCount = tasks.filter((task) => task.pullRequest != null).length;
const focusedTaskStatus = focusedTask?.runtimeStatus ?? focusedTask?.status ?? null;
const focusedTaskState = describeTaskState(focusedTaskStatus, focusedTask?.statusMessage ?? null);
const lastWebhookAt = organization?.github.lastWebhookAt ?? null;
const focusedTaskStatus = focusedTask?.status ?? null;
const focusedTaskState = describeTaskState(focusedTaskStatus);
const lastWebhookAt = liveGithub?.lastWebhookAt ?? null;
const hasRecentWebhook = lastWebhookAt != null && now - lastWebhookAt < 5 * 60_000;
const totalOrgs = appSnapshot?.organizations.length ?? 0;
const authStatus = appSnapshot?.auth.status ?? "unknown";
@ -442,7 +441,7 @@ export const DevPanel = memo(function DevPanel({ organizationId, snapshot, organ
{/* GitHub */}
<Section label="GitHub" t={t} css={css}>
{organization ? (
{liveGithub ? (
<div className={css({ display: "flex", flexDirection: "column", gap: "3px", fontSize: "10px" })}>
<div className={css({ display: "flex", alignItems: "center", gap: "6px" })}>
<span
@ -450,13 +449,13 @@ export const DevPanel = memo(function DevPanel({ organizationId, snapshot, organ
width: "5px",
height: "5px",
borderRadius: "50%",
backgroundColor: installStatusColor(organization.github.installationStatus, t),
backgroundColor: installStatusColor(liveGithub.installationStatus, t),
flexShrink: 0,
})}
/>
<span className={css({ color: t.textPrimary, flex: 1 })}>App Install</span>
<span className={`${mono} ${css({ color: installStatusColor(organization.github.installationStatus, t) })}`}>
{organization.github.installationStatus.replace(/_/g, " ")}
<span className={`${mono} ${css({ color: installStatusColor(liveGithub.installationStatus, t) })}`}>
{liveGithub.installationStatus.replace(/_/g, " ")}
</span>
</div>
<div className={css({ display: "flex", alignItems: "center", gap: "6px" })}>
@ -465,14 +464,14 @@ export const DevPanel = memo(function DevPanel({ organizationId, snapshot, organ
width: "5px",
height: "5px",
borderRadius: "50%",
backgroundColor: syncStatusColor(organization.github.syncStatus, t),
backgroundColor: syncStatusColor(liveGithub.syncStatus, t),
flexShrink: 0,
})}
/>
<span className={css({ color: t.textPrimary, flex: 1 })}>Sync</span>
<span className={`${mono} ${css({ color: syncStatusColor(organization.github.syncStatus, t) })}`}>{organization.github.syncStatus}</span>
{organization.github.lastSyncAt != null && (
<span className={`${mono} ${css({ color: t.textTertiary })}`}>{timeAgo(organization.github.lastSyncAt)}</span>
<span className={`${mono} ${css({ color: syncStatusColor(liveGithub.syncStatus, t) })}`}>{liveGithub.syncStatus}</span>
{liveGithub.lastSyncAt != null && (
<span className={`${mono} ${css({ color: t.textTertiary })}`}>{timeAgo(liveGithub.lastSyncAt)}</span>
)}
</div>
<div className={css({ display: "flex", alignItems: "center", gap: "6px" })}>
@ -488,21 +487,27 @@ export const DevPanel = memo(function DevPanel({ organizationId, snapshot, organ
<span className={css({ color: t.textPrimary, flex: 1 })}>Webhook</span>
{lastWebhookAt != null ? (
<span className={`${mono} ${css({ color: hasRecentWebhook ? t.textPrimary : t.textMuted })}`}>
{organization.github.lastWebhookEvent} · {timeAgo(lastWebhookAt)}
{liveGithub.lastWebhookEvent} · {timeAgo(lastWebhookAt)}
</span>
) : (
<span className={`${mono} ${css({ color: t.statusWarning })}`}>never received</span>
)}
</div>
<div className={css({ display: "flex", gap: "10px", marginTop: "2px" })}>
<Stat label="imported" value={organization.github.importedRepoCount} t={t} css={css} />
<Stat label="catalog" value={organization.repoCatalog.length} t={t} css={css} />
<Stat label="imported" value={liveGithub.importedRepoCount} t={t} css={css} />
<Stat label="catalog" value={organization?.repoCatalog.length ?? repos.length} t={t} css={css} />
<Stat label="target" value={liveGithub.totalRepositoryCount} t={t} css={css} />
</div>
{organization.github.connectedAccount && (
<div className={`${mono} ${css({ color: t.textMuted, marginTop: "1px" })}`}>@{organization.github.connectedAccount}</div>
{liveGithub.connectedAccount && (
<div className={`${mono} ${css({ color: t.textMuted, marginTop: "1px" })}`}>@{liveGithub.connectedAccount}</div>
)}
{organization.github.lastSyncLabel && (
<div className={`${mono} ${css({ color: t.textMuted })}`}>last sync: {organization.github.lastSyncLabel}</div>
{liveGithub.lastSyncLabel && (
<div className={`${mono} ${css({ color: t.textMuted })}`}>last sync: {liveGithub.lastSyncLabel}</div>
)}
{liveGithub.syncPhase && (
<div className={`${mono} ${css({ color: t.textTertiary })}`}>
phase: {liveGithub.syncPhase.replace(/^syncing_/, "").replace(/_/g, " ")} ({liveGithub.processedRepositoryCount}/{liveGithub.totalRepositoryCount})
</div>
)}
</div>
) : (

View file

@ -1,11 +1,14 @@
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { useStyletron } from "baseui";
import {
DEFAULT_WORKSPACE_MODEL_GROUPS,
DEFAULT_WORKSPACE_MODEL_ID,
createErrorContext,
type FoundryOrganization,
type TaskWorkspaceSnapshot,
type WorkspaceOpenPrSummary,
type WorkspaceModelGroup,
type WorkspaceSessionSummary,
type WorkspaceTaskDetail,
type WorkspaceTaskSummary,
@ -77,29 +80,35 @@ function sanitizeActiveSessionId(task: Task, sessionId: string | null | undefine
return openDiffs.length > 0 ? diffTabId(openDiffs[openDiffs.length - 1]!) : lastAgentSessionId;
}
function githubInstallationWarningTitle(organization: FoundryOrganization): string {
return organization.github.installationStatus === "install_required" ? "GitHub App not installed" : "GitHub App needs reconnection";
type GithubStatusView = Pick<FoundryOrganization["github"], "connectedAccount" | "installationStatus" | "syncStatus" | "importedRepoCount" | "lastSyncLabel"> & {
syncPhase?: string | null;
processedRepositoryCount?: number;
totalRepositoryCount?: number;
};
function githubInstallationWarningTitle(github: GithubStatusView): string {
return github.installationStatus === "install_required" ? "GitHub App not installed" : "GitHub App needs reconnection";
}
function githubInstallationWarningDetail(organization: FoundryOrganization): string {
const statusDetail = organization.github.lastSyncLabel.trim();
function githubInstallationWarningDetail(github: GithubStatusView): string {
const statusDetail = github.lastSyncLabel.trim();
const requirementDetail =
organization.github.installationStatus === "install_required"
github.installationStatus === "install_required"
? "Webhooks are required for Foundry to function. Repo sync and PR updates will not work until the GitHub App is installed for this organization."
: "Webhook delivery is unavailable. Repo sync and PR updates will not work until the GitHub App is reconnected.";
return statusDetail ? `${requirementDetail} ${statusDetail}.` : requirementDetail;
}
function GithubInstallationWarning({
organization,
github,
css,
t,
}: {
organization: FoundryOrganization;
github: GithubStatusView;
css: ReturnType<typeof useStyletron>[0];
t: ReturnType<typeof useFoundryTokens>;
}) {
if (organization.github.installationStatus === "connected") {
if (github.installationStatus === "connected") {
return null;
}
@ -123,8 +132,8 @@ function GithubInstallationWarning({
>
<CircleAlert size={15} color={t.statusError} />
<div className={css({ display: "flex", flexDirection: "column", gap: "3px" })}>
<div className={css({ fontSize: "11px", fontWeight: 600, color: t.textPrimary })}>{githubInstallationWarningTitle(organization)}</div>
<div className={css({ fontSize: "11px", lineHeight: 1.45, color: t.textMuted })}>{githubInstallationWarningDetail(organization)}</div>
<div className={css({ fontSize: "11px", fontWeight: 600, color: t.textPrimary })}>{githubInstallationWarningTitle(github)}</div>
<div className={css({ fontSize: "11px", lineHeight: 1.45, color: t.textMuted })}>{githubInstallationWarningDetail(github)}</div>
</div>
</div>
);
@ -164,13 +173,12 @@ function toTaskModel(
id: summary.id,
repoId: summary.repoId,
title: detail?.title ?? summary.title,
status: detail?.runtimeStatus ?? detail?.status ?? summary.status,
runtimeStatus: detail?.runtimeStatus,
statusMessage: detail?.statusMessage ?? null,
status: detail?.status ?? summary.status,
repoName: detail?.repoName ?? summary.repoName,
updatedAtMs: detail?.updatedAtMs ?? summary.updatedAtMs,
branch: detail?.branch ?? summary.branch,
pullRequest: detail?.pullRequest ?? summary.pullRequest,
activeSessionId: detail?.activeSessionId ?? summary.activeSessionId ?? null,
sessions: sessions.map((session) => toSessionModel(session, sessionCache?.get(session.id))),
fileChanges: detail?.fileChanges ?? [],
diffs: detail?.diffs ?? {},
@ -180,40 +188,6 @@ function toTaskModel(
};
}
const OPEN_PR_TASK_PREFIX = "pr:";
function openPrTaskId(prId: string): string {
return `${OPEN_PR_TASK_PREFIX}${prId}`;
}
function isOpenPrTaskId(taskId: string): boolean {
return taskId.startsWith(OPEN_PR_TASK_PREFIX);
}
function toOpenPrTaskModel(pullRequest: WorkspaceOpenPrSummary): Task {
return {
id: openPrTaskId(pullRequest.prId),
repoId: pullRequest.repoId,
title: pullRequest.title,
status: "new",
runtimeStatus: undefined,
statusMessage: pullRequest.authorLogin ? `@${pullRequest.authorLogin}` : null,
repoName: pullRequest.repoFullName,
updatedAtMs: pullRequest.updatedAtMs,
branch: pullRequest.headRefName,
pullRequest: {
number: pullRequest.number,
status: pullRequest.isDraft ? "draft" : "ready",
},
sessions: [],
fileChanges: [],
diffs: {},
fileTree: [],
minutesUsed: 0,
activeSandboxId: null,
};
}
function sessionStateMessage(tab: Task["sessions"][number] | null | undefined): string | null {
if (!tab) {
return null;
@ -258,15 +232,14 @@ interface WorkspaceActions {
updateDraft(input: { repoId: string; taskId: string; sessionId: string; text: string; attachments: LineAttachment[] }): Promise<void>;
sendMessage(input: { repoId: string; taskId: string; sessionId: string; text: string; attachments: LineAttachment[] }): Promise<void>;
stopAgent(input: { repoId: string; taskId: string; sessionId: string }): Promise<void>;
selectSession(input: { repoId: string; taskId: string; sessionId: string }): Promise<void>;
setSessionUnread(input: { repoId: string; taskId: string; sessionId: string; unread: boolean }): Promise<void>;
renameSession(input: { repoId: string; taskId: string; sessionId: string; title: string }): Promise<void>;
closeSession(input: { repoId: string; taskId: string; sessionId: string }): Promise<void>;
addSession(input: { repoId: string; taskId: string; model?: string }): Promise<{ sessionId: string }>;
changeModel(input: { repoId: string; taskId: string; sessionId: string; model: ModelId }): Promise<void>;
adminReloadGithubOrganization(): Promise<void>;
adminReloadGithubPullRequests(): Promise<void>;
adminReloadGithubRepository(repoId: string): Promise<void>;
adminReloadGithubPullRequest(repoId: string, prNumber: number): Promise<void>;
}
const TranscriptPanel = memo(function TranscriptPanel({
@ -287,6 +260,7 @@ const TranscriptPanel = memo(function TranscriptPanel({
rightSidebarCollapsed,
onToggleRightSidebar,
selectedSessionHydrating = false,
modelGroups,
onNavigateToUsage,
}: {
taskWorkspaceClient: WorkspaceActions;
@ -306,13 +280,14 @@ const TranscriptPanel = memo(function TranscriptPanel({
rightSidebarCollapsed?: boolean;
onToggleRightSidebar?: () => void;
selectedSessionHydrating?: boolean;
modelGroups: WorkspaceModelGroup[];
onNavigateToUsage?: () => void;
}) {
const t = useFoundryTokens();
const appSnapshot = useMockAppSnapshot();
const appClient = useMockAppClient();
const currentUser = activeMockUser(appSnapshot);
const defaultModel = currentUser?.defaultModel ?? "claude-sonnet-4";
const defaultModel = currentUser?.defaultModel ?? DEFAULT_WORKSPACE_MODEL_ID;
const [editingField, setEditingField] = useState<"title" | null>(null);
const [editValue, setEditValue] = useState("");
const [editingSessionId, setEditingSessionId] = useState<string | null>(null);
@ -335,9 +310,8 @@ const TranscriptPanel = memo(function TranscriptPanel({
const isTerminal = task.status === "archived";
const historyEvents = useMemo(() => buildHistoryEvents(task.sessions), [task.sessions]);
const activeMessages = useMemo(() => buildDisplayMessages(activeAgentSession), [activeAgentSession]);
const taskRuntimeStatus = task.runtimeStatus ?? task.status;
const taskState = describeTaskState(taskRuntimeStatus, task.statusMessage ?? null);
const taskProvisioning = isProvisioningTaskStatus(taskRuntimeStatus);
const taskState = describeTaskState(task.status);
const taskProvisioning = isProvisioningTaskStatus(task.status);
const taskProvisioningMessage = taskState.detail;
const activeSessionMessage = sessionStateMessage(activeAgentSession);
const showPendingSessionState =
@ -562,6 +536,11 @@ const TranscriptPanel = memo(function TranscriptPanel({
if (!isDiffTab(sessionId)) {
onSetLastAgentSessionId(sessionId);
void taskWorkspaceClient.selectSession({
repoId: task.repoId,
taskId: task.id,
sessionId,
});
const session = task.sessions.find((candidate) => candidate.id === sessionId);
if (session?.unread) {
void taskWorkspaceClient.setSessionUnread({
@ -574,7 +553,7 @@ const TranscriptPanel = memo(function TranscriptPanel({
onSyncRouteSession(task.id, sessionId);
}
},
[task.id, task.sessions, onSetActiveSessionId, onSetLastAgentSessionId, onSyncRouteSession],
[task.id, task.repoId, task.sessions, onSetActiveSessionId, onSetLastAgentSessionId, onSyncRouteSession],
);
const setSessionUnread = useCallback(
@ -963,6 +942,7 @@ const TranscriptPanel = memo(function TranscriptPanel({
textareaRef={textareaRef}
placeholder={!promptSession.created ? "Describe your task..." : "Send a message..."}
attachments={attachments}
modelGroups={modelGroups}
defaultModel={defaultModel}
model={promptSession.model}
isRunning={promptSession.status === "running"}
@ -1298,30 +1278,20 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
updateDraft: (input) => backendClient.updateWorkspaceDraft(organizationId, input),
sendMessage: (input) => backendClient.sendWorkspaceMessage(organizationId, input),
stopAgent: (input) => backendClient.stopWorkspaceSession(organizationId, input),
selectSession: (input) => backendClient.selectWorkspaceSession(organizationId, input),
setSessionUnread: (input) => backendClient.setWorkspaceSessionUnread(organizationId, input),
renameSession: (input) => backendClient.renameWorkspaceSession(organizationId, input),
closeSession: (input) => backendClient.closeWorkspaceSession(organizationId, input),
addSession: (input) => backendClient.createWorkspaceSession(organizationId, input),
changeModel: (input) => backendClient.changeWorkspaceModel(organizationId, input),
adminReloadGithubOrganization: () => backendClient.adminReloadGithubOrganization(organizationId),
adminReloadGithubPullRequests: () => backendClient.adminReloadGithubPullRequests(organizationId),
adminReloadGithubRepository: (repoId) => backendClient.adminReloadGithubRepository(organizationId, repoId),
adminReloadGithubPullRequest: (repoId, prNumber) => backendClient.adminReloadGithubPullRequest(organizationId, repoId, prNumber),
}),
[organizationId],
);
const organizationState = useSubscription(subscriptionManager, "organization", { organizationId });
const organizationRepos = organizationState.data?.repos ?? [];
const taskSummaries = organizationState.data?.taskSummaries ?? [];
const openPullRequests = organizationState.data?.openPullRequests ?? [];
const openPullRequestsByTaskId = useMemo(
() => new Map(openPullRequests.map((pullRequest) => [openPrTaskId(pullRequest.prId), pullRequest])),
[openPullRequests],
);
const selectedOpenPullRequest = useMemo(
() => (selectedTaskId ? (openPullRequestsByTaskId.get(selectedTaskId) ?? null) : null),
[openPullRequestsByTaskId, selectedTaskId],
);
const selectedTaskSummary = useMemo(
() => taskSummaries.find((task) => task.id === selectedTaskId) ?? taskSummaries[0] ?? null,
[selectedTaskId, taskSummaries],
@ -1365,6 +1335,20 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
: null,
);
const hasSandbox = Boolean(activeSandbox) && sandboxState.status !== "error";
const modelGroupsQuery = useQuery({
queryKey: ["mock-layout", "workspace-model-groups", organizationId, activeSandbox?.sandboxProviderId ?? "", activeSandbox?.sandboxId ?? ""],
enabled: Boolean(activeSandbox?.sandboxId),
staleTime: 30_000,
refetchOnWindowFocus: false,
queryFn: async () => {
if (!activeSandbox) {
throw new Error("Cannot load workspace model groups without an active sandbox.");
}
return await backendClient.getSandboxWorkspaceModelGroups(organizationId, activeSandbox.sandboxProviderId, activeSandbox.sandboxId);
},
});
const modelGroups = modelGroupsQuery.data && modelGroupsQuery.data.length > 0 ? modelGroupsQuery.data : DEFAULT_WORKSPACE_MODEL_GROUPS;
const tasks = useMemo(() => {
const sessionCache = new Map<string, { draft: Task["sessions"][number]["draft"]; transcript: Task["sessions"][number]["transcript"] }>();
if (selectedTaskSummary && taskState.data) {
@ -1389,12 +1373,13 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
const hydratedTasks = taskSummaries.map((summary) =>
summary.id === selectedTaskSummary?.id ? toTaskModel(summary, taskState.data, sessionCache) : toTaskModel(summary),
);
const openPrTasks = openPullRequests.map((pullRequest) => toOpenPrTaskModel(pullRequest));
return [...hydratedTasks, ...openPrTasks].sort((left, right) => right.updatedAtMs - left.updatedAtMs);
}, [openPullRequests, selectedTaskSummary, selectedSessionId, sessionState.data, taskState.data, taskSummaries, organizationId]);
return hydratedTasks.sort((left, right) => right.updatedAtMs - left.updatedAtMs);
}, [selectedTaskSummary, selectedSessionId, sessionState.data, taskState.data, taskSummaries, organizationId]);
const rawRepositories = useMemo(() => groupRepositories(organizationRepos, tasks), [tasks, organizationRepos]);
const appSnapshot = useMockAppSnapshot();
const currentUser = activeMockUser(appSnapshot);
const activeOrg = activeMockOrganization(appSnapshot);
const liveGithub = organizationState.data?.github ?? activeOrg?.github ?? null;
const navigateToUsage = useCallback(() => {
if (activeOrg) {
void navigate({ to: "/organizations/$organizationId/billing" as never, params: { organizationId: activeOrg.id } as never });
@ -1419,11 +1404,9 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
const leftWidthRef = useRef(leftWidth);
const rightWidthRef = useRef(rightWidth);
const autoCreatingSessionForTaskRef = useRef<Set<string>>(new Set());
const resolvingOpenPullRequestsRef = useRef<Set<string>>(new Set());
const [leftSidebarOpen, setLeftSidebarOpen] = useState(true);
const [rightSidebarOpen, setRightSidebarOpen] = useState(true);
const [leftSidebarPeeking, setLeftSidebarPeeking] = useState(false);
const [materializingOpenPrId, setMaterializingOpenPrId] = useState<string | null>(null);
const showDevPanel = useDevPanel();
const peekTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@ -1490,80 +1473,17 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
}, []);
const activeTask = useMemo(() => {
const realTasks = tasks.filter((task) => !isOpenPrTaskId(task.id));
if (selectedOpenPullRequest) {
return null;
}
if (selectedTaskId) {
return realTasks.find((task) => task.id === selectedTaskId) ?? realTasks[0] ?? null;
return tasks.find((task) => task.id === selectedTaskId) ?? tasks[0] ?? null;
}
return realTasks[0] ?? null;
}, [selectedOpenPullRequest, selectedTaskId, tasks]);
const materializeOpenPullRequest = useCallback(
async (pullRequest: WorkspaceOpenPrSummary) => {
if (resolvingOpenPullRequestsRef.current.has(pullRequest.prId)) {
return;
}
resolvingOpenPullRequestsRef.current.add(pullRequest.prId);
setMaterializingOpenPrId(pullRequest.prId);
try {
const { taskId, sessionId } = await taskWorkspaceClient.createTask({
repoId: pullRequest.repoId,
task: `Continue work on GitHub PR #${pullRequest.number}: ${pullRequest.title}`,
model: "gpt-5.3-codex",
title: pullRequest.title,
onBranch: pullRequest.headRefName,
});
await navigate({
to: "/organizations/$organizationId/tasks/$taskId",
params: {
organizationId,
taskId,
},
search: { sessionId: sessionId ?? undefined },
replace: true,
});
} catch (error) {
setMaterializingOpenPrId((current) => (current === pullRequest.prId ? null : current));
resolvingOpenPullRequestsRef.current.delete(pullRequest.prId);
logger.error(
{
prId: pullRequest.prId,
repoId: pullRequest.repoId,
branchName: pullRequest.headRefName,
...createErrorContext(error),
},
"failed_to_materialize_open_pull_request_task",
);
}
},
[navigate, taskWorkspaceClient, organizationId],
);
useEffect(() => {
if (!selectedOpenPullRequest) {
if (materializingOpenPrId) {
resolvingOpenPullRequestsRef.current.delete(materializingOpenPrId);
}
setMaterializingOpenPrId(null);
return;
}
void materializeOpenPullRequest(selectedOpenPullRequest);
}, [materializeOpenPullRequest, materializingOpenPrId, selectedOpenPullRequest]);
return tasks[0] ?? null;
}, [selectedTaskId, tasks]);
useEffect(() => {
if (activeTask) {
return;
}
if (selectedOpenPullRequest || materializingOpenPrId) {
return;
}
const fallbackTaskId = tasks[0]?.id;
if (!fallbackTaskId) {
return;
@ -1580,11 +1500,13 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
search: { sessionId: fallbackTask?.sessions[0]?.id ?? undefined },
replace: true,
});
}, [activeTask, materializingOpenPrId, navigate, selectedOpenPullRequest, tasks, organizationId]);
}, [activeTask, navigate, tasks, organizationId]);
const openDiffs = activeTask ? sanitizeOpenDiffs(activeTask, openDiffsByTask[activeTask.id]) : [];
const lastAgentSessionId = activeTask ? sanitizeLastAgentSessionId(activeTask, lastAgentSessionIdByTask[activeTask.id]) : null;
const activeSessionId = activeTask ? sanitizeActiveSessionId(activeTask, activeSessionIdByTask[activeTask.id], openDiffs, lastAgentSessionId) : null;
const activeSessionId = activeTask
? sanitizeActiveSessionId(activeTask, activeSessionIdByTask[activeTask.id] ?? activeTask.activeSessionId ?? null, openDiffs, lastAgentSessionId)
: null;
const selectedSessionHydrating = Boolean(
selectedSessionId && activeSessionId === selectedSessionId && sessionState.status === "loading" && !sessionState.data,
);
@ -1697,7 +1619,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
const { taskId, sessionId } = await taskWorkspaceClient.createTask({
repoId,
task: options?.task ?? "New task",
model: "gpt-5.3-codex",
model: currentUser?.defaultModel ?? DEFAULT_WORKSPACE_MODEL_ID,
title: options?.title ?? "New task",
...(options?.branch ? { branch: options.branch } : {}),
...(options?.onBranch ? { onBranch: options.onBranch } : {}),
@ -1712,7 +1634,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
});
})();
},
[navigate, selectedNewTaskRepoId, taskWorkspaceClient, organizationId],
[currentUser?.defaultModel, navigate, selectedNewTaskRepoId, taskWorkspaceClient, organizationId],
);
const openDiffTab = useCallback(
@ -1741,14 +1663,6 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
const selectTask = useCallback(
(id: string) => {
if (isOpenPrTaskId(id)) {
const pullRequest = openPullRequestsByTaskId.get(id);
if (!pullRequest) {
return;
}
void materializeOpenPullRequest(pullRequest);
return;
}
const task = tasks.find((candidate) => candidate.id === id) ?? null;
void navigate({
to: "/organizations/$organizationId/tasks/$taskId",
@ -1759,7 +1673,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
search: { sessionId: task?.sessions[0]?.id ?? undefined },
});
},
[materializeOpenPullRequest, navigate, openPullRequestsByTaskId, tasks, organizationId],
[navigate, tasks, organizationId],
);
const markTaskUnread = useCallback(
@ -1904,7 +1818,6 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
};
if (!activeTask) {
const isMaterializingSelectedOpenPr = Boolean(selectedOpenPullRequest) || materializingOpenPrId != null;
return (
<>
{dragRegion}
@ -1935,9 +1848,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
taskOrderByRepository={taskOrderByRepository}
onReorderTasks={reorderTasks}
onReloadOrganization={() => void taskWorkspaceClient.adminReloadGithubOrganization()}
onReloadPullRequests={() => void taskWorkspaceClient.adminReloadGithubPullRequests()}
onReloadRepository={(repoId) => void taskWorkspaceClient.adminReloadGithubRepository(repoId)}
onReloadPullRequest={(repoId, prNumber) => void taskWorkspaceClient.adminReloadGithubPullRequest(repoId, prNumber)}
onToggleSidebar={() => setLeftSidebarOpen(false)}
/>
</div>
@ -1979,7 +1890,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
gap: "12px",
}}
>
{activeOrg?.github.syncStatus === "syncing" || activeOrg?.github.syncStatus === "pending" ? (
{liveGithub?.syncStatus === "syncing" || liveGithub?.syncStatus === "pending" ? (
<>
<div
className={css({
@ -2000,19 +1911,18 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
/>
<h2 style={{ margin: 0, fontSize: "20px", fontWeight: 600 }}>Syncing with GitHub</h2>
<p style={{ margin: 0, opacity: 0.75 }}>
Importing repos from @{activeOrg.github.connectedAccount || "GitHub"}...
{activeOrg.github.importedRepoCount > 0 && <> {activeOrg.github.importedRepoCount} repos imported so far.</>}
{liveGithub.lastSyncLabel || `Importing repos from @${liveGithub.connectedAccount || "GitHub"}...`}
{liveGithub.totalRepositoryCount > 0 && (
<>
{" "}
{liveGithub.syncPhase === "syncing_repositories"
? `${liveGithub.importedRepoCount} of ${liveGithub.totalRepositoryCount} repos imported so far.`
: `${liveGithub.processedRepositoryCount} of ${liveGithub.totalRepositoryCount} repos processed in ${liveGithub.syncPhase?.replace(/^syncing_/, "").replace(/_/g, " ") ?? "sync"}.`}
</>
)}
</p>
</>
) : isMaterializingSelectedOpenPr && selectedOpenPullRequest ? (
<>
<SpinnerDot />
<h2 style={{ margin: 0, fontSize: "20px", fontWeight: 600 }}>Creating task from pull request</h2>
<p style={{ margin: 0, opacity: 0.75 }}>
Preparing a task for <strong>{selectedOpenPullRequest.title}</strong> on <strong>{selectedOpenPullRequest.headRefName}</strong>.
</p>
</>
) : activeOrg?.github.syncStatus === "error" ? (
) : liveGithub?.syncStatus === "error" ? (
<>
<h2 style={{ margin: 0, fontSize: "20px", fontWeight: 600, color: t.statusError }}>GitHub sync failed</h2>
<p style={{ margin: 0, opacity: 0.75 }}>There was a problem syncing repos from GitHub. Check the dev panel for details.</p>
@ -2066,7 +1976,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
</div>
</div>
</Shell>
{activeOrg && <GithubInstallationWarning organization={activeOrg} css={css} t={t} />}
{liveGithub && <GithubInstallationWarning github={liveGithub} css={css} t={t} />}
{showDevPanel && (
<DevPanel
organizationId={organizationId}
@ -2109,9 +2019,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
taskOrderByRepository={taskOrderByRepository}
onReorderTasks={reorderTasks}
onReloadOrganization={() => void taskWorkspaceClient.adminReloadGithubOrganization()}
onReloadPullRequests={() => void taskWorkspaceClient.adminReloadGithubPullRequests()}
onReloadRepository={(repoId) => void taskWorkspaceClient.adminReloadGithubRepository(repoId)}
onReloadPullRequest={(repoId, prNumber) => void taskWorkspaceClient.adminReloadGithubPullRequest(repoId, prNumber)}
onToggleSidebar={() => setLeftSidebarOpen(false)}
/>
</div>
@ -2163,9 +2071,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
taskOrderByRepository={taskOrderByRepository}
onReorderTasks={reorderTasks}
onReloadOrganization={() => void taskWorkspaceClient.adminReloadGithubOrganization()}
onReloadPullRequests={() => void taskWorkspaceClient.adminReloadGithubPullRequests()}
onReloadRepository={(repoId) => void taskWorkspaceClient.adminReloadGithubRepository(repoId)}
onReloadPullRequest={(repoId, prNumber) => void taskWorkspaceClient.adminReloadGithubPullRequest(repoId, prNumber)}
onToggleSidebar={() => {
setLeftSidebarPeeking(false);
setLeftSidebarOpen(true);
@ -2181,6 +2087,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
taskWorkspaceClient={taskWorkspaceClient}
task={activeTask}
hasSandbox={hasSandbox}
modelGroups={modelGroups}
activeSessionId={activeSessionId}
lastAgentSessionId={lastAgentSessionId}
openDiffs={openDiffs}
@ -2233,7 +2140,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
</div>
</div>
</div>
{activeOrg && <GithubInstallationWarning organization={activeOrg} css={css} t={t} />}
{liveGithub && <GithubInstallationWarning github={liveGithub} css={css} t={t} />}
{showDevPanel && (
<DevPanel
organizationId={organizationId}
@ -2244,11 +2151,9 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
repoId: activeTask.repoId,
title: activeTask.title,
status: activeTask.status,
runtimeStatus: activeTask.runtimeStatus ?? null,
statusMessage: activeTask.statusMessage ?? null,
branch: activeTask.branch ?? null,
activeSandboxId: activeTask.activeSandboxId ?? null,
activeSessionId: selectedSessionId ?? activeTask.sessions[0]?.id ?? null,
activeSessionId: activeTask.activeSessionId ?? selectedSessionId ?? activeTask.sessions[0]?.id ?? null,
sandboxes: [],
sessions:
activeTask.sessions?.map((tab) => ({

View file

@ -2,18 +2,21 @@ import { memo, useState } from "react";
import { useStyletron } from "baseui";
import { StatefulPopover, PLACEMENT } from "baseui/popover";
import { ChevronUp, Star } from "lucide-react";
import { workspaceModelLabel, type WorkspaceModelGroup } from "@sandbox-agent/foundry-shared";
import { useFoundryTokens } from "../../app/theme";
import { AgentIcon } from "./ui";
import { MODEL_GROUPS, modelLabel, providerAgent, type ModelId } from "./view-model";
import { type ModelId } from "./view-model";
const ModelPickerContent = memo(function ModelPickerContent({
groups,
value,
defaultModel,
onChange,
onSetDefault,
close,
}: {
groups: WorkspaceModelGroup[];
value: ModelId;
defaultModel: ModelId;
onChange: (id: ModelId) => void;
@ -26,7 +29,7 @@ const ModelPickerContent = memo(function ModelPickerContent({
return (
<div className={css({ minWidth: "220px", padding: "6px 0" })}>
{MODEL_GROUPS.map((group) => (
{groups.map((group) => (
<div key={group.provider}>
<div
className={css({
@ -44,7 +47,7 @@ const ModelPickerContent = memo(function ModelPickerContent({
const isActive = model.id === value;
const isDefault = model.id === defaultModel;
const isHovered = model.id === hoveredId;
const agent = providerAgent(group.provider);
const agent = group.agentKind;
return (
<div
@ -94,11 +97,13 @@ const ModelPickerContent = memo(function ModelPickerContent({
});
export const ModelPicker = memo(function ModelPicker({
groups,
value,
defaultModel,
onChange,
onSetDefault,
}: {
groups: WorkspaceModelGroup[];
value: ModelId;
defaultModel: ModelId;
onChange: (id: ModelId) => void;
@ -137,7 +142,9 @@ export const ModelPicker = memo(function ModelPicker({
},
},
}}
content={({ close }) => <ModelPickerContent value={value} defaultModel={defaultModel} onChange={onChange} onSetDefault={onSetDefault} close={close} />}
content={({ close }) => (
<ModelPickerContent groups={groups} value={value} defaultModel={defaultModel} onChange={onChange} onSetDefault={onSetDefault} close={close} />
)}
>
<div className={css({ display: "inline-flex" })}>
<button
@ -162,7 +169,7 @@ export const ModelPicker = memo(function ModelPicker({
":hover": { color: t.textSecondary, backgroundColor: t.interactiveHover },
})}
>
{modelLabel(value)}
{workspaceModelLabel(value, groups)}
{(isHovered || isOpen) && <ChevronUp size={11} />}
</button>
</div>

View file

@ -2,6 +2,7 @@ import { memo, type Ref } from "react";
import { useStyletron } from "baseui";
import { ChatComposer, type ChatComposerClassNames } from "@sandbox-agent/react";
import { FileCode, SendHorizonal, Square, X } from "lucide-react";
import { type WorkspaceModelGroup } from "@sandbox-agent/foundry-shared";
import { useFoundryTokens } from "../../app/theme";
import { ModelPicker } from "./model-picker";
@ -13,6 +14,7 @@ export const PromptComposer = memo(function PromptComposer({
textareaRef,
placeholder,
attachments,
modelGroups,
defaultModel,
model,
isRunning,
@ -27,6 +29,7 @@ export const PromptComposer = memo(function PromptComposer({
textareaRef: Ref<HTMLTextAreaElement>;
placeholder: string;
attachments: LineAttachment[];
modelGroups: WorkspaceModelGroup[];
defaultModel: ModelId;
model: ModelId;
isRunning: boolean;
@ -172,7 +175,7 @@ export const PromptComposer = memo(function PromptComposer({
renderSubmitContent={() => (isRunning ? <Square size={16} style={{ display: "block" }} /> : <SendHorizonal size={16} style={{ display: "block" }} />)}
renderFooter={() => (
<div className={css({ padding: "0 10px 8px" })}>
<ModelPicker value={model} defaultModel={defaultModel} onChange={onChangeModel} onSetDefault={onSetDefaultModel} />
<ModelPicker groups={modelGroups} value={model} defaultModel={defaultModel} onChange={onChangeModel} onSetDefault={onSetDefaultModel} />
</div>
)}
/>

View file

@ -125,7 +125,7 @@ export const RightSidebar = memo(function RightSidebar({
});
observer.observe(node);
}, []);
const pullRequestUrl = task.pullRequest != null ? `https://github.com/${task.repoName}/pull/${task.pullRequest.number}` : null;
const pullRequestUrl = task.pullRequest?.url ?? null;
const copyFilePath = useCallback(async (path: string) => {
try {

View file

@ -54,10 +54,6 @@ function repositoryIconColor(label: string): string {
return REPOSITORY_COLORS[Math.abs(hash) % REPOSITORY_COLORS.length]!;
}
function isPullRequestSidebarItem(task: Task): boolean {
return task.id.startsWith("pr:");
}
export const Sidebar = memo(function Sidebar({
repositories,
newTaskRepos,
@ -72,9 +68,7 @@ export const Sidebar = memo(function Sidebar({
taskOrderByRepository,
onReorderTasks,
onReloadOrganization,
onReloadPullRequests,
onReloadRepository,
onReloadPullRequest,
onToggleSidebar,
}: {
repositories: RepositorySection[];
@ -90,9 +84,7 @@ export const Sidebar = memo(function Sidebar({
taskOrderByRepository: Record<string, string[]>;
onReorderTasks: (repositoryId: string, fromIndex: number, toIndex: number) => void;
onReloadOrganization: () => void;
onReloadPullRequests: () => void;
onReloadRepository: (repoId: string) => void;
onReloadPullRequest: (repoId: string, prNumber: number) => void;
onToggleSidebar?: () => void;
}) {
const [css] = useStyletron();
@ -444,16 +436,6 @@ export const Sidebar = memo(function Sidebar({
>
Reload organization
</button>
<button
type="button"
onClick={() => {
setHeaderMenuOpen(false);
onReloadPullRequests();
}}
className={css(menuButtonStyle(false, t))}
>
Reload all PRs
</button>
</div>
) : null}
<div
@ -665,15 +647,12 @@ export const Sidebar = memo(function Sidebar({
if (item.type === "task") {
const { repository, task, taskIndex } = item;
const isActive = task.id === activeId;
const isPullRequestItem = isPullRequestSidebarItem(task);
const isRunning = task.sessions.some((s) => s.status === "running");
const isProvisioning =
!isPullRequestItem &&
((String(task.status).startsWith("init_") && task.status !== "init_complete") ||
task.status === "new" ||
task.sessions.some((s) => s.status === "pending_provision" || s.status === "pending_session_create"));
(String(task.status).startsWith("init_") && task.status !== "init_complete") ||
task.sessions.some((s) => s.status === "pending_provision" || s.status === "pending_session_create");
const hasUnread = task.sessions.some((s) => s.unread);
const isDraft = task.pullRequest == null || task.pullRequest.status === "draft";
const isDraft = task.pullRequest?.isDraft ?? true;
const totalAdded = task.fileChanges.reduce((sum, file) => sum + file.added, 0);
const totalRemoved = task.fileChanges.reduce((sum, file) => sum + file.removed, 0);
const hasDiffs = totalAdded > 0 || totalRemoved > 0;
@ -718,17 +697,11 @@ export const Sidebar = memo(function Sidebar({
<div
onClick={() => onSelect(task.id)}
onContextMenu={(event) => {
if (isPullRequestItem && task.pullRequest) {
contextMenu.open(event, [
{ label: "Reload pull request", onClick: () => onReloadPullRequest(task.repoId, task.pullRequest!.number) },
{ label: "Create task", onClick: () => onSelect(task.id) },
]);
return;
}
contextMenu.open(event, [
const items = [
{ label: "Rename task", onClick: () => onRenameTask(task.id) },
{ label: "Mark as unread", onClick: () => onMarkUnread(task.id) },
]);
];
contextMenu.open(event, items);
}}
className={css({
padding: "8px 12px",
@ -753,11 +726,7 @@ export const Sidebar = memo(function Sidebar({
flexShrink: 0,
})}
>
{isPullRequestItem ? (
<GitPullRequestDraft size={13} color={isDraft ? t.accent : t.textSecondary} />
) : (
<TaskIndicator isRunning={isRunning} isProvisioning={isProvisioning} hasUnread={hasUnread} isDraft={isDraft} />
)}
<TaskIndicator isRunning={isRunning} isProvisioning={isProvisioning} hasUnread={hasUnread} isDraft={isDraft} />
</div>
<div className={css({ minWidth: 0, flex: 1, display: "flex", flexDirection: "column", gap: "1px" })}>
<LabelSmall
@ -773,18 +742,13 @@ export const Sidebar = memo(function Sidebar({
>
{task.title}
</LabelSmall>
{isPullRequestItem && task.statusMessage ? (
<LabelXSmall color={t.textTertiary} $style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{task.statusMessage}
</LabelXSmall>
) : null}
</div>
{task.pullRequest != null ? (
<span className={css({ display: "inline-flex", alignItems: "center", gap: "4px", flexShrink: 0 })}>
<LabelXSmall color={t.textSecondary} $style={{ fontWeight: 600 }}>
#{task.pullRequest.number}
</LabelXSmall>
{task.pullRequest.status === "draft" ? <CloudUpload size={11} color={t.accent} /> : null}
{task.pullRequest.isDraft ? <CloudUpload size={11} color={t.accent} /> : null}
</span>
) : (
<GitPullRequestDraft size={11} color={t.textTertiary} />

View file

@ -49,10 +49,9 @@ export const TranscriptHeader = memo(function TranscriptHeader({
const t = useFoundryTokens();
const isDesktop = !!import.meta.env.VITE_DESKTOP;
const needsTrafficLightInset = isDesktop && sidebarCollapsed;
const taskStatus = task.runtimeStatus ?? task.status;
const headerStatus = useMemo(
() => deriveHeaderStatus(taskStatus, task.statusMessage ?? null, activeSession?.status ?? null, activeSession?.errorMessage ?? null, hasSandbox),
[taskStatus, task.statusMessage, activeSession?.status, activeSession?.errorMessage, hasSandbox],
() => deriveHeaderStatus(task.status, activeSession?.status ?? null, activeSession?.errorMessage ?? null, hasSandbox),
[task.status, activeSession?.status, activeSession?.errorMessage, hasSandbox],
);
return (

View file

@ -181,6 +181,8 @@ export const AgentIcon = memo(function AgentIcon({ agent, size = 14 }: { agent:
return <OpenAIIcon size={size} />;
case "Cursor":
return <CursorIcon size={size} />;
default:
return <CursorIcon size={size} />;
}
});

View file

@ -1,3 +1,8 @@
import {
DEFAULT_WORKSPACE_MODEL_GROUPS as SharedModelGroups,
workspaceModelLabel as sharedWorkspaceModelLabel,
workspaceProviderAgent as sharedWorkspaceProviderAgent,
} from "@sandbox-agent/foundry-shared";
import type {
WorkspaceAgentKind as AgentKind,
WorkspaceSession as AgentSession,
@ -17,26 +22,7 @@ import { extractEventText } from "../../features/sessions/model";
export type { RepositorySection };
export const MODEL_GROUPS: ModelGroup[] = [
{
provider: "Claude",
models: [
{ id: "claude-sonnet-4", label: "Sonnet 4" },
{ id: "claude-opus-4", label: "Opus 4" },
],
},
{
provider: "OpenAI",
models: [
{ id: "gpt-5.3-codex", label: "GPT-5.3 Codex" },
{ id: "gpt-5.4", label: "GPT-5.4" },
{ id: "gpt-5.2-codex", label: "GPT-5.2 Codex" },
{ id: "gpt-5.1-codex-max", label: "GPT-5.1 Codex Max" },
{ id: "gpt-5.2", label: "GPT-5.2" },
{ id: "gpt-5.1-codex-mini", label: "GPT-5.1 Codex Mini" },
],
},
];
export const MODEL_GROUPS: ModelGroup[] = SharedModelGroups;
export function formatRelativeAge(updatedAtMs: number, nowMs = Date.now()): string {
const deltaSeconds = Math.max(0, Math.floor((nowMs - updatedAtMs) / 1000));
@ -94,15 +80,11 @@ export function formatMessageDuration(durationMs: number): string {
}
export function modelLabel(id: ModelId): string {
const group = MODEL_GROUPS.find((candidate) => candidate.models.some((model) => model.id === id));
const model = group?.models.find((candidate) => candidate.id === id);
return model && group ? `${group.provider} ${model.label}` : id;
return sharedWorkspaceModelLabel(id, MODEL_GROUPS);
}
export function providerAgent(provider: string): AgentKind {
if (provider === "Claude") return "Claude";
if (provider === "OpenAI") return "Codex";
return "Cursor";
return sharedWorkspaceProviderAgent(provider);
}
const DIFF_PREFIX = "diff:";

View file

@ -1,5 +1,5 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import type { AgentType, RepoBranchRecord, RepoOverview, TaskWorkspaceSnapshot, WorkspaceTaskStatus } from "@sandbox-agent/foundry-shared";
import type { RepoBranchRecord, RepoOverview, TaskWorkspaceSnapshot, WorkspaceTaskStatus } from "@sandbox-agent/foundry-shared";
import { currentFoundryOrganization, useSubscription } from "@sandbox-agent/foundry-client";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Link, useNavigate } from "@tanstack/react-router";
@ -14,7 +14,6 @@ import { StyledDivider } from "baseui/divider";
import { styled, useStyletron } from "baseui";
import { HeadingSmall, HeadingXSmall, LabelSmall, LabelXSmall, MonoLabelSmall, ParagraphSmall } from "baseui/typography";
import { Bot, CircleAlert, FolderGit2, GitBranch, MessageSquareText, SendHorizontal } from "lucide-react";
import { formatDiffStat } from "../features/tasks/model";
import { deriveHeaderStatus, describeTaskState } from "../features/tasks/status";
import { HeaderStatusPill } from "./mock-layout/ui";
import { buildTranscript, resolveSessionSelection } from "../features/sessions/model";
@ -95,25 +94,13 @@ const FILTER_OPTIONS: SelectItem[] = [
{ id: "all", label: "All Branches" },
];
const AGENT_OPTIONS: SelectItem[] = [
{ id: "codex", label: "codex" },
{ id: "claude", label: "claude" },
];
function statusKind(status: WorkspaceTaskStatus): StatusTagKind {
if (status === "running") return "positive";
if (status === "error") return "negative";
if (status === "new" || String(status).startsWith("init_")) return "warning";
if (String(status).startsWith("init_")) return "warning";
return "neutral";
}
function normalizeAgent(agent: string | null): AgentType | undefined {
if (agent === "claude" || agent === "codex") {
return agent;
}
return undefined;
}
function formatTime(value: number): string {
return new Date(value).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
@ -160,7 +147,7 @@ function repoSummary(overview: RepoOverview | undefined): {
if (row.taskId) {
mapped += 1;
}
if (row.prNumber && row.prState !== "MERGED" && row.prState !== "CLOSED") {
if (row.pullRequest && row.pullRequest.state !== "MERGED" && row.pullRequest.state !== "CLOSED") {
openPrs += 1;
}
}
@ -174,15 +161,25 @@ function repoSummary(overview: RepoOverview | undefined): {
}
function branchKind(row: RepoBranchRecord): StatusTagKind {
if (row.prState === "OPEN" || row.prState === "DRAFT") {
if (row.pullRequest?.isDraft || row.pullRequest?.state === "OPEN") {
return "warning";
}
if (row.prState === "MERGED") {
if (row.pullRequest?.state === "MERGED") {
return "positive";
}
return "neutral";
}
function branchPullRequestLabel(branch: RepoBranchRecord): string {
if (!branch.pullRequest) {
return "no pr";
}
if (branch.pullRequest.isDraft) {
return "draft";
}
return branch.pullRequest.state.toLowerCase();
}
function matchesOverviewFilter(branch: RepoBranchRecord, filter: RepoOverviewFilter): boolean {
if (filter === "archived") {
return branch.taskStatus === "archived";
@ -332,14 +329,6 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
const [createTaskOpen, setCreateTaskOpen] = useState(false);
const [selectedOverviewBranch, setSelectedOverviewBranch] = useState<string | null>(null);
const [overviewFilter, setOverviewFilter] = useState<RepoOverviewFilter>("active");
const [newAgentType, setNewAgentType] = useState<AgentType>(() => {
try {
const raw = globalThis.localStorage?.getItem("hf.settings.agentType");
return raw === "claude" || raw === "codex" ? raw : "codex";
} catch {
return "codex";
}
});
const [createError, setCreateError] = useState<string | null>(null);
const appState = useSubscription(subscriptionManager, "app", {});
@ -383,14 +372,6 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
}
}, [createRepoId, repoOverviewMode, repos, selectedRepoId]);
useEffect(() => {
try {
globalThis.localStorage?.setItem("hf.settings.agentType", newAgentType);
} catch {
// ignore storage failures
}
}, [newAgentType]);
const repoGroups = useMemo(() => {
const byRepo = new Map<string, typeof rows>();
for (const row of rows) {
@ -451,10 +432,10 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
}, [selectedForSession?.id]);
const sessionRows = selectedForSession?.sessionsSummary ?? [];
const taskRuntimeStatus = selectedForSession?.runtimeStatus ?? selectedForSession?.status ?? null;
const taskStatusState = describeTaskState(taskRuntimeStatus, selectedForSession?.statusMessage ?? null);
const taskStatus = selectedForSession?.status ?? null;
const taskStatusState = describeTaskState(taskStatus);
const taskStateSummary = `${taskStatusState.title}. ${taskStatusState.detail}`;
const shouldUseTaskStateEmptyState = Boolean(selectedForSession && taskRuntimeStatus && taskRuntimeStatus !== "running" && taskRuntimeStatus !== "idle");
const shouldUseTaskStateEmptyState = Boolean(selectedForSession && taskStatus && taskStatus !== "running" && taskStatus !== "idle");
const sessionSelection = useMemo(
() =>
resolveSessionSelection({
@ -505,8 +486,6 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
repoId: task.repoId,
title: task.title,
status: task.status,
runtimeStatus: selectedForSession?.runtimeStatus ?? null,
statusMessage: selectedForSession?.statusMessage ?? null,
branch: task.branch ?? null,
activeSandboxId: selectedForSession?.activeSandboxId ?? null,
activeSessionId: selectedForSession?.activeSessionId ?? null,
@ -524,8 +503,6 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
repoId: task.repoId,
title: task.title,
status: task.status,
runtimeStatus: selectedForSession?.id === task.id ? selectedForSession.runtimeStatus : undefined,
statusMessage: selectedForSession?.id === task.id ? selectedForSession.statusMessage : null,
repoName: task.repoName,
updatedAtMs: task.updatedAtMs,
branch: task.branch ?? null,
@ -553,13 +530,15 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
if (!selectedForSession || !activeSandbox?.sandboxId) {
throw new Error("No sandbox is available for this task");
}
const preferredAgent =
selectedSessionSummary?.agent === "Claude" ? "claude" : selectedSessionSummary?.agent === "Codex" ? "codex" : undefined;
return backendClient.createSandboxSession({
organizationId,
sandboxProviderId: activeSandbox.sandboxProviderId,
sandboxId: activeSandbox.sandboxId,
prompt: selectedForSession.task,
cwd: activeSandbox.cwd ?? undefined,
agent: normalizeAgent(selectedForSession.agentType),
agent: preferredAgent,
});
};
@ -616,7 +595,6 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
organizationId,
repoId,
task,
agentType: newAgentType,
explicitTitle: draftTitle || undefined,
explicitBranchName: createOnBranch ? undefined : draftBranchName || undefined,
onBranch: createOnBranch ?? undefined,
@ -656,7 +634,6 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
const repoOptions = useMemo(() => repos.map((repo) => createOption({ id: repo.id, label: repo.label })), [repos]);
const selectedRepoOption = repoOptions.find((option) => option.id === createRepoId) ?? null;
const selectedAgentOption = useMemo(() => createOption(AGENT_OPTIONS.find((option) => option.id === newAgentType) ?? AGENT_OPTIONS[0]!), [newAgentType]);
const selectedFilterOption = useMemo(
() => createOption(FILTER_OPTIONS.find((option) => option.id === overviewFilter) ?? FILTER_OPTIONS[0]!),
[overviewFilter],
@ -1057,23 +1034,23 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
</div>
<div className={cellClass}>{branch.taskTitle ?? branch.taskId ?? "-"}</div>
<div className={cellClass}>
{branch.prNumber ? (
{branch.pullRequest ? (
<a
href={branch.prUrl ?? undefined}
href={branch.pullRequest.url}
target="_blank"
rel="noreferrer"
className={css({
color: theme.colors.contentPrimary,
})}
>
#{branch.prNumber} {branch.prState ?? "open"}
#{branch.pullRequest.number} {branchPullRequestLabel(branch)}
</a>
) : (
<span className={css({ color: theme.colors.contentSecondary })}>-</span>
)}
</div>
<div className={cellClass}>
{branch.ciStatus ?? "-"} / {branch.reviewStatus ?? "-"}
{branch.ciStatus ?? "-"} / {branch.pullRequest ? (branch.pullRequest.isDraft ? "draft" : "ready") : "-"}
</div>
<div className={cellClass}>{formatRelativeAge(branch.updatedAt)}</div>
<div className={cellClass}>
@ -1098,7 +1075,7 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
</Button>
) : null}
<StatusPill kind={branchKind(branch)}>{branch.prState?.toLowerCase() ?? "no pr"}</StatusPill>
<StatusPill kind={branchKind(branch)}>{branchPullRequestLabel(branch)}</StatusPill>
</div>
</div>
</div>
@ -1138,7 +1115,6 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
<HeaderStatusPill
status={deriveHeaderStatus(
taskRuntimeStatus ?? selectedForSession.status,
selectedForSession.statusMessage ?? null,
selectedSessionSummary?.status ?? null,
selectedSessionSummary?.errorMessage ?? null,
Boolean(activeSandbox?.sandboxId),
@ -1266,8 +1242,7 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
<ParagraphSmall marginTop="0" marginBottom="0" color="contentSecondary">
{shouldUseTaskStateEmptyState
? taskStateSummary
: (selectedForSession?.statusMessage ??
(isPendingProvision ? "The task is still provisioning." : "The session is being created."))}
: (isPendingProvision ? "The task is still provisioning." : "The session is being created.")}
</ParagraphSmall>
</div>
) : null}
@ -1277,15 +1252,13 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
{shouldUseTaskStateEmptyState
? taskStateSummary
: isPendingProvision
? (selectedForSession.statusMessage ?? "Provisioning sandbox...")
? "Provisioning sandbox..."
: isPendingSessionCreate
? "Creating session..."
: isSessionError
? (selectedSessionSummary?.errorMessage ?? "Session failed to start.")
: !activeSandbox?.sandboxId
? selectedForSession.statusMessage
? `Sandbox unavailable: ${selectedForSession.statusMessage}`
: "This task is still provisioning its sandbox."
? "This task is still provisioning its sandbox."
: staleSessionId
? `Session ${staleSessionId} is unavailable. Start a new session to continue.`
: resolvedSessionId
@ -1458,7 +1431,7 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
<MetaRow label="Branch" value={selectedBranchOverview.branchName} mono />
<MetaRow label="Commit" value={selectedBranchOverview.commitSha.slice(0, 10)} mono />
<MetaRow label="Task" value={selectedBranchOverview.taskTitle ?? selectedBranchOverview.taskId ?? "-"} />
<MetaRow label="PR" value={selectedBranchOverview.prUrl ?? "-"} />
<MetaRow label="PR" value={selectedBranchOverview.pullRequest?.url ?? "-"} />
<MetaRow label="Updated" value={new Date(selectedBranchOverview.updatedAt).toLocaleTimeString()} />
</div>
)}
@ -1504,9 +1477,8 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
})}
>
<MetaRow label="Branch" value={selectedForSession.branch ?? "-"} mono />
<MetaRow label="Diff" value={formatDiffStat(selectedForSession.diffStat)} />
<MetaRow label="PR" value={selectedForSession.prUrl ?? "-"} />
<MetaRow label="Review" value={selectedForSession.reviewStatus ?? "-"} />
<MetaRow label="PR" value={selectedForSession.pullRequest?.url ?? "-"} />
<MetaRow label="Review" value={selectedForSession.pullRequest ? (selectedForSession.pullRequest.isDraft ? "draft" : "ready") : "-"} />
</div>
</section>
@ -1607,25 +1579,6 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
) : null}
</div>
<div>
<LabelXSmall color="contentSecondary" marginBottom="scale200">
Agent
</LabelXSmall>
<Select
options={AGENT_OPTIONS.map(createOption)}
value={selectValue(selectedAgentOption)}
clearable={false}
searchable={false}
onChange={(params: OnChangeParams) => {
const next = optionId(params.value);
if (next === "claude" || next === "codex") {
setNewAgentType(next);
}
}}
overrides={selectTestIdOverrides("task-create-agent")}
/>
</div>
<div>
<LabelXSmall color="contentSecondary" marginBottom="scale200">
Task

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { TaskRecord } from "@sandbox-agent/foundry-shared";
import { formatDiffStat, groupTasksByRepo } from "./model";
import { groupTasksByRepo } from "./model";
const base: TaskRecord = {
organizationId: "default",
@ -12,9 +12,8 @@ const base: TaskRecord = {
task: "Ship one",
sandboxProviderId: "local",
status: "running",
statusMessage: null,
activeSandboxId: "sandbox-1",
activeSessionId: "session-1",
pullRequest: null,
sandboxes: [
{
sandboxId: "sandbox-1",
@ -26,17 +25,6 @@ const base: TaskRecord = {
updatedAt: 10,
},
],
agentType: null,
prSubmitted: false,
diffStat: null,
prUrl: null,
prAuthor: null,
ciStatus: null,
reviewStatus: null,
reviewer: null,
conflictsWithMain: null,
hasUnpushed: null,
parentBranch: null,
createdAt: 10,
updatedAt: 10,
};
@ -66,19 +54,3 @@ describe("groupTasksByRepo", () => {
expect(groups[1]?.repoId).toBe("repo-a");
});
});
describe("formatDiffStat", () => {
it("returns No changes for zero-diff values", () => {
expect(formatDiffStat("+0/-0")).toBe("No changes");
expect(formatDiffStat("+0 -0")).toBe("No changes");
});
it("returns dash for empty values", () => {
expect(formatDiffStat(null)).toBe("-");
expect(formatDiffStat("")).toBe("-");
});
it("keeps non-empty non-zero diff stats", () => {
expect(formatDiffStat("+12/-4")).toBe("+12/-4");
});
});

View file

@ -37,14 +37,3 @@ export function groupTasksByRepo(tasks: TaskRecord[]): RepoGroup[] {
return a.repoRemote.localeCompare(b.repoRemote);
});
}
export function formatDiffStat(diffStat: string | null | undefined): string {
const normalized = diffStat?.trim();
if (!normalized) {
return "-";
}
if (normalized === "+0/-0" || normalized === "+0 -0" || normalized === "0 files changed") {
return "No changes";
}
return normalized;
}

View file

@ -4,7 +4,7 @@ import { defaultTaskStatusMessage, deriveHeaderStatus, describeTaskState, isProv
describe("defaultTaskStatusMessage", () => {
it("covers every backend task status", () => {
for (const status of [...TaskStatusSchema.options, "new"] as const) {
for (const status of TaskStatusSchema.options) {
expect(defaultTaskStatusMessage(status)).toMatch(/\S/);
}
});
@ -15,18 +15,14 @@ describe("defaultTaskStatusMessage", () => {
});
describe("resolveTaskStateDetail", () => {
it("prefers the backend status message when present", () => {
expect(resolveTaskStateDetail("init_ensure_name", "determining title and branch")).toBe("determining title and branch");
});
it("falls back to the default copy when the backend message is empty", () => {
expect(resolveTaskStateDetail("init_complete", " ")).toBe("Finalizing task initialization.");
it("returns the default copy for the current task status", () => {
expect(resolveTaskStateDetail("init_complete")).toBe("Finalizing task initialization.");
});
});
describe("describeTaskState", () => {
it("includes the raw backend status code in the title", () => {
expect(describeTaskState("kill_destroy_sandbox", null)).toEqual({
expect(describeTaskState("kill_destroy_sandbox")).toEqual({
title: "Task state: kill_destroy_sandbox",
detail: "Destroying sandbox resources.",
});
@ -52,7 +48,7 @@ describe("isProvisioningTaskStatus", () => {
describe("deriveHeaderStatus", () => {
it("returns error variant when session has error", () => {
const result = deriveHeaderStatus("running", null, "error", "Sandbox crashed");
const result = deriveHeaderStatus("running", "error", "Sandbox crashed");
expect(result.variant).toBe("error");
expect(result.label).toBe("Session error");
expect(result.tooltip).toBe("Sandbox crashed");
@ -60,76 +56,76 @@ describe("deriveHeaderStatus", () => {
});
it("returns error variant when task has error", () => {
const result = deriveHeaderStatus("error", "session:error", null, null);
const result = deriveHeaderStatus("error", null, null);
expect(result.variant).toBe("error");
expect(result.label).toBe("Error");
expect(result.spinning).toBe(false);
});
it("returns warning variant with spinner for provisioning task", () => {
const result = deriveHeaderStatus("init_enqueue_provision", null, null, null);
const result = deriveHeaderStatus("init_enqueue_provision", null, null);
expect(result.variant).toBe("warning");
expect(result.label).toBe("Provisioning");
expect(result.spinning).toBe(true);
});
it("returns warning variant for pending_provision session", () => {
const result = deriveHeaderStatus("running", null, "pending_provision", null);
const result = deriveHeaderStatus("running", "pending_provision", null);
expect(result.variant).toBe("warning");
expect(result.label).toBe("Provisioning");
expect(result.spinning).toBe(true);
});
it("returns warning variant for pending_session_create session", () => {
const result = deriveHeaderStatus("running", null, "pending_session_create", null);
const result = deriveHeaderStatus("running", "pending_session_create", null);
expect(result.variant).toBe("warning");
expect(result.label).toBe("Creating session");
expect(result.spinning).toBe(true);
});
it("returns success variant with spinner for running session", () => {
const result = deriveHeaderStatus("running", null, "running", null);
const result = deriveHeaderStatus("running", "running", null);
expect(result.variant).toBe("success");
expect(result.label).toBe("Running");
expect(result.spinning).toBe(true);
});
it("returns success variant for idle/ready state", () => {
const result = deriveHeaderStatus("idle", null, "idle", null);
const result = deriveHeaderStatus("idle", "idle", null);
expect(result.variant).toBe("success");
expect(result.label).toBe("Ready");
expect(result.spinning).toBe(false);
});
it("returns neutral variant for archived task", () => {
const result = deriveHeaderStatus("archived", null, null, null);
const result = deriveHeaderStatus("archived", null, null);
expect(result.variant).toBe("neutral");
expect(result.label).toBe("Archived");
});
it("session error takes priority over task error", () => {
const result = deriveHeaderStatus("error", "session:error", "error", "Sandbox OOM");
const result = deriveHeaderStatus("error", "error", "Sandbox OOM");
expect(result.variant).toBe("error");
expect(result.label).toBe("Session error");
expect(result.tooltip).toBe("Sandbox OOM");
});
it("returns warning when no sandbox is available", () => {
const result = deriveHeaderStatus("idle", null, "idle", null, false);
const result = deriveHeaderStatus("idle", "idle", null, false);
expect(result.variant).toBe("warning");
expect(result.label).toBe("No sandbox");
expect(result.spinning).toBe(false);
});
it("still shows provisioning when no sandbox but task is provisioning", () => {
const result = deriveHeaderStatus("init_enqueue_provision", null, null, null, false);
const result = deriveHeaderStatus("init_enqueue_provision", null, null, false);
expect(result.variant).toBe("warning");
expect(result.label).toBe("Provisioning");
expect(result.spinning).toBe(true);
});
it("shows error over no-sandbox when session has error", () => {
const result = deriveHeaderStatus("idle", null, "error", "Connection lost", false);
const result = deriveHeaderStatus("idle", "error", "Connection lost", false);
expect(result.variant).toBe("error");
expect(result.label).toBe("Session error");
});

View file

@ -1,7 +1,7 @@
import type { TaskStatus, WorkspaceSessionStatus } from "@sandbox-agent/foundry-shared";
import type { HeaderStatusInfo } from "../../components/mock-layout/ui";
export type TaskDisplayStatus = TaskStatus | "new";
export type TaskDisplayStatus = TaskStatus;
export interface TaskStateDescriptor {
title: string;
@ -9,15 +9,11 @@ export interface TaskStateDescriptor {
}
export function isProvisioningTaskStatus(status: TaskDisplayStatus | null | undefined): boolean {
return (
status === "new" || status === "init_bootstrap_db" || status === "init_enqueue_provision" || status === "init_ensure_name" || status === "init_assert_name"
);
return status === "init_bootstrap_db" || status === "init_enqueue_provision" || status === "init_ensure_name" || status === "init_assert_name";
}
export function defaultTaskStatusMessage(status: TaskDisplayStatus | null | undefined): string {
switch (status) {
case "new":
return "Task created. Waiting to initialize.";
case "init_bootstrap_db":
return "Creating task records.";
case "init_enqueue_provision":
@ -54,15 +50,14 @@ export function defaultTaskStatusMessage(status: TaskDisplayStatus | null | unde
}
}
export function resolveTaskStateDetail(status: TaskDisplayStatus | null | undefined, statusMessage: string | null | undefined): string {
const normalized = statusMessage?.trim();
return normalized && normalized.length > 0 ? normalized : defaultTaskStatusMessage(status);
export function resolveTaskStateDetail(status: TaskDisplayStatus | null | undefined): string {
return defaultTaskStatusMessage(status);
}
export function describeTaskState(status: TaskDisplayStatus | null | undefined, statusMessage: string | null | undefined): TaskStateDescriptor {
export function describeTaskState(status: TaskDisplayStatus | null | undefined): TaskStateDescriptor {
return {
title: status ? `Task state: ${status}` : "Task state unavailable",
detail: resolveTaskStateDetail(status, statusMessage),
detail: resolveTaskStateDetail(status),
};
}
@ -72,7 +67,6 @@ export function describeTaskState(status: TaskDisplayStatus | null | undefined,
*/
export function deriveHeaderStatus(
taskStatus: TaskDisplayStatus | null | undefined,
taskStatusMessage: string | null | undefined,
sessionStatus: WorkspaceSessionStatus | null | undefined,
sessionErrorMessage: string | null | undefined,
hasSandbox?: boolean,
@ -93,7 +87,7 @@ export function deriveHeaderStatus(
variant: "error",
label: "Error",
spinning: false,
tooltip: taskStatusMessage ?? "Task entered an error state.",
tooltip: "Task entered an error state.",
};
}
@ -103,7 +97,7 @@ export function deriveHeaderStatus(
variant: "warning",
label: "No sandbox",
spinning: false,
tooltip: taskStatusMessage ?? "Sandbox is not available for this task.",
tooltip: "Sandbox is not available for this task.",
};
}
@ -113,7 +107,7 @@ export function deriveHeaderStatus(
variant: "warning",
label: "Provisioning",
spinning: true,
tooltip: resolveTaskStateDetail(taskStatus, taskStatusMessage),
tooltip: resolveTaskStateDetail(taskStatus),
};
}

View file

@ -4,6 +4,12 @@ export type FoundryBillingPlanId = "free" | "team";
export type FoundryBillingStatus = "active" | "trialing" | "past_due" | "scheduled_cancel";
export type FoundryGithubInstallationStatus = "connected" | "install_required" | "reconnect_required";
export type FoundryGithubSyncStatus = "pending" | "syncing" | "synced" | "error";
export type FoundryGithubSyncPhase =
| "discovering_repositories"
| "syncing_repositories"
| "syncing_branches"
| "syncing_members"
| "syncing_pull_requests";
export type FoundryOrganizationKind = "personal" | "organization";
export type FoundryStarterRepoStatus = "pending" | "starred" | "skipped";
@ -53,6 +59,10 @@ export interface FoundryGithubState {
lastSyncAt: number | null;
lastWebhookAt: number | null;
lastWebhookEvent: string;
syncGeneration?: number;
syncPhase?: FoundryGithubSyncPhase | null;
processedRepositoryCount?: number;
totalRepositoryCount?: number;
}
export interface FoundryOrganizationSettings {

View file

@ -58,6 +58,19 @@ export const CreateTaskInputSchema = z.object({
});
export type CreateTaskInput = z.infer<typeof CreateTaskInputSchema>;
export const WorkspacePullRequestSummarySchema = z.object({
number: z.number().int(),
title: z.string().min(1),
state: z.string().min(1),
url: z.string().min(1),
headRefName: z.string().min(1),
baseRefName: z.string().min(1),
repoFullName: z.string().min(1),
authorLogin: z.string().nullable(),
isDraft: z.boolean(),
updatedAtMs: z.number().int(),
});
export const TaskRecordSchema = z.object({
organizationId: OrganizationIdSchema,
repoId: z.string().min(1),
@ -69,6 +82,7 @@ export const TaskRecordSchema = z.object({
sandboxProviderId: SandboxProviderIdSchema,
status: TaskStatusSchema,
activeSandboxId: z.string().nullable(),
pullRequest: WorkspacePullRequestSummarySchema.nullable(),
sandboxes: z.array(
z.object({
sandboxId: z.string().min(1),
@ -80,12 +94,6 @@ export const TaskRecordSchema = z.object({
updatedAt: z.number().int(),
}),
),
diffStat: z.string().nullable(),
prUrl: z.string().nullable(),
prAuthor: z.string().nullable(),
ciStatus: z.string().nullable(),
reviewStatus: z.string().nullable(),
reviewer: z.string().nullable(),
createdAt: z.number().int(),
updatedAt: z.number().int(),
});
@ -99,6 +107,7 @@ export const TaskSummarySchema = z.object({
title: z.string().min(1).nullable(),
status: TaskStatusSchema,
updatedAt: z.number().int(),
pullRequest: WorkspacePullRequestSummarySchema.nullable(),
});
export type TaskSummary = z.infer<typeof TaskSummarySchema>;
@ -129,12 +138,8 @@ export const RepoBranchRecordSchema = z.object({
taskId: z.string().nullable(),
taskTitle: z.string().nullable(),
taskStatus: TaskStatusSchema.nullable(),
prNumber: z.number().int().nullable(),
prState: z.string().nullable(),
prUrl: z.string().nullable(),
pullRequest: WorkspacePullRequestSummarySchema.nullable(),
ciStatus: z.string().nullable(),
reviewStatus: z.string().nullable(),
reviewer: z.string().nullable(),
updatedAt: z.number().int(),
});
export type RepoBranchRecord = z.infer<typeof RepoBranchRecordSchema>;

View file

@ -2,6 +2,7 @@ export * from "./app-shell.js";
export * from "./contracts.js";
export * from "./config.js";
export * from "./logging.js";
export * from "./models.js";
export * from "./realtime-events.js";
export * from "./workspace.js";
export * from "./organization.js";

View file

@ -0,0 +1,217 @@
import claudeConfig from "../../../../scripts/agent-configs/resources/claude.json" with { type: "json" };
import codexConfig from "../../../../scripts/agent-configs/resources/codex.json" with { type: "json" };
export type WorkspaceAgentKind = string;
export type WorkspaceModelId = string;
export interface WorkspaceModelOption {
id: WorkspaceModelId;
label: string;
}
export interface WorkspaceModelGroup {
provider: string;
agentKind: WorkspaceAgentKind;
sandboxAgentId: string;
models: WorkspaceModelOption[];
}
interface AgentConfigResource {
defaultModel?: string;
models?: Array<{ id?: string; name?: string }>;
}
interface SandboxAgentInfoLike {
id?: unknown;
configOptions?: unknown;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function normalizeModelLabel(model: { id?: string; name?: string }): string {
const name = model.name?.trim();
if (name && name.length > 0) {
return name;
}
return model.id?.trim() || "Unknown";
}
function buildGroup(provider: string, agentKind: WorkspaceAgentKind, sandboxAgentId: string, config: AgentConfigResource): WorkspaceModelGroup {
return {
provider,
agentKind,
sandboxAgentId,
models: (config.models ?? [])
.map((model) => {
const id = model.id?.trim();
if (!id) {
return null;
}
return {
id,
label: normalizeModelLabel(model),
};
})
.filter((model): model is WorkspaceModelOption => model != null),
};
}
function titleCaseIdentifier(value: string): string {
return value
.split(/[\s_-]+/)
.filter(Boolean)
.map((part) => part.slice(0, 1).toUpperCase() + part.slice(1))
.join(" ");
}
function workspaceAgentMetadata(agentId: string): { provider: string; agentKind: string } {
const normalized = agentId.trim().toLowerCase();
switch (normalized) {
case "claude":
return { provider: "Claude", agentKind: "Claude" };
case "codex":
return { provider: "Codex", agentKind: "Codex" };
default:
return {
provider: titleCaseIdentifier(agentId),
agentKind: titleCaseIdentifier(agentId),
};
}
}
function normalizeOptionLabel(entry: Record<string, unknown>): string | null {
const name = typeof entry.name === "string" ? entry.name.trim() : "";
if (name) {
return name;
}
const label = typeof entry.label === "string" ? entry.label.trim() : "";
if (label) {
return label;
}
const value = typeof entry.value === "string" ? entry.value.trim() : "";
return value || null;
}
function appendSelectOptionModels(target: WorkspaceModelOption[], options: unknown): void {
if (!Array.isArray(options)) {
return;
}
for (const entry of options) {
if (!isRecord(entry)) {
continue;
}
const value = typeof entry.value === "string" ? entry.value.trim() : "";
if (value) {
target.push({
id: value,
label: normalizeOptionLabel(entry) ?? value,
});
continue;
}
appendSelectOptionModels(target, entry.options);
}
}
function normalizeAgentModels(configOptions: unknown): WorkspaceModelOption[] {
if (!Array.isArray(configOptions)) {
return [];
}
const options = configOptions.find((entry) => isRecord(entry) && entry.category === "model" && entry.type === "select");
if (!isRecord(options)) {
return [];
}
const models: WorkspaceModelOption[] = [];
appendSelectOptionModels(models, options.options);
const seen = new Set<string>();
return models.filter((model) => {
if (!model.id || seen.has(model.id)) {
return false;
}
seen.add(model.id);
return true;
});
}
export function workspaceModelGroupsFromSandboxAgents(agents: SandboxAgentInfoLike[]): WorkspaceModelGroup[] {
return agents
.map((agent) => {
const sandboxAgentId = typeof agent.id === "string" ? agent.id.trim() : "";
if (!sandboxAgentId) {
return null;
}
const models = normalizeAgentModels(agent.configOptions);
if (models.length === 0) {
return null;
}
const metadata = workspaceAgentMetadata(sandboxAgentId);
return {
provider: metadata.provider,
agentKind: metadata.agentKind,
sandboxAgentId,
models,
} satisfies WorkspaceModelGroup;
})
.filter((group): group is WorkspaceModelGroup => group != null);
}
export const DEFAULT_WORKSPACE_MODEL_GROUPS: WorkspaceModelGroup[] = [
buildGroup("Claude", "Claude", "claude", claudeConfig as AgentConfigResource),
buildGroup("Codex", "Codex", "codex", codexConfig as AgentConfigResource),
].filter((group) => group.models.length > 0);
export const DEFAULT_WORKSPACE_MODEL_ID: WorkspaceModelId =
((codexConfig as AgentConfigResource).defaultModel ?? DEFAULT_WORKSPACE_MODEL_GROUPS[0]?.models[0]?.id ?? "default").trim();
export function workspaceProviderAgent(
provider: string,
groups: WorkspaceModelGroup[] = DEFAULT_WORKSPACE_MODEL_GROUPS,
): WorkspaceAgentKind {
return groups.find((group) => group.provider === provider)?.agentKind ?? provider;
}
export function workspaceModelGroupForId(
id: WorkspaceModelId,
groups: WorkspaceModelGroup[] = DEFAULT_WORKSPACE_MODEL_GROUPS,
): WorkspaceModelGroup | null {
return groups.find((group) => group.models.some((model) => model.id === id)) ?? null;
}
export function workspaceModelLabel(
id: WorkspaceModelId,
groups: WorkspaceModelGroup[] = DEFAULT_WORKSPACE_MODEL_GROUPS,
): string {
const group = workspaceModelGroupForId(id, groups);
const model = group?.models.find((candidate) => candidate.id === id);
return model && group ? `${group.provider} ${model.label}` : id;
}
export function workspaceAgentForModel(
id: WorkspaceModelId,
groups: WorkspaceModelGroup[] = DEFAULT_WORKSPACE_MODEL_GROUPS,
): WorkspaceAgentKind {
const group = workspaceModelGroupForId(id, groups);
if (group) {
return group.agentKind;
}
return groups[0]?.agentKind ?? "Claude";
}
export function workspaceSandboxAgentIdForModel(
id: WorkspaceModelId,
groups: WorkspaceModelGroup[] = DEFAULT_WORKSPACE_MODEL_GROUPS,
): string {
const group = workspaceModelGroupForId(id, groups);
return group?.sandboxAgentId ?? groups[0]?.sandboxAgentId ?? "claude";
}

View file

@ -1,18 +1,11 @@
import type { SandboxProviderId, TaskStatus } from "./contracts.js";
import type { WorkspaceAgentKind, WorkspaceModelGroup, WorkspaceModelId, WorkspaceModelOption } from "./models.js";
export type WorkspaceTaskStatus = TaskStatus | "new";
export type WorkspaceAgentKind = "Claude" | "Codex" | "Cursor";
export type WorkspaceModelId =
| "claude-sonnet-4"
| "claude-opus-4"
| "gpt-5.3-codex"
| "gpt-5.4"
| "gpt-5.2-codex"
| "gpt-5.1-codex-max"
| "gpt-5.2"
| "gpt-5.1-codex-mini";
export type WorkspaceTaskStatus = TaskStatus;
export type WorkspaceSessionStatus = "pending_provision" | "pending_session_create" | "ready" | "running" | "idle" | "error";
export type { WorkspaceAgentKind, WorkspaceModelGroup, WorkspaceModelId, WorkspaceModelOption } from "./models.js";
export interface WorkspaceTranscriptEvent {
id: string;
eventIndex: number;
@ -132,6 +125,7 @@ export interface WorkspaceTaskSummary {
updatedAtMs: number;
branch: string | null;
pullRequest: WorkspacePullRequestSummary | null;
activeSessionId: string | null;
/** Summary of sessions — no transcript content. */
sessionsSummary: WorkspaceSessionSummary[];
}
@ -140,11 +134,6 @@ export interface WorkspaceTaskSummary {
export interface WorkspaceTaskDetail extends WorkspaceTaskSummary {
/** Original task prompt/instructions shown in the detail view. */
task: string;
/** Underlying task runtime status preserved for detail views and error handling. */
runtimeStatus: TaskStatus;
diffStat: string | null;
prUrl: string | null;
reviewStatus: string | null;
fileChanges: WorkspaceFileChange[];
diffs: Record<string, string>;
fileTree: WorkspaceFileTreeNode[];
@ -163,9 +152,32 @@ export interface WorkspaceRepositorySummary {
latestActivityMs: number;
}
export type OrganizationGithubSyncPhase =
| "discovering_repositories"
| "syncing_repositories"
| "syncing_branches"
| "syncing_members"
| "syncing_pull_requests";
export interface OrganizationGithubSummary {
connectedAccount: string;
installationStatus: "connected" | "install_required" | "reconnect_required";
syncStatus: "pending" | "syncing" | "synced" | "error";
importedRepoCount: number;
lastSyncLabel: string;
lastSyncAt: number | null;
lastWebhookAt: number | null;
lastWebhookEvent: string;
syncGeneration: number;
syncPhase: OrganizationGithubSyncPhase | null;
processedRepositoryCount: number;
totalRepositoryCount: number;
}
/** Organization-level snapshot — initial fetch for the organization topic. */
export interface OrganizationSummarySnapshot {
organizationId: string;
github: OrganizationGithubSummary;
repos: WorkspaceRepositorySummary[];
taskSummaries: WorkspaceTaskSummary[];
}
@ -180,11 +192,11 @@ export interface WorkspaceTask {
repoId: string;
title: string;
status: WorkspaceTaskStatus;
runtimeStatus?: TaskStatus;
repoName: string;
updatedAtMs: number;
branch: string | null;
pullRequest: WorkspacePullRequestSummary | null;
activeSessionId?: string | null;
sessions: WorkspaceSession[];
fileChanges: WorkspaceFileChange[];
diffs: Record<string, string>;
@ -212,16 +224,6 @@ export interface TaskWorkspaceSnapshot {
tasks: WorkspaceTask[];
}
export interface WorkspaceModelOption {
id: WorkspaceModelId;
label: string;
}
export interface WorkspaceModelGroup {
provider: string;
models: WorkspaceModelOption[];
}
export interface TaskWorkspaceSelectInput {
repoId: string;
taskId: string;

View file

@ -3,6 +3,7 @@
* Do not make direct changes to the file.
*/
export interface paths {
"/v1/acp": {
get: operations["get_v1_acp_servers"];
@ -234,23 +235,7 @@ export interface components {
agents: components["schemas"]["AgentInfo"][];
};
/** @enum {string} */
ErrorType:
| "invalid_request"
| "conflict"
| "unsupported_agent"
| "agent_not_installed"
| "install_failed"
| "agent_process_exited"
| "token_invalid"
| "permission_denied"
| "not_acceptable"
| "unsupported_media_type"
| "not_found"
| "session_not_found"
| "session_already_exists"
| "mode_not_supported"
| "stream_error"
| "timeout";
ErrorType: "invalid_request" | "conflict" | "unsupported_agent" | "agent_not_installed" | "install_failed" | "agent_process_exited" | "token_invalid" | "permission_denied" | "not_acceptable" | "unsupported_media_type" | "not_found" | "session_not_found" | "session_already_exists" | "mode_not_supported" | "stream_error" | "timeout";
FsActionResponse: {
path: string;
};
@ -309,37 +294,35 @@ export interface components {
directory: string;
mcpName: string;
};
McpServerConfig:
| {
args?: string[];
command: string;
cwd?: string | null;
enabled?: boolean | null;
env?: {
[key: string]: string;
} | null;
/** Format: int64 */
timeoutMs?: number | null;
/** @enum {string} */
type: "local";
}
| {
bearerTokenEnvVar?: string | null;
enabled?: boolean | null;
envHeaders?: {
[key: string]: string;
} | null;
headers?: {
[key: string]: string;
} | null;
oauth?: Record<string, unknown> | null | null;
/** Format: int64 */
timeoutMs?: number | null;
transport?: string | null;
/** @enum {string} */
type: "remote";
url: string;
};
McpServerConfig: ({
args?: string[];
command: string;
cwd?: string | null;
enabled?: boolean | null;
env?: {
[key: string]: string;
} | null;
/** Format: int64 */
timeoutMs?: number | null;
/** @enum {string} */
type: "local";
}) | ({
bearerTokenEnvVar?: string | null;
enabled?: boolean | null;
envHeaders?: {
[key: string]: string;
} | null;
headers?: {
[key: string]: string;
} | null;
oauth?: Record<string, unknown> | null | null;
/** Format: int64 */
timeoutMs?: number | null;
transport?: string | null;
/** @enum {string} */
type: "remote";
url: string;
});
ProblemDetails: {
detail?: string | null;
instance?: string | null;
@ -493,6 +476,7 @@ export type $defs = Record<string, never>;
export type external = Record<string, never>;
export interface operations {
get_v1_acp_servers: {
responses: {
/** @description Active ACP server instances */