From 6657c6b6ed69814457a829b758714f5dd2993799 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Wed, 29 Apr 2026 18:44:32 -0700 Subject: [PATCH 01/16] docs(plans): add v2 workspace create canonical refactor specs Two design docs guiding a clean reimplementation of the canonical workspace.create() flow: - 20260425-canonical-workspace-create-flow.md: umbrella design covering the unified host-service create API, host-scoped attachments, pane store registry, prompt boundary, and PR sequencing (PRs 1-7). - 20260425-host-agent-configs-pr1.md: PR 1 spec for the host-runtime agent config model (host_agent_configs table + settings router). Sourced from the prior v2-workspace-create-canonical branch so the reimplementation can land PR-by-PR per the plan. --- ...0260425-canonical-workspace-create-flow.md | 642 ++++++++++++++++++ plans/20260425-host-agent-configs-pr1.md | 177 +++++ 2 files changed, 819 insertions(+) create mode 100644 plans/20260425-canonical-workspace-create-flow.md create mode 100644 plans/20260425-host-agent-configs-pr1.md diff --git a/plans/20260425-canonical-workspace-create-flow.md b/plans/20260425-canonical-workspace-create-flow.md new file mode 100644 index 00000000000..5ea833b0344 --- /dev/null +++ b/plans/20260425-canonical-workspace-create-flow.md @@ -0,0 +1,642 @@ +# Canonical Workspace Create Flow + +## Summary + +Workspace creation should be a single host-service orchestration flow. Today the renderer drives too much of the lifecycle: it creates a pending row, creates or checks out the workspace, loads attachments, builds launch prompts, writes attachment files, stores transient launch intent, navigates, and then relies on the workspace route mounting to actually start or reveal work. + +The target is to move workspace creation and runtime startup behind one `workspace.create()` API. The renderer uploads attachments separately, calls `workspace.create()`, writes returned launch panes into the workspace pane store, and navigates. The workspace route renders existing pane state; it does not start agents or populate panes as a side effect of mounting. + +## Goals + +- Provide one create contract usable by the new workspace modal, task view, automations, CLI, and future flows. +- Support creating a workspace with zero, one, or many requested launches. +- Keep user-editable prompts as plain Markdown. +- Keep attachments host-scoped and independent of workspaces. +- Start terminal/chat sessions in host-service, without requiring renderer navigation. +- Remove pending workspace launch orchestration and route-mount launch effects. + +## Public APIs + +### Attachments + +Add host-scoped attachment APIs: + +```ts +attachments.upload({ + data, + mediaType, + originalFilename?, +}) => { + attachmentId, + originalFilename?, + mediaType, + sizeBytes, +} + +attachments.delete({ attachmentId }) => { success: true } +``` + +Attachments are stored on the selected host, not in Superset cloud: + +```txt +~/.superset/attachments//. +~/.superset/attachments//metadata.json +``` + +`attachmentId` is the only stable identifier. The extension is derived from MIME type, and original filename is metadata only. + +### Workspace Create + +Replace the current narrow `workspace.create({ projectId, name, branch })` with: + +```ts +workspace.create({ + mode: + | { + kind: "fork"; + branchName: string; + baseBranch?: string; + baseBranchSource?: "local" | "remote-tracking"; + } + | { kind: "checkout"; branchName: string } + | { kind: "pr-checkout"; prNumber: number } + | { kind: "adopt"; branchName: string; worktreePath?: string }, + + projectId: string, + name: string, + + launches?: Array< + | { kind: "terminal"; command: string; label?: string } + | { + kind: "agent"; + agentId: string; + prompt?: string; + attachmentIds?: string[]; + } + >, +}) => { + workspace: { id, projectId, name, branch }, + launches: Array< + | { kind: "terminal"; terminalId: string; label?: string } + | { kind: "chat"; chatSessionId: string; label?: string } + >, + warnings: string[], +} +``` + +Notes: + +- `prompt` is plain Markdown on each requested agent launch. +- `attachmentIds` belong to the agent launch that needs them. +- Setup is not a public input. If `.superset/setup.sh` exists, host-service starts it and returns it as a terminal launch. +- Attachment paths are not returned to the UI. + +## Host-Service Flow + +`workspace.create()` owns the full server-side flow: + +1. Resolve the local project/repo. +2. Execute the requested workspace mode: + - `fork`: create a new branch/worktree from base branch. + - `checkout`: check out an existing branch into a workspace. + - `pr-checkout`: check out a GitHub PR branch. + - `adopt`: register an existing worktree. +3. Register the host and cloud workspace row. +4. Persist the local host workspace row. +5. Build an internal launch list: + - setup terminal if `.superset/setup.sh` exists; + - all requested terminal launches; + - all requested agent launches. +6. For each agent launch: + - resolve the selected agent config; + - resolve `attachmentIds` to host-readable paths; + - append a deterministic attachment block to the Markdown prompt; + - start either a terminal-backed or chat-backed session. +7. Return the workspace row, all launched session IDs, and warnings. + +Attachment prompt block for terminal agents should reference absolute host paths, for example: + +```md +# Attached files + +The user attached these files. They are available on this host at: + +- /Users/satya/.superset/attachments//.png +``` + +## Renderer Flow + +Interactive UI flows should work like this: + +1. User selects a target host. +2. User attaches files. +3. Renderer immediately calls `attachments.upload()` on the selected host. +4. Renderer stores `attachmentId` plus display metadata in local Zustand state. +5. If the selected host changes, clear or reupload attachments. +6. On submit, renderer calls `workspace.create()` with the requested mode and launches. +7. After create resolves, renderer writes returned launches into the workspace pane store. +8. Renderer navigates to `/v2-workspace/$workspaceId`. + +The workspace route should only render the existing pane store. It should not be required to start agents, consume pending launch intent, or populate panes as a side effect of mounting. + +## Pane Store Registry + +Add a renderer-level registry: + +```ts +getOrCreateWorkspacePaneStore(workspaceId) +``` + +The workspace route and create callers should both use this registry. That makes pane state writable before navigation. + +Add a helper that deduplicates and focuses panes: + +```ts +addLaunchPanes(workspaceId, launches) +``` + +It should: + +- create or fetch the pane store for `workspaceId`; +- add terminal panes for returned `terminalId`s; +- add chat panes for returned `chatSessionId`s; +- dedupe by session ID; +- focus the created or existing pane. + +## Prompt Building Boundary + +Prompt templates are separate from workspace creation. + +The create API accepts user-editable Markdown on each agent launch: + +```ts +{ kind: "agent", agentId, prompt, attachmentIds } +``` + +Template systems can generate that Markdown before submit, and users can edit it freely. `workspace.create()` does not need to know whether the prompt came from a saved template, a task view button, an automation, CLI input, or manual typing. + +Host-service owns only runtime prompt finalization: + +- resolve attachment IDs to readable host paths; +- append the attachment block; +- adapt the prompt for the selected terminal/chat agent config; +- start the session. + +This keeps semantic prompt authoring host-independent while keeping host-local paths host-owned. + +## Prompt Builder Design + +The prompt builder should be split into two responsibilities: + +1. Template rendering before create. +2. Runtime prompt finalization during create. + +### Template Rendering + +Templates produce Markdown. They are not part of the `workspace.create()` contract. + +Saved templates, task view actions, automations, CLI helpers, and manual input should all eventually produce the same simple value: + +```ts +prompt: string +``` + +The renderer may support a template authoring flow like: + +```ts +promptTemplates.render({ + templateMarkdown, + variables, +}) => { + prompt: string, + unresolvedVariables: string[], +} +``` + +This can be implemented in shared code or a cloud/API endpoint. It should not require a selected host because it only resolves host-independent values such as issue title, PR title, task title, or user-supplied variables. + +The UI can use this to show unresolved variables before submit. Users should always be able to edit the rendered Markdown before launching. + +### Runtime Prompt Finalization + +Host-service finalizes prompts only at launch time. + +For each requested agent launch, host-service receives: + +```ts +{ + kind: "agent", + agentId: string, + prompt?: string, + attachmentIds?: string[], +} +``` + +Host-service then: + +- loads the selected agent config; +- resolves whether the agent is terminal-backed or chat-backed; +- resolves each `attachmentId` from the selected host's attachment store; +- adds an attachment section to the prompt when attachments exist; +- adapts the final prompt for the selected agent runtime; +- starts the terminal or chat session. + +The attachment section should be deterministic and host-local: + +```md +# Attached files + +The user attached these files. They are available on this host at: + +- /Users/satya/.superset/attachments//.png +``` + +Original filenames may be included as display metadata in the block, but they should not be used as filesystem paths. + +### What The Client Should Not Do + +The renderer should not: + +- resolve attachment IDs to paths; +- decide attachment filenames; +- write attachment bytes into worktrees; +- build terminal command strings for agent launches; +- read agent prompt templates directly to produce runtime-specific commands; +- fetch GitHub issue or PR bodies solely to assemble the final launch prompt. + +The renderer can preview and edit human-authored Markdown, but host-service owns runtime-specific prompt assembly. + +### Agent Configs + +Agent configs should be host-local launch profiles for v1. They encode real runtime and security preferences: CLI flags, approval mode, sandboxing behavior, model selection, and command templates. Those preferences can reasonably differ per machine. + +Responsibilities: + +- Product/settings UI owns editing agent profiles on the selected/local host. +- Host-local settings own persistence. +- Host-service owns runtime validation and execution. +- Renderer should pass `agentId`, not reconstruct command flags. + +For v1, `agentId` means "the agent profile with this ID on the selected host." If another host does not have the same profile, that host cannot launch it. Cross-device synced agent profiles can be a later product decision. + +For `workspace.create()`, an agent launch should require a prompt. If the caller wants a promptless process, it should use a raw terminal launch: + +```ts +{ kind: "terminal", command: "claude", label: "Claude" } +``` + +That lets agent profiles focus on one job: "given a Markdown prompt, how does this host start this agent?" + +The host-local config model should be a list of configured preset instances. Hardcoded presets provide defaults and icons. Stored entries represent the agents this host actually exposes. + +```ts +type HostAgentSettings = { + version: 1; + agents: Array<{ + // Config instance id. Multiple entries may use the same presetId. + id: string; + // Hardcoded preset id, e.g. "claude", "codex", "custom-terminal". + presetId: string; + + // Optional overrides. Missing values resolve from the preset. + label?: string; + launchCommand?: string; + promptInput?: "argv" | "stdin"; + + order: number; + }>; +}; +``` + +Resolved runtime shape: + +```ts +type ResolvedHostAgentConfig = { + id: string; + presetId: string; + kind: "terminal"; + label: string; + description?: string; + launchCommand: string; + promptInput: "argv" | "stdin"; + order: number; +}; +``` + +Configured entries are the available agents. Removing an entry removes it from the picker. Adding an entry creates a new instance from a hardcoded preset. Reordering edits `order`. + +Superset Chat should not be part of this host-local terminal agent config model for v1. It can still appear as a launch option, but its model/provider behavior should stay in chat/model settings. We can skip additional Superset Chat configuration in this refactor. + +Icons should not be stored in config for v1. The UI resolves icons from `presetId`. Builtins get branded icons; custom terminal entries get a generic terminal/custom icon. + +This removes the need for both `command` and `promptCommand` in the create flow. The old distinction exists because some surfaces can open an agent with no prompt, while other surfaces launch with a prompt. In the new workspace create contract: + +- `agent` launches are prompted and use `launchCommand`. +- promptless/manual commands are represented as `{ kind: "terminal", command }`. +- `launchCommand` is everything before the prompt. For `argv`, host-service appends the prompt argument. For `stdin`, host-service pipes the prompt through stdin. + +Based on current builtins: + +- Claude, Gemini, Mastracode, Pi, and Cursor can use their normal prompt-aware command with argv input. +- Amp needs stdin prompt input. +- Codex, OpenCode, and Copilot need prompt-specific CLI flags, which become their `launchCommand`. +- None of the current builtins need trailing arguments after the prompt, so no `launchCommandSuffix` is needed. + +Do not include a file-based prompt input mode in v1. It may be useful later for CLIs with native `--prompt-file` support or for avoiding shell argument limits, but none of the current builtins require it and the existing prompt transport enum only supports `argv` and `stdin`. + +The current code does have `buildPromptFileCommandString(filePath, ...)`, but that is not a file transport mode. It reads an existing prompt file and still passes the resulting prompt through `argv` or `stdin`. + +Host-service should validate: + +- the agent config exists and is enabled; +- the requested agent kind is supported on that host; +- required commands/providers are available; +- command templates and CLI flags are well-formed; +- prompt input mode is supported. + +If an agent command/provider is unavailable on that host, `workspace.create()` should fail that specific launch clearly or return a warning when other launches can continue. + +Security boundary: + +- User-owned configs may run user-configured commands on that user's host. +- Host-local capabilities, paths, tokens, and installed tools remain host-owned and are never assumed from synced config alone. + +The invariant should be: + +> The same `agentId` is only stable within a host. The renderer selects from the target host's available profiles; host-service resolves that profile and launches it. + +## Router Migration + +`workspaceCreation` should be deprecated, not extended. + +Move current create behavior into `workspace.create()`: + +- `workspaceCreation.create` fork behavior; +- `workspaceCreation.checkout` branch checkout behavior; +- `workspaceCreation.checkout` PR checkout behavior; +- `workspaceCreation.adopt` adopt behavior. + +Keep existing `workspace.get`, `workspace.gitStatus`, and `workspace.delete`. + +Move or delete remaining `workspaceCreation` helpers: + +- `getProgress`: delete; create is promise-based for v1. +- `searchBranches`: move to `project` or `workspace`. +- `generateBranchName`: move to `workspace`. +- GitHub issue/PR search and content helpers: move to `github` or a context-oriented router. +- `getContext`: delete if no new caller needs it. + +After callers migrate, remove `workspaceCreation` from `appRouter`. + +## Logic To Remove + +This design should let us remove: + +- pending workspace route as create orchestration; +- pending row `terminalLaunch` / `chatLaunch` intent; +- `dispatchForkLaunch`; +- renderer-side terminal command construction for create flows; +- renderer-side attachment file writing; +- renderer-side attachment filename generation; +- route-mount behavior that starts agents; +- `workspaceCreation.getProgress`. + +## UI Cleanup And Adjustments + +The new UI model should be simpler and more explicit. + +### New Workspace Modal + +Replace the current pending-row flow with direct mutation state: + +- upload attachments to the selected host as they are added; +- store uploaded attachment metadata in modal-local Zustand state; +- call `workspace.create()` on submit; +- show loading while the create promise is in flight; +- write returned launches to the workspace pane store; +- navigate to the created workspace. + +Remove modal plumbing that only exists for pending orchestration: + +- storing attachment blobs in IndexedDB for this flow; +- creating a pending workspace row before the host create call; +- routing through `/pending/$pendingId` to continue creation; +- serializing `terminalLaunch` or `chatLaunch` onto a row; +- preserving `runSetupScript` draft state. + +### Pending Workspace Route + +The pending route should no longer be the owner of create orchestration. + +Delete or shrink code that: + +- reads pending attachment blobs; +- calls `workspaceCreation.create`, `checkout`, or `adopt`; +- fetches PR content to build launch payloads; +- calls `dispatchForkLaunch`; +- updates pending status for launch dispatch; +- polls `workspaceCreation.getProgress`. + +If a loading screen is still desired, it should be a UI state around a running mutation or a future `workspace.operations.*` operation, not a renderer-owned creation state machine. + +### Workspace Route + +The workspace route should become a renderer of pane state. + +It should not: + +- consume pending launch intent; +- create terminal/chat sessions as a mount side effect; +- parse query params for fresh create flows; +- populate panes because a create flow navigated there. + +It may keep compatibility adoption for existing automation run links temporarily, but new internal callers should use the shared pane store helper. + +### Pane Store + +Move pane store access behind a registry: + +```ts +getOrCreateWorkspacePaneStore(workspaceId) +addLaunchPanes(workspaceId, launches) +``` + +This lets callers populate panes before route mount. The route should read the same store instance. + +### Automations And CLI + +Automations should call the same `workspace.create()` endpoint with requested launches instead of doing: + +1. workspace create; +2. separate chat or terminal dispatch; +3. separate run-row session wiring. + +The automation run row should persist the returned workspace ID and launch IDs. + +CLI should call the same endpoint and print the returned workspace and session IDs. It does not need pane store logic. + +## PR Boundaries And Implementation Order + +This should be split into several PRs. The safest order is to move ownership one boundary at a time and keep old flows working until the replacement path is complete. + +### PR 1: Host-Local Agent Config Model + +Goal: introduce the new configured-agent-instance model without changing workspace creation. + +Changes: + +- Add hardcoded terminal agent presets with `presetId`, label, description, default `launchCommand`, default `promptInput`, and UI icon mapping. +- Add host-local storage for `HostAgentSettings { version, agents }`. +- Add host-service/settings APIs to list, add, update, remove, and reorder configured agents. +- Migrate existing builtin overrides/custom agents into configured entries, preserving current enabled agents and command edits. +- Keep existing renderer consumers working by exposing resolved configs in the current `ResolvedAgentConfig`-compatible shape where needed. + +Tests: + +- migration preserves current configured agents; +- duplicate preset instances are allowed; +- removing an entry removes it from resolved agents; +- order is stable; +- builtins resolve defaults when overrides are missing. + +### PR 2: Host Attachment Store + +Goal: make attachments host-scoped resources independent of workspaces. + +Changes: + +- Add `attachments.upload` and `attachments.delete` on host-service. +- Store files under `~/.superset/attachments//.`. +- Store metadata sidecar. +- Enforce file size/type caps. +- Add renderer attachment state that stores uploaded IDs and display metadata. +- Do not remove the old IndexedDB path yet; keep it for the existing pending route until create is migrated. + +Tests: + +- upload writes bytes and metadata; +- delete removes the attachment directory; +- invalid media type/oversized file is rejected; +- host change clears or reuploads pending attachment IDs. + +### PR 3: Pane Store Registry + +Goal: make workspace panes writable before route mount. + +Changes: + +- Add `getOrCreateWorkspacePaneStore(workspaceId)`. +- Update the workspace route to read from the registry. +- Add `addLaunchPanes(workspaceId, launches)` with dedupe/focus behavior. +- Keep current query-param/pending launch adoption temporarily. + +Tests: + +- panes can be added before navigation; +- route renders pre-populated panes; +- duplicate terminal/chat IDs do not create duplicate panes. + +### PR 4: New `workspace.create()` API + +Goal: add canonical host-service orchestration while leaving `workspaceCreation` in place. + +Changes: + +- Add the new `workspace.create()` input/output shape. +- Port fork, checkout, PR checkout, and adopt internals from `workspaceCreation`. +- Start setup terminal automatically when `.superset/setup.sh` exists. +- Start requested raw terminal launches. +- Start requested agent launches by resolving host-local agent config, finalizing prompt with host attachment paths, and creating terminal sessions. +- Return `workspace`, `launches`, and `warnings`. + +Tests: + +- all modes create/adopt the expected workspace; +- multiple launches start for one workspace; +- setup launch is included when setup script exists; +- invalid agent IDs fail clearly; +- attachment IDs are resolved into prompt text. + +### PR 5: Migrate Interactive Create UI + +Goal: move the new workspace modal/task entrypoints onto the new create flow. + +Changes: + +- New workspace modal uploads attachments to host on attach. +- Submit calls `workspace.create()` directly. +- After success, call `addLaunchPanes()` and navigate. +- Remove create-flow use of pending rows, pending route, IndexedDB attachment blobs, and renderer-side launch building for migrated entrypoints. +- Task view/open-in-workspace flows build semantic Markdown in the UI and call the same endpoint. + +Tests: + +- modal creates workspace and opens returned agent pane; +- attachments appear in terminal prompt as host-local paths; +- task launch creates prompt Markdown in UI and launches via host-service; +- route mount is not required to start the agent. + +### PR 6: Migrate Automations And CLI + +Goal: make non-renderer callers use the same create API. + +Changes: + +- Automations call `workspace.create()` with requested launches instead of create plus separate dispatch. +- Automation run rows persist returned workspace ID and launch IDs. +- CLI calls `workspace.create()` and prints workspace/session IDs. + +Tests: + +- automation run creates workspace and session through one host call; +- CLI create works without renderer pane state. + +### PR 7: Remove Legacy Creation Machinery + +Goal: delete the old orchestration path after all callers migrate. + +Changes: + +- Remove `workspaceCreation.create`, `checkout`, `adopt`, and `getProgress`. +- Move remaining picker/search helpers to their final routers. +- Remove `dispatchForkLaunch`. +- Remove pending row `terminalLaunch` / `chatLaunch`. +- Remove pending route create orchestration. +- Remove renderer-side terminal command construction and attachment writing for create flows. + +Tests: + +- no references remain to removed procedures; +- full create flows still pass across modal, task, automation, and CLI. + +## Testing + +Host-service tests: + +- `workspace.create` works for `fork`, `checkout`, `pr-checkout`, and `adopt`. +- multiple requested launches start for one workspace. +- setup script, when present, returns as a terminal launch. +- agent sessions start without renderer navigation. +- attachment IDs resolve to host-readable paths used in prompts. +- invalid attachment IDs fail the relevant agent launch clearly. +- raw terminal launch starts the requested command. + +Renderer tests: + +- attachment upload stores only IDs and display metadata in local UI state. +- host changes clear or reupload attachments. +- create result launches are added to the workspace pane store before route mount. +- duplicate launch IDs focus existing panes instead of creating duplicates. +- workspace route renders pre-populated pane state without consuming pending launch intent. + +Integration tests: + +- new workspace modal, task view, automations, and CLI can call the same create API. +- no create path depends on pending rows, query params, or workspace route effects. + +## Assumptions + +- Create is a promise-based mutation for v1. +- If durable progress is needed later, add `workspace.operations.*` rather than restoring renderer pending-row orchestration. +- Attachment IDs are host-scoped and invalid after switching hosts unless reuploaded. +- Superset cloud stores workspace/session metadata, not attachment bytes. diff --git a/plans/20260425-host-agent-configs-pr1.md b/plans/20260425-host-agent-configs-pr1.md new file mode 100644 index 00000000000..e37daeb058c --- /dev/null +++ b/plans/20260425-host-agent-configs-pr1.md @@ -0,0 +1,177 @@ +# PR 1 Plan: Host Agent Configs + +## Summary + +This PR introduces the V2 agent configuration model used by host runtimes. Agent configs are stored in the active host runtime database (`host.db`), edited through the V2 Agents settings UI, and selected by the V2 new workspace modal. + +The key product rule is that the UI sends only an `agentId` when creating a workspace. The host owns the config and resolves that `agentId` locally before launching anything. + +This PR does not migrate legacy desktop agent preset customizations into host configs. That migration should be handled later by the existing v1-to-v2 migration flow. + +## Data Model + +Add one shared shape for both storage and UI: + +```ts +type HostAgentConfig = { + id: string; + presetId: string; + label: string; + launchCommand: string; + promptInput: "argv" | "stdin"; + order: number; +}; + +type AgentPreset = { + presetId: string; + label: string; + launchCommand: string; + promptInput: "argv" | "stdin"; +}; +``` + +Hardcoded presets are add templates only. Adding a preset copies its fields into a new `HostAgentConfig` with a fresh `id` and next `order`. + +Do not include these concepts in the V2 model: + +- `enabled`: remove a config instead. +- `pinned`: ordering is explicit. +- `description`: not needed in this UI. +- `iconId` / `iconUrl`: icons are derived from `presetId`. +- `launchCommandSuffix`: current agents can express flags before the prompt. +- separate `command` / `promptCommand`: use one `launchCommand`. +- Superset Chat config: keep it out of this terminal-agent config UI for now. + +## Host Service + +Store V2 configs in `host.db`, not desktop `local.db`. The host service is the runtime that launches agents, so it must be able to resolve `agentId` without asking the renderer or desktop settings router. + +Add a host-service settings router: + +```ts +settings.agentConfigs.list() +settings.agentConfigs.add({ presetId }) +settings.agentConfigs.update({ id, patch }) +settings.agentConfigs.remove({ id }) +settings.agentConfigs.reorder({ ids }) +settings.agentConfigs.resetToDefaults() +``` + +Behavior: + +- `list()` returns configs ordered by `order`. +- If no configs exist yet, seed from bundled built-in terminal defaults only. +- `add()` copies from hardcoded presets and allows duplicate `presetId` entries. +- `update()` can change `label`, `launchCommand`, and `promptInput`. +- `remove()` deletes the config. +- `reorder()` persists the submitted config id order. +- `resetToDefaults()` replaces the list with bundled defaults. + +Out of scope: + +- Reading legacy desktop `settings.getAgentPresets()`. +- Seeding from desktop local DB overrides or custom agents. +- Migrating user customizations. The v1-to-v2 migration should own that later. + +## Renderer + +Under `FEATURE_FLAGS.V2_CLOUD`, the Agents settings page should use the active host service: + +```ts +hostClient.settings.agentConfigs.* +``` + +The V2 UI shows: + +- configured agents in persisted order +- add buttons from hardcoded presets +- duplicate configs as separate rows +- editable `label`, `launchCommand`, and `promptInput` +- remove and reorder controls + +Non-V2 keeps the existing desktop `settings.getAgentPresets()` UI unchanged. + +## New Workspace Modal + +Under `FEATURE_FLAGS.V2_CLOUD`, the V2 new workspace modal reads agents from: + +```ts +hostClient.settings.agentConfigs.list() +``` + +It must not read desktop `settings.getAgentPresets()`. + +The picker displays host config instances in persisted order. The selected value is the config instance `id`, not `presetId`, so duplicate presets work. + +When submitting a V2 workspace create, the pending row/create flow carries only: + +```ts +agentId: selectedHostAgentConfigId +``` + +The renderer must not send `label`, `launchCommand`, `promptInput`, or expanded agent config data in the create payload. + +## Launch Flow + +When the V2 pending/create flow launches an agent: + +1. Resolve `agentId` against `host.db` via the host-service config API or host-side helper. +2. If `agentId` is `null` or `"none"`, do not launch an agent. +3. If `agentId` is stale or missing, fail clearly with a missing agent config error. +4. Do not fall back to desktop `settings.getAgentPresets()`. +5. Build the terminal launch from the resolved host config: + - `launchCommand` + - `promptInput` + - `label` + +This PR can keep the existing pending launch machinery. It should only swap the V2 source of agent config truth from desktop presets to host configs. + +## Cloud Sandbox Fit + +The model is host-runtime scoped, not desktop-specific. + +For desktop, configs live in the active organization-scoped `host.db`. + +For future cloud sandboxes, the same config shape can be seeded into the sandbox `host.db` from: + +- the sandbox image/template, or +- the sandbox creation payload + +The UI contract stays the same: ask the active host for configs, display them, and send back only `agentId`. + +## Tests + +Host-service tests: + +- first `list()` seeds bundled defaults +- Superset Chat is not included +- `add()` copies preset fields and assigns a unique `id` +- duplicate `presetId` configs are allowed +- `update()` persists `label`, `launchCommand`, and `promptInput` +- invalid `promptInput` is rejected +- `remove()` deletes configs +- `reorder()` persists order +- `resetToDefaults()` replaces current configs + +Renderer tests: + +- V2 flag shows the new host config UI +- non-V2 flag keeps the old agent preset UI +- V2 modal queries host `settings.agentConfigs.list()` +- V2 modal submits only selected config `id` +- duplicate configs can be selected distinctly + +Launch/create tests: + +- V2 launch resolves `agentId` from host configs +- V2 launch never calls desktop `settings.getAgentPresets()` +- stale `agentId` fails clearly +- legacy create path remains unchanged + +## Follow-Ups + +- Move to the canonical `workspace.create()` endpoint. +- Add multi-agent launch arrays. +- Move attachment upload to the direct host upload flow. +- Add v1-to-v2 migration that copies legacy desktop preset/custom-agent state into host configs. +- Remove legacy desktop agent preset UI and APIs once V2 fully replaces them. From f91e1de07acbac0ec4cffa7ec34766730a7641a1 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Thu, 30 Apr 2026 10:57:24 -0700 Subject: [PATCH 02/16] docs(plans): switch host agent configs to argv-array launch spec Replace the single `launchCommand` string with a structured argv-array shape (`command` + `args[]` + `promptArgs[]` + `env`) matching VS Code ITerminalProfile / Tabby Shell / WezTerm SpawnCommand / Zellij panes. Empty launches drop `promptArgs` automatically, so codex/opencode/copilot no longer carry their prompt-mode flags into no-prompt sessions. Storing argv directly avoids shell-quoting bugs and makes prompt injection a list push instead of string concatenation. Adds first-class `env` overlay and the per-preset breakdown for the 8 in-scope agents. Implementation in PR1 will be redone against this shape in a separate branch; this branch is now docs-only. --- plans/20260425-host-agent-configs-pr1.md | 78 ++++++++++++++++++------ 1 file changed, 58 insertions(+), 20 deletions(-) diff --git a/plans/20260425-host-agent-configs-pr1.md b/plans/20260425-host-agent-configs-pr1.md index e37daeb058c..74c0d2e897d 100644 --- a/plans/20260425-host-agent-configs-pr1.md +++ b/plans/20260425-host-agent-configs-pr1.md @@ -10,36 +10,69 @@ This PR does not migrate legacy desktop agent preset customizations into host co ## Data Model -Add one shared shape for both storage and UI: +Use an argv-array launch spec, matching the dominant pattern in data-driven launchers (VS Code `ITerminalProfile`, Tabby `Shell`, WezTerm `SpawnCommand`, Zellij panes). Storing argv directly avoids shell-quoting bugs and makes prompt injection a list push instead of string concatenation. ```ts type HostAgentConfig = { id: string; presetId: string; label: string; - launchCommand: string; - promptInput: "argv" | "stdin"; order: number; -}; -type AgentPreset = { - presetId: string; - label: string; - launchCommand: string; - promptInput: "argv" | "stdin"; + // Process spec + command: string; // executable, e.g. "codex" + args: string[]; // argv that's always present + + // Prompt injection + promptTransport: "argv" | "stdin"; + promptArgs: string[]; // argv inserted ONLY when launching with a prompt; placed between `args` and the prompt itself + + // Environment overlay + env: Record; }; + +type AgentPreset = Omit; +``` + +Launch resolution is mechanical: + +```ts +const argv = prompt + ? [command, ...args, ...promptArgs, ...(promptTransport === "argv" ? [prompt] : [])] + : [command, ...args]; +// when promptTransport === "stdin" and prompt is present, pipe `prompt` to the spawned process's stdin. ``` Hardcoded presets are add templates only. Adding a preset copies its fields into a new `HostAgentConfig` with a fresh `id` and next `order`. +The settings UI presents `command` + `args` as a single shell-style text input. Parse on save with `shell-quote` and split into `command` (= tokens[0]) and `args` (= rest); render back as `${command} ${args.map(quote).join(" ")}` for editing. `promptArgs` is a separate small input (typically empty; non-empty for codex/opencode/copilot-style agents that need a prompt-mode-only flag). `promptTransport` is a toggle. `env` is an optional collapsible key/value editor. + +Examples for the bundled presets: + +| Agent | command | args | promptArgs | transport | +|---|---|---|---|---| +| claude | `claude` | `["--permission-mode", "acceptEdits"]` | `[]` | argv | +| amp | `amp` | `[]` | `[]` | stdin | +| codex | `codex` | `["-c", "model_reasoning_effort=high", "-c", "model_reasoning_summary=detailed", "-c", "model_supports_reasoning_summaries=true", "--full-auto"]` | `["--"]` | argv | +| gemini | `gemini` | `["--approval-mode=auto_edit"]` | `[]` | argv | +| opencode | `opencode` | `[]` | `["--prompt"]` | argv | +| pi | `pi` | `[]` | `[]` | argv | +| copilot | `copilot` | `["--allow-tool=write"]` | `["-i"]` | argv | +| cursor-agent | `cursor-agent` | `[]` | `[]` | argv | + +Empty launches drop `promptArgs` automatically, so codex doesn't get a stray `--`, opencode doesn't get a stray `--prompt`, and copilot's `-i` only appears in prompt mode. No per-preset special-casing required. + Do not include these concepts in the V2 model: - `enabled`: remove a config instead. - `pinned`: ordering is explicit. - `description`: not needed in this UI. - `iconId` / `iconUrl`: icons are derived from `presetId`. -- `launchCommandSuffix`: current agents can express flags before the prompt. -- separate `command` / `promptCommand`: use one `launchCommand`. +- `promptCommandSuffix` / post-prompt shell chaining: shell-language feature; agents that need it (mastracode-style `; mastracode`) should change their CLI rather than have this layer model shell semantics. +- `cwd` override: the workspace controls cwd. +- `shell: boolean` (run via `sh -c`): adds an escaping mode and a footgun. Users who need shell features can author `sh -c '…'` explicitly via `command` + `args`. +- `taskPromptTemplate` / context templates per agent: keep centralized in `agent-prompt-template`. Per-agent overrides can be a follow-up column when there's a real need. +- `source: "user" | "team" | "builtin"`: V2 is host-scoped user data only. Layering can be added later if it lands. - Superset Chat config: keep it out of this terminal-agent config UI for now. ## Host Service @@ -62,7 +95,7 @@ Behavior: - `list()` returns configs ordered by `order`. - If no configs exist yet, seed from bundled built-in terminal defaults only. - `add()` copies from hardcoded presets and allows duplicate `presetId` entries. -- `update()` can change `label`, `launchCommand`, and `promptInput`. +- `update()` can change `label`, `command`, `args`, `promptTransport`, `promptArgs`, and `env`. - `remove()` deletes the config. - `reorder()` persists the submitted config id order. - `resetToDefaults()` replaces the list with bundled defaults. @@ -86,7 +119,7 @@ The V2 UI shows: - configured agents in persisted order - add buttons from hardcoded presets - duplicate configs as separate rows -- editable `label`, `launchCommand`, and `promptInput` +- editable `label`, command (single text input parsed into `command` + `args`), `promptArgs`, `promptTransport` toggle, and optional `env` key/value editor - remove and reorder controls Non-V2 keeps the existing desktop `settings.getAgentPresets()` UI unchanged. @@ -109,7 +142,7 @@ When submitting a V2 workspace create, the pending row/create flow carries only: agentId: selectedHostAgentConfigId ``` -The renderer must not send `label`, `launchCommand`, `promptInput`, or expanded agent config data in the create payload. +The renderer must not send `label`, `command`, `args`, `promptArgs`, `promptTransport`, `env`, or any other expanded agent config data in the create payload. ## Launch Flow @@ -119,10 +152,11 @@ When the V2 pending/create flow launches an agent: 2. If `agentId` is `null` or `"none"`, do not launch an agent. 3. If `agentId` is stale or missing, fail clearly with a missing agent config error. 4. Do not fall back to desktop `settings.getAgentPresets()`. -5. Build the terminal launch from the resolved host config: - - `launchCommand` - - `promptInput` - - `label` +5. Build the terminal launch argv from the resolved host config: + - argv = `prompt ? [command, ...args, ...promptArgs, ...(promptTransport === "argv" ? [prompt] : [])] : [command, ...args]` + - if `promptTransport === "stdin"` and a prompt is present, pipe `prompt` to the spawned process's stdin + - apply `env` as an overlay on the workspace base env + - carry `label` for display This PR can keep the existing pending launch machinery. It should only swap the V2 source of agent config truth from desktop presets to host configs. @@ -147,8 +181,12 @@ Host-service tests: - Superset Chat is not included - `add()` copies preset fields and assigns a unique `id` - duplicate `presetId` configs are allowed -- `update()` persists `label`, `launchCommand`, and `promptInput` -- invalid `promptInput` is rejected +- `update()` persists `label`, `command`, `args`, `promptTransport`, `promptArgs`, and `env` +- invalid `promptTransport` is rejected +- empty-launch resolution drops `promptArgs` (codex has no trailing `--`, opencode has no `--prompt`, copilot has no `-i`) +- prompt-launch resolution appends `promptArgs` and (for argv transport) the prompt as the last positional +- stdin transport pipes the prompt to stdin instead of pushing it to argv +- `env` overlay is merged onto the workspace base env at launch - `remove()` deletes configs - `reorder()` persists order - `resetToDefaults()` replaces current configs From f654ef176e766b3b86149b1ee83eaf78c9001eba Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Thu, 30 Apr 2026 12:43:08 -0700 Subject: [PATCH 03/16] docs(plans): address review on prompt contract + PR1 scope canonical-workspace-create-flow.md: - Make `prompt` required on agent launches in both code blocks (was optional in the API shape but later text said agent launches require one and promptless = raw terminal). Reconciled to the stricter rule and added a Notes line pointing readers to the Agent Configs section. 20260425-host-agent-configs-pr1.md: - Tighten the Summary to make explicit that this PR ships only the storage + tRPC router + V2 settings page. V2 modal and launch dispatch remain on legacy desktop presets and move to host configs in PR 5. --- plans/20260425-canonical-workspace-create-flow.md | 6 +++--- plans/20260425-host-agent-configs-pr1.md | 6 ++++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/plans/20260425-canonical-workspace-create-flow.md b/plans/20260425-canonical-workspace-create-flow.md index 5ea833b0344..2e18b4c91aa 100644 --- a/plans/20260425-canonical-workspace-create-flow.md +++ b/plans/20260425-canonical-workspace-create-flow.md @@ -70,7 +70,7 @@ workspace.create({ | { kind: "agent"; agentId: string; - prompt?: string; + prompt: string; attachmentIds?: string[]; } >, @@ -86,7 +86,7 @@ workspace.create({ Notes: -- `prompt` is plain Markdown on each requested agent launch. +- `prompt` is required, plain Markdown, on each requested agent launch. A promptless agent invocation has no place in this contract — use a raw `{ kind: "terminal", command }` launch instead. (See "Agent Configs" below for why agent profiles focus exclusively on prompt-mode startup.) - `attachmentIds` belong to the agent launch that needs them. - Setup is not a public input. If `.superset/setup.sh` exists, host-service starts it and returns it as a terminal launch. - Attachment paths are not returned to the UI. @@ -227,7 +227,7 @@ For each requested agent launch, host-service receives: { kind: "agent", agentId: string, - prompt?: string, + prompt: string, attachmentIds?: string[], } ``` diff --git a/plans/20260425-host-agent-configs-pr1.md b/plans/20260425-host-agent-configs-pr1.md index 74c0d2e897d..489a535cd3e 100644 --- a/plans/20260425-host-agent-configs-pr1.md +++ b/plans/20260425-host-agent-configs-pr1.md @@ -2,9 +2,11 @@ ## Summary -This PR introduces the V2 agent configuration model used by host runtimes. Agent configs are stored in the active host runtime database (`host.db`), edited through the V2 Agents settings UI, and selected by the V2 new workspace modal. +This PR introduces the V2 agent configuration model used by host runtimes. Agent configs are stored in the active host runtime database (`host.db`) and edited through a new V2 Agents settings UI. -The key product rule is that the UI sends only an `agentId` when creating a workspace. The host owns the config and resolves that `agentId` locally before launching anything. +**Scope of this PR:** the data model, the host-service tRPC router, and the V2 settings page. Nothing else. The V2 new workspace modal and pending-page launch dispatch continue reading the legacy desktop presets — moving those onto host configs is **PR 5** ("Migrate Interactive Create UI" in `20260425-canonical-workspace-create-flow.md`). Splitting the scope this way keeps the data model PR small and avoids conflating it with the workspace-create rewrite. + +The eventual product rule (delivered in PR 4 + PR 5) is that the UI sends only an `agentId` when creating a workspace, and the host resolves that `agentId` locally before launching. This PR ships the storage and editing surface that contract will rely on. This PR does not migrate legacy desktop agent preset customizations into host configs. That migration should be handled later by the existing v1-to-v2 migration flow. From 504692231bdd0dc025f592392499191415f7e90f Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Sun, 3 May 2026 01:35:34 -0700 Subject: [PATCH 04/16] wip: canonical workspace create flow + optimistic attachment uploads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidate in-flight create lifecycle into one WorkspaceCreatesManager + useWorkspaceCreates hook (submit/retry/dismiss). Navigate immediately on submit; route renders creating/error/notfound states from the in-flight store. Sidebar reads the same store and supports dismiss on hover for failed entries. Optimistic attachment uploads via a module-scoped Zustand store keyed (fileId, hostUrl). Files upload to whichever host was active when added; switching hosts hides their pills without re-uploading and keeps cached attachment ids for return visits. Submit awaits in-flight uploads and joins file metadata for error messaging. Submit-time gating moved into handleSubmit so all paths (button, Enter, Cmd+Enter) respect preconditions. Plain Enter inserts a newline; submit is button or Cmd+Enter only. Server-side: workspaces.create detects an existing standard-path worktree and adopts it instead of failing on git worktree add — folds the picker's adopt path into the same submit. Layout for v2-workspace falls through to the page when no real row exists so the in-flight UI can render. Other cleanup: drop legacy in-flight-creates store + reconciler, the v2 useCreateWorkspace wrapper, useAdoptWorktree; collapse picker checkout+adopt callbacks; UploadingAttachmentPill replaces the library pill with subtle status overlays; PR link command keeps the Show closed checkbox (pagination concern); useShallow on the draft selector to fix an infinite-loop bug. --- .../host-service/useHostTargetUrl/index.ts | 3 +- .../useHostTargetUrl/resolveHostUrl.ts | 22 + .../useHostTargetUrl/useHostTargetUrl.ts | 23 +- .../renderer/lib/pending-attachment-store.ts | 101 - .../AutomationBody/AutomationBody.tsx | 6 +- .../AutomationDetailSidebar.tsx | 14 +- .../CreateAutomationDialog.tsx | 18 +- .../useProjectFileSearch.ts | 9 +- .../DashboardSidebarWorkspaceItem.tsx | 26 +- .../DashboardSidebarExpandedWorkspaceRow.tsx | 156 +- .../useDashboardSidebarData.ts | 39 +- .../_authenticated/_dashboard/layout.tsx | 2 + .../$pendingId/buildForkAgentLaunch.test.ts | 237 - .../$pendingId/buildForkAgentLaunch.ts | 556 -- .../$pendingId/buildIntentPayload.test.ts | 324 - .../pending/$pendingId/buildIntentPayload.ts | 163 - .../$pendingId/buildSetupPaneLayout.ts | 39 - .../pending/$pendingId/dispatchForkLaunch.ts | 238 - .../_dashboard/pending/$pendingId/page.tsx | 553 -- .../hooks/useConsumePendingLaunch/index.ts | 1 - .../useConsumePendingLaunch.ts | 189 - .../v2-workspace/$workspaceId/page.tsx | 25 +- .../WorkspaceCreateErrorState.tsx | 47 + .../WorkspaceCreateErrorState/index.ts | 1 + .../WorkspaceCreatingState.tsx | 30 + .../WorkspaceCreatingState/index.ts | 1 + .../_dashboard/v2-workspace/layout.tsx | 7 +- .../DashboardNewWorkspaceDraftContext.tsx | 246 +- .../PromptGroup/PromptGroup.tsx | 161 +- .../CompareBaseBranchPicker.tsx | 4 +- .../GitHubIssueLinkCommand.tsx | 9 +- .../PRLinkCommand/PRLinkCommand.tsx | 10 +- .../UploadingAttachmentPill.tsx | 53 + .../UploadingAttachmentPill/index.ts | 1 + .../useBranchPickerController.ts | 137 +- .../hooks/useSubmitWorkspace/index.ts | 5 +- .../useSubmitWorkspace/useSubmitWorkspace.ts | 177 +- .../hooks/useUploadAttachments/index.ts | 10 + .../hooks/useUploadAttachments/store.ts | 180 + .../useUploadAttachments.ts | 70 + .../components/DevicePicker/DevicePicker.tsx | 57 +- .../components/DevicePicker/index.ts | 1 - .../components/DevicePicker/types.ts | 3 - .../useBranchContext/useBranchContext.ts | 7 +- .../DashboardNewWorkspaceModalContent.tsx | 25 +- .../useSelectedHostProjectIds.ts | 7 +- .../hooks/useAdoptWorktree/index.ts | 1 - .../useAdoptWorktree/useAdoptWorktree.ts | 43 - .../useCheckoutDashboardWorkspace/index.ts | 1 - .../useCheckoutDashboardWorkspace.ts | 87 - .../useCreateDashboardWorkspace/index.ts | 1 - .../useCreateDashboardWorkspace.ts | 75 - .../CollectionsProvider/collections.ts | 19 - .../dashboardSidebarLocal/schema.ts | 117 - .../renderer/stores/new-workspace-draft.ts | 111 + .../stores/v2-workspace-create-defaults.ts | 31 +- .../stores/workspace-creates/Manager.tsx | 30 + .../stores/workspace-creates/index.ts | 11 + .../stores/workspace-creates/store.ts | 54 + .../workspace-creates/useWorkspaceCreates.ts | 130 + bun.lock | 2 +- .../src/commands/workspaces/create/command.ts | 25 +- .../db/drizzle/0043_add_workspace_tasks.sql | 10 + packages/db/drizzle/meta/0043_snapshot.json | 5912 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/schema/relations.ts | 16 +- packages/db/src/schema/schema.ts | 22 + .../src/trpc/router/agents/agents.ts | 231 + .../src/trpc/router/agents/index.ts | 8 + .../src/trpc/router/attachments/storage.ts | 40 +- .../host-service/src/trpc/router/router.ts | 4 + .../workspace-creation/procedures/checkout.ts | 285 - .../workspace-creation/procedures/create.ts | 362 - .../procedures/generate-branch-name.ts | 22 - .../procedures/get-context.ts | 27 - .../procedures/get-github-issue-content.ts | 42 - .../get-github-pull-request-content.ts | 92 - .../procedures/get-progress.ts | 14 - .../workspace-creation/procedures/index.ts | 7 - .../trpc/router/workspace-creation/schemas.ts | 87 - .../shared/finish-checkout.ts | 162 - .../shared/progress-store.ts | 54 - .../workspace-creation/workspace-creation.ts | 14 - .../src/trpc/router/workspace/workspace.ts | 113 - .../src/trpc/router/workspaces/index.ts | 1 + .../src/trpc/router/workspaces/workspaces.ts | 780 +++ .../mcp-v2/src/tools/workspaces/create.ts | 56 +- packages/sdk/src/client.ts | 10 +- packages/sdk/src/index.ts | 5 +- packages/sdk/src/resources/index.ts | 5 +- packages/sdk/src/resources/workspaces.ts | 112 +- .../trpc/src/router/automation/dispatch.ts | 24 +- .../src/router/v2-workspace/v2-workspace.ts | 212 +- ...0260425-canonical-workspace-create-flow.md | 596 +- 94 files changed, 8996 insertions(+), 5067 deletions(-) create mode 100644 apps/desktop/src/renderer/hooks/host-service/useHostTargetUrl/resolveHostUrl.ts delete mode 100644 apps/desktop/src/renderer/lib/pending-attachment-store.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildForkAgentLaunch.test.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildForkAgentLaunch.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildIntentPayload.test.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildIntentPayload.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildSetupPaneLayout.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/dispatchForkLaunch.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/page.tsx delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumePendingLaunch/index.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumePendingLaunch/useConsumePendingLaunch.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceCreateErrorState/WorkspaceCreateErrorState.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceCreateErrorState/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceCreatingState/WorkspaceCreatingState.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceCreatingState/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/UploadingAttachmentPill/UploadingAttachmentPill.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/UploadingAttachmentPill/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useUploadAttachments/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useUploadAttachments/store.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useUploadAttachments/useUploadAttachments.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/types.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useAdoptWorktree/index.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useAdoptWorktree/useAdoptWorktree.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useCheckoutDashboardWorkspace/index.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useCheckoutDashboardWorkspace/useCheckoutDashboardWorkspace.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useCreateDashboardWorkspace/index.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useCreateDashboardWorkspace/useCreateDashboardWorkspace.ts create mode 100644 apps/desktop/src/renderer/stores/new-workspace-draft.ts create mode 100644 apps/desktop/src/renderer/stores/workspace-creates/Manager.tsx create mode 100644 apps/desktop/src/renderer/stores/workspace-creates/index.ts create mode 100644 apps/desktop/src/renderer/stores/workspace-creates/store.ts create mode 100644 apps/desktop/src/renderer/stores/workspace-creates/useWorkspaceCreates.ts create mode 100644 packages/db/drizzle/0043_add_workspace_tasks.sql create mode 100644 packages/db/drizzle/meta/0043_snapshot.json create mode 100644 packages/host-service/src/trpc/router/agents/agents.ts create mode 100644 packages/host-service/src/trpc/router/agents/index.ts delete mode 100644 packages/host-service/src/trpc/router/workspace-creation/procedures/checkout.ts delete mode 100644 packages/host-service/src/trpc/router/workspace-creation/procedures/create.ts delete mode 100644 packages/host-service/src/trpc/router/workspace-creation/procedures/generate-branch-name.ts delete mode 100644 packages/host-service/src/trpc/router/workspace-creation/procedures/get-context.ts delete mode 100644 packages/host-service/src/trpc/router/workspace-creation/procedures/get-github-issue-content.ts delete mode 100644 packages/host-service/src/trpc/router/workspace-creation/procedures/get-github-pull-request-content.ts delete mode 100644 packages/host-service/src/trpc/router/workspace-creation/procedures/get-progress.ts delete mode 100644 packages/host-service/src/trpc/router/workspace-creation/shared/finish-checkout.ts delete mode 100644 packages/host-service/src/trpc/router/workspace-creation/shared/progress-store.ts create mode 100644 packages/host-service/src/trpc/router/workspaces/index.ts create mode 100644 packages/host-service/src/trpc/router/workspaces/workspaces.ts diff --git a/apps/desktop/src/renderer/hooks/host-service/useHostTargetUrl/index.ts b/apps/desktop/src/renderer/hooks/host-service/useHostTargetUrl/index.ts index c6e517c7d34..f425ba68bae 100644 --- a/apps/desktop/src/renderer/hooks/host-service/useHostTargetUrl/index.ts +++ b/apps/desktop/src/renderer/hooks/host-service/useHostTargetUrl/index.ts @@ -1 +1,2 @@ -export { useHostTargetUrl } from "./useHostTargetUrl"; +export { resolveHostUrl } from "./resolveHostUrl"; +export { useHostUrl } from "./useHostTargetUrl"; diff --git a/apps/desktop/src/renderer/hooks/host-service/useHostTargetUrl/resolveHostUrl.ts b/apps/desktop/src/renderer/hooks/host-service/useHostTargetUrl/resolveHostUrl.ts new file mode 100644 index 00000000000..f22b3899c7a --- /dev/null +++ b/apps/desktop/src/renderer/hooks/host-service/useHostTargetUrl/resolveHostUrl.ts @@ -0,0 +1,22 @@ +import { buildHostRoutingKey } from "@superset/shared/host-routing"; +import { env } from "renderer/env.renderer"; + +/** + * Pure resolver: hostId + machineId + activeHostUrl + organizationId → URL. + * Hosts other than the local machine are reached via relay; the local + * machine is reached directly via electronTrpc through `activeHostUrl`. + * + * Guaranteed-non-null inputs are typed as required because callers inside + * `_authenticated/` get organizationId from the route guard. A null at call + * time is a programmer error, not a runtime UX state. + */ +export function resolveHostUrl(args: { + hostId: string; + machineId: string | null; + activeHostUrl: string | null; + organizationId: string; +}): string | null { + if (args.hostId === args.machineId) return args.activeHostUrl; + const routingKey = buildHostRoutingKey(args.organizationId, args.hostId); + return `${env.RELAY_URL}/hosts/${routingKey}`; +} diff --git a/apps/desktop/src/renderer/hooks/host-service/useHostTargetUrl/useHostTargetUrl.ts b/apps/desktop/src/renderer/hooks/host-service/useHostTargetUrl/useHostTargetUrl.ts index a3edd0ee1a0..31e022ddd8b 100644 --- a/apps/desktop/src/renderer/hooks/host-service/useHostTargetUrl/useHostTargetUrl.ts +++ b/apps/desktop/src/renderer/hooks/host-service/useHostTargetUrl/useHostTargetUrl.ts @@ -2,24 +2,23 @@ import { buildHostRoutingKey } from "@superset/shared/host-routing"; import { useMemo } from "react"; import { env } from "renderer/env.renderer"; import { authClient } from "renderer/lib/auth-client"; -import type { WorkspaceHostTarget } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/types"; import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; -export function useHostTargetUrl( - hostTarget: WorkspaceHostTarget | null | undefined, -): string | null { - const { activeHostUrl } = useLocalHostService(); +/** + * Resolves a host machineId to a host-service URL. `null` (or `hostId === + * machineId`) routes through the local electronTrpc proxy; any other id + * routes through the relay tunnel. + */ +export function useHostUrl(hostId: string | null | undefined): string | null { + const { machineId, activeHostUrl } = useLocalHostService(); const { data: session } = authClient.useSession(); const activeOrganizationId = session?.session?.activeOrganizationId ?? null; return useMemo(() => { - if (!hostTarget) return null; - if (hostTarget.kind === "local") return activeHostUrl; + if (hostId === undefined) return null; + if (hostId === null || hostId === machineId) return activeHostUrl; if (!activeOrganizationId) return null; - const routingKey = buildHostRoutingKey( - activeOrganizationId, - hostTarget.hostId, - ); + const routingKey = buildHostRoutingKey(activeOrganizationId, hostId); return `${env.RELAY_URL}/hosts/${routingKey}`; - }, [hostTarget, activeOrganizationId, activeHostUrl]); + }, [hostId, machineId, activeOrganizationId, activeHostUrl]); } diff --git a/apps/desktop/src/renderer/lib/pending-attachment-store.ts b/apps/desktop/src/renderer/lib/pending-attachment-store.ts deleted file mode 100644 index 24473cdad3c..00000000000 --- a/apps/desktop/src/renderer/lib/pending-attachment-store.ts +++ /dev/null @@ -1,101 +0,0 @@ -import Dexie, { type Table } from "dexie"; - -/** - * IndexedDB store for pending workspace attachment blobs. Keyed by - * `${pendingId}/${uuid}` so we can prefix-query all blobs belonging - * to a single pending row on retry or cleanup. - * - * Dexie handles transaction lifecycle — no manual tx.complete waits, - * no "transaction has finished" footguns. - */ - -interface StoredAttachment { - key: string; // pendingId/uuid - blob: Blob; - mediaType: string; - filename: string; -} - -class PendingAttachmentsDb extends Dexie { - attachments!: Table; - - constructor() { - super("superset-pending-attachments"); - this.version(1).stores({ - attachments: "&key", // primary key only - }); - } -} - -const db = new PendingAttachmentsDb(); - -/** - * Store attachment blobs from the PromptInput. - * Call before closing the modal so blobs survive for retry. - */ -export async function storeAttachments( - pendingId: string, - files: Array<{ url: string; mediaType: string; filename?: string }>, -): Promise { - if (files.length === 0) return; - - const resolved = await Promise.all( - files.map(async (file) => { - const response = await fetch(file.url); - if (!response.ok) { - throw new Error( - `Failed to fetch attachment: ${response.status} ${response.statusText}`, - ); - } - const blob = await response.blob(); - return { - key: `${pendingId}/${crypto.randomUUID()}`, - blob, - mediaType: file.mediaType, - filename: file.filename ?? "attachment", - } satisfies StoredAttachment; - }), - ); - - await db.attachments.bulkPut(resolved); -} - -/** - * Load stored attachment blobs and convert them to data URLs - * for the API payload. Used on retry. - */ -export async function loadAttachments( - pendingId: string, -): Promise> { - const prefix = `${pendingId}/`; - const entries = await db.attachments - .where("key") - .startsWith(prefix) - .toArray(); - - return Promise.all( - entries.map(async (entry) => ({ - data: await blobToDataUrl(entry.blob), - mediaType: entry.mediaType, - filename: entry.filename, - })), - ); -} - -/** - * Delete all stored attachments for a pending workspace. - * Call on create success or dismiss. - */ -export async function clearAttachments(pendingId: string): Promise { - const prefix = `${pendingId}/`; - await db.attachments.where("key").startsWith(prefix).delete(); -} - -function blobToDataUrl(blob: Blob): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onloadend = () => resolve(reader.result as string); - reader.onerror = () => reject(reader.error); - reader.readAsDataURL(blob); - }); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/AutomationBody/AutomationBody.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/AutomationBody/AutomationBody.tsx index a232197b59b..3f62298d02d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/AutomationBody/AutomationBody.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/AutomationBody/AutomationBody.tsx @@ -4,7 +4,6 @@ import { useEffect, useRef, useState } from "react"; import { EmojiTextInput } from "renderer/components/EmojiTextInput"; import { MarkdownEditor } from "renderer/components/MarkdownEditor"; import { apiTrpcClient } from "renderer/lib/api-trpc-client"; -import type { WorkspaceHostTarget } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/types"; import { useProjectFileSearch } from "../../../hooks/useProjectFileSearch"; export function AutomationBody({ @@ -42,11 +41,8 @@ export function AutomationBody({ }, }); - const hostTarget: WorkspaceHostTarget = automation.targetHostId - ? { kind: "host", hostId: automation.targetHostId } - : { kind: "local" }; const searchFiles = useProjectFileSearch({ - hostTarget, + hostId: automation.targetHostId ?? null, projectId: automation.v2ProjectId, }); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/AutomationDetailSidebar/AutomationDetailSidebar.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/AutomationDetailSidebar/AutomationDetailSidebar.tsx index bcf42853e57..d704665d6a0 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/AutomationDetailSidebar/AutomationDetailSidebar.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/AutomationDetailSidebar/AutomationDetailSidebar.tsx @@ -9,7 +9,6 @@ import { useEnabledAgents } from "renderer/hooks/useEnabledAgents"; import { apiTrpcClient } from "renderer/lib/api-trpc-client"; import { DevicePicker } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker"; import { useWorkspaceHostOptions } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/hooks/useWorkspaceHostOptions/useWorkspaceHostOptions"; -import type { WorkspaceHostTarget } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/types"; import { AgentPicker } from "../../../components/AgentPicker"; import { ProjectPicker } from "../../../components/ProjectPicker"; import { SchedulePicker } from "../../../components/SchedulePicker"; @@ -37,10 +36,7 @@ export function AutomationDetailSidebar({ (p) => p.id === automation.v2ProjectId, ); - const hostTarget: WorkspaceHostTarget = - automation.targetHostId && automation.targetHostId !== localHostId - ? { kind: "host", hostId: automation.targetHostId } - : { kind: "local" }; + const hostId = automation.targetHostId ?? localHostId ?? null; const updateMutation = useMutation({ mutationFn: ( @@ -104,12 +100,8 @@ export function AutomationDetailSidebar({ value={ { - const nextHostId = - target.kind === "host" - ? target.hostId - : (localHostId ?? null); + hostId={hostId} + onSelectHostId={(nextHostId) => { updateMutation.mutate({ targetHostId: nextHostId }); }} /> diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/CreateAutomationDialog/CreateAutomationDialog.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/CreateAutomationDialog/CreateAutomationDialog.tsx index 7109616971f..2552b0dafbc 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/CreateAutomationDialog/CreateAutomationDialog.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/CreateAutomationDialog/CreateAutomationDialog.tsx @@ -17,7 +17,6 @@ import { useEnabledAgents } from "renderer/hooks/useEnabledAgents"; import { apiTrpcClient } from "renderer/lib/api-trpc-client"; import { DevicePicker } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker"; import { useWorkspaceHostOptions } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/hooks/useWorkspaceHostOptions/useWorkspaceHostOptions"; -import type { WorkspaceHostTarget } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/types"; import { hideAll as hideAllTippy } from "tippy.js"; import { useProjectFileSearch } from "../../hooks/useProjectFileSearch"; import { useRecentProjects } from "../../hooks/useRecentProjects"; @@ -51,9 +50,7 @@ export function CreateAutomationDialog({ const [view, setView] = useState<"compose" | "gallery">("compose"); const [name, setName] = useState(""); const [prompt, setPrompt] = useState(""); - const [hostTarget, setHostTarget] = useState({ - kind: "local", - }); + const [hostId, setHostId] = useState(null); const [selectedProjectId, setSelectedProjectId] = useState( null, ); @@ -65,7 +62,7 @@ export function CreateAutomationDialog({ const recentProjects = useRecentProjects(); const { agents: enabledAgents } = useEnabledAgents(); const searchFiles = useProjectFileSearch({ - hostTarget, + hostId, projectId: selectedProjectId, }); const selectedProject = recentProjects.find( @@ -102,7 +99,7 @@ export function CreateAutomationDialog({ setView("compose"); setName(""); setPrompt(""); - setHostTarget({ kind: "local" }); + setHostId(null); setSelectedProjectId(null); setAgentType("claude"); setRrule(DEFAULT_RRULE); @@ -110,8 +107,7 @@ export function CreateAutomationDialog({ } }, [open]); - const targetHostId = - hostTarget.kind === "host" ? hostTarget.hostId : localHostId; + const targetHostId = hostId ?? localHostId; const createMutation = useMutation({ mutationFn: () => { @@ -233,9 +229,9 @@ export function CreateAutomationDialog({
{ - setHostTarget(next); + hostId={hostId} + onSelectHostId={(next) => { + setHostId(next); setV2WorkspaceId(null); }} /> diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/hooks/useProjectFileSearch/useProjectFileSearch.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/hooks/useProjectFileSearch/useProjectFileSearch.ts index 116930ee6be..8ae752d0ae1 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/hooks/useProjectFileSearch/useProjectFileSearch.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/hooks/useProjectFileSearch/useProjectFileSearch.ts @@ -1,19 +1,18 @@ import { useCallback } from "react"; import type { FileMentionSearchFn } from "renderer/components/MarkdownEditor/components/FileMention"; -import { useHostTargetUrl } from "renderer/hooks/host-service/useHostTargetUrl"; +import { useHostUrl } from "renderer/hooks/host-service/useHostTargetUrl"; import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; -import type { WorkspaceHostTarget } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/types"; const SEARCH_LIMIT = 15; export function useProjectFileSearch({ - hostTarget, + hostId, projectId, }: { - hostTarget: WorkspaceHostTarget; + hostId: string | null; projectId: string | null; }): FileMentionSearchFn | undefined { - const hostUrl = useHostTargetUrl(hostTarget); + const hostUrl = useHostUrl(hostId); return useCallback( async (query) => { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx index 10b79b82d02..a45ffe0fdda 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx @@ -5,6 +5,7 @@ import { useOptimisticCollectionActions } from "renderer/routes/_authenticated/h import { useDeletingWorkspaces } from "renderer/routes/_authenticated/providers/DeletingWorkspacesProvider"; import { RenameBranchDialog } from "renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components"; import { useV2WorkspaceNotificationStatus } from "renderer/stores/v2-notifications"; +import { useWorkspaceCreatesStore } from "renderer/stores/workspace-creates"; import { useDashboardSidebarHover } from "../../providers/DashboardSidebarHoverProvider"; import type { DashboardSidebarWorkspace } from "../../types"; import { DashboardSidebarDeleteDialog } from "../DashboardSidebarDeleteDialog"; @@ -69,7 +70,7 @@ export function DashboardSidebarWorkspaceItem({ isMainWorkspace, }); - const navigate = useNavigate(); + const _navigate = useNavigate(); const { v2Workspaces: v2WorkspaceActions } = useOptimisticCollectionActions(); const [renameBranchTarget, setRenameBranchTarget] = useState( null, @@ -78,16 +79,14 @@ export function DashboardSidebarWorkspaceItem({ v2WorkspaceActions.updateWorkspace(id, { branch: newBranchName }); }; const isPending = !!creationStatus; + const isFailedInFlight = creationStatus === "failed"; // Keep the delete dialog outside the hidden wrapper below — the destroy // flow reopens it into an error pane on conflict/teardown-failed. const isDeleting = useDeletingWorkspaces().isDeleting(id); - const handlePendingClick = isPending - ? () => { - void navigate({ - to: `/pending/${id}` as string, - }); - } - : undefined; + + const handleDismissInFlight = useCallback(() => { + useWorkspaceCreatesStore.getState().remove(id); + }, [id]); const { hoveredId: hoverHoveredId, @@ -143,9 +142,8 @@ export function DashboardSidebarWorkspaceItem({ hostIsOnline={hostIsOnline} isActive={isActive} workspaceStatus={workspaceStatus} - onClick={isPending ? handlePendingClick : handleClick} + onClick={handleClick} creationStatus={creationStatus} - disabled={isPending} aria-label={ creationStatus ? `Creating workspace: ${name}` : undefined } @@ -223,10 +221,14 @@ export function DashboardSidebarWorkspaceItem({ diffStats={isPending ? null : diffStats} workspaceStatus={workspaceStatus} isInSection={isInSection} - onClick={isPending ? handlePendingClick : handleClick} + onClick={handleClick} onDoubleClick={isPending ? undefined : startRename} onRemoveFromSidebarClick={handleRemoveFromSidebar} - onCloseWorkspaceClick={() => setIsDeleteDialogOpen(true)} + onCloseWorkspaceClick={ + isFailedInFlight + ? handleDismissInFlight + : () => setIsDeleteDialogOpen(true) + } onRenameValueChange={setRenameValue} onSubmitRename={submitRename} onCancelRename={cancelRename} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/DashboardSidebarExpandedWorkspaceRow.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/DashboardSidebarExpandedWorkspaceRow.tsx index 506ab6d4520..d25fc5d1e5c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/DashboardSidebarExpandedWorkspaceRow.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/DashboardSidebarExpandedWorkspaceRow.tsx @@ -254,96 +254,104 @@ export const DashboardSidebarExpandedWorkspaceRow = forwardRef< )} -
+
{creationStatusText ? ( {creationStatusText} ) : ( - <> - {diffStats && - (diffStats.additions > 0 || diffStats.deletions > 0) && ( - - )} -
- {shortcutLabel && ( - - {shortcutLabel} - - )} - {isMainWorkspace ? ( - - - - - - - - - ) : ( - - - + + + + + + ) : ( + + + - - + } + }} + className="flex items-center justify-center text-muted-foreground hover:text-foreground" + aria-label={ + creationStatus === "failed" + ? "Dismiss" + : "Close workspace" + } + > + + + + + {creationStatus === "failed" ? ( + "Dismiss" + ) : ( - - - )} -
- + )} + + + )} +
)}
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts index 411df3dfc71..3e03edaf52d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts @@ -8,6 +8,7 @@ import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/u import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import { getVisibleSidebarWorkspaces } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal"; import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; +import { useWorkspaceCreatesStore } from "renderer/stores/workspace-creates"; import type { DashboardSidebarProject, DashboardSidebarProjectChild, @@ -131,17 +132,25 @@ export function useDashboardSidebarData() { const { toggleProjectCollapsed } = useDashboardSidebarState(); const queryClient = useQueryClient(); - // Query pending workspaces from the local collection - const { data: pendingWorkspaces = [] } = useLiveQuery( - (q) => - q.from({ pw: collections.pendingWorkspaces }).select(({ pw }) => ({ - id: pw.id, - projectId: pw.projectId, - name: pw.name, - branchName: pw.branchName, - status: pw.status, - })), - [collections], + // In-flight workspace.create operations. These don't have a backing DB row + // — they're kept in renderer memory until the real v2Workspaces row arrives + // via Electric sync (or until error/dismiss). + const inFlightEntries = useWorkspaceCreatesStore((store) => store.entries); + const inFlightSidebarRows = useMemo( + () => + inFlightEntries + .filter((entry) => entry.snapshot.id !== undefined) + .map((entry) => ({ + id: entry.snapshot.id as string, + projectId: entry.snapshot.projectId, + name: entry.snapshot.name, + branchName: entry.snapshot.branch ?? entry.snapshot.name, + status: + entry.state === "creating" + ? ("creating" as const) + : ("failed" as const), + })), + [inFlightEntries], ); const { data: hosts = [] } = useLiveQuery( @@ -453,9 +462,9 @@ export function useDashboardSidebarData() { }); } - // Inject pending workspaces (creating / failed) - for (const pw of pendingWorkspaces) { - if (pw.status === "succeeded") continue; // will appear as a real workspace + // Inject in-flight workspaces (creating / failed) from the renderer-side + // in-flight store. + for (const pw of inFlightSidebarRows) { const project = projectsById.get(pw.projectId); if (!project) continue; @@ -530,7 +539,7 @@ export function useDashboardSidebarData() { }, [ machineId, pullRequestsByWorkspaceId, - pendingWorkspaces, + inFlightSidebarRows, sidebarProjects, sidebarSections, visibleSidebarWorkspaces, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx index bb14377eca4..362b36d5bbe 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx @@ -15,6 +15,7 @@ import { ResizablePanel } from "renderer/screens/main/components/ResizablePanel" import { WorkspaceSidebar } from "renderer/screens/main/components/WorkspaceSidebar"; import { DeleteWorkspaceDialog } from "renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components"; import { useOpenNewWorkspaceModal } from "renderer/stores/new-workspace-modal"; +import { WorkspaceCreatesManager } from "renderer/stores/workspace-creates"; import { COLLAPSED_WORKSPACE_SIDEBAR_WIDTH, DEFAULT_WORKSPACE_SIDEBAR_WIDTH, @@ -128,6 +129,7 @@ function DashboardLayout() { return (
+ {sidebarOutsideColumn && sidebarPanel}
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildForkAgentLaunch.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildForkAgentLaunch.test.ts deleted file mode 100644 index 0bcd171395b..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildForkAgentLaunch.test.ts +++ /dev/null @@ -1,237 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { resolveAgentConfigs } from "@superset/shared/agent-settings"; -import { - buildForkAgentLaunch, - buildLaunchSourcesFromPending, -} from "./buildForkAgentLaunch"; - -const PROJECT_ID = "proj-1"; - -function pendingBase( - overrides: Partial[0]> = {}, -): Parameters[0] { - return { - projectId: PROJECT_ID, - prompt: "", - linkedIssues: [], - linkedPR: null, - agentId: null, - ...overrides, - }; -} - -describe("buildLaunchSourcesFromPending", () => { - test("returns [] when everything is empty", () => { - expect(buildLaunchSourcesFromPending(pendingBase(), undefined)).toEqual([]); - }); - - test("produces user-prompt source when prompt is non-empty", () => { - const sources = buildLaunchSourcesFromPending( - pendingBase({ prompt: "refactor auth" }), - undefined, - ); - expect(sources).toEqual([ - { - kind: "user-prompt", - content: [{ type: "text", text: "refactor auth" }], - }, - ]); - }); - - test("trims whitespace-only prompts out", () => { - const sources = buildLaunchSourcesFromPending( - pendingBase({ prompt: " \n " }), - undefined, - ); - expect(sources.filter((s) => s.kind === "user-prompt")).toEqual([]); - }); - - test("orders sources: user-prompt, task, issue, pr, attachment", () => { - const sources = buildLaunchSourcesFromPending( - pendingBase({ - prompt: "fix", - linkedIssues: [ - { source: "internal", taskId: "T-1", slug: "s", title: "t" }, - { - source: "github", - url: "https://x/issues/9", - number: 9, - slug: "s", - title: "t", - state: "open", - }, - ], - linkedPR: { - prNumber: 1, - url: "https://x/pull/1", - title: "t", - state: "open", - }, - }), - [ - { - data: "data:text/plain;base64,AA==", - mediaType: "text/plain", - filename: "a.txt", - }, - ], - ); - expect(sources.map((s) => s.kind)).toEqual([ - "user-prompt", - "internal-task", - "github-issue", - "github-pr", - "attachment", - ]); - }); - - test("decodes base64 data URLs to Uint8Array", () => { - const sources = buildLaunchSourcesFromPending(pendingBase(), [ - { - data: "data:text/plain;base64,AQID", - mediaType: "text/plain", - filename: "logs.txt", - }, - ]); - expect(sources).toHaveLength(1); - const source = sources[0]; - if (source?.kind !== "attachment") throw new Error("wrong kind"); - expect(source.file.filename).toBe("logs.txt"); - expect(Array.from(source.file.data)).toEqual([1, 2, 3]); - }); -}); - -describe("buildForkAgentLaunch", () => { - const agentConfigs = resolveAgentConfigs({}); - - test("returns null when there are no sources", async () => { - const build = await buildForkAgentLaunch({ - pending: pendingBase(), - attachments: undefined, - agentConfigs, - }); - expect(build).toBeNull(); - }); - - test("returns null when there are no enabled agents", async () => { - const build = await buildForkAgentLaunch({ - pending: pendingBase({ prompt: "hi" }), - attachments: undefined, - agentConfigs: [], - }); - expect(build).toBeNull(); - }); - - test("selected claude agent → terminal launch", async () => { - const build = await buildForkAgentLaunch({ - pending: pendingBase({ - prompt: "refactor the auth middleware", - agentId: "claude", - }), - attachments: undefined, - agentConfigs, - }); - expect(build?.kind).toBe("terminal"); - if (build?.kind !== "terminal") throw new Error("wrong kind"); - expect(build.launch.name).toBe("Claude"); - expect(build.launch.command).toContain("claude"); - expect(build.launch.command).toContain("refactor the auth middleware"); - expect(build.launch.attachmentNames).toEqual([]); - expect(build.attachmentsToWrite).toEqual([]); - }); - - test("linked internal task renders into the command", async () => { - const build = await buildForkAgentLaunch({ - pending: pendingBase({ - prompt: "do it", - agentId: "claude", - linkedIssues: [ - { - source: "internal", - taskId: "TASK-42", - slug: "refactor-auth", - title: "Refactor auth", - }, - ], - }), - attachments: undefined, - agentConfigs, - }); - if (build?.kind !== "terminal") throw new Error("wrong kind"); - expect(build.launch.command).toContain("Refactor auth"); - }); - - test("attachments produce disk-ready bytes + matching names", async () => { - const build = await buildForkAgentLaunch({ - pending: pendingBase({ prompt: "fix", agentId: "claude" }), - attachments: [ - { - data: "data:text/plain;base64,AQID", // [1,2,3] - mediaType: "text/plain", - filename: "logs.txt", - }, - ], - agentConfigs, - }); - if (build?.kind !== "terminal") throw new Error("wrong kind"); - expect(build.attachmentsToWrite).toHaveLength(1); - expect(build.attachmentsToWrite[0]?.filename).toBe("logs.txt"); - expect(Array.from(build.attachmentsToWrite[0]?.data ?? [])).toEqual([ - 1, 2, 3, - ]); - expect(build.launch.attachmentNames).toEqual(["logs.txt"]); - }); - - test("chat agent → chat launch with initialPrompt + files", async () => { - const build = await buildForkAgentLaunch({ - pending: pendingBase({ - prompt: "help me refactor", - agentId: "superset-chat", - }), - attachments: [ - { - data: "data:text/plain;base64,AQID", - mediaType: "text/plain", - filename: "logs.txt", - }, - ], - agentConfigs, - }); - expect(build?.kind).toBe("chat"); - if (build?.kind !== "chat") throw new Error("wrong kind"); - expect(build.launch.initialPrompt).toContain("help me refactor"); - expect(build.launch.initialFiles).toHaveLength(1); - expect(build.launch.initialFiles?.[0]?.data).toBe( - "data:text/plain;base64,AQID", - ); - expect(build.launch.initialFiles?.[0]?.filename).toBe("logs.txt"); - }); - - test("disabled agent → null", async () => { - const disabled = agentConfigs.map((c) => ({ ...c, enabled: false })); - const build = await buildForkAgentLaunch({ - pending: pendingBase({ prompt: "hi", agentId: "claude" }), - attachments: undefined, - agentConfigs: disabled, - }); - expect(build).toBeNull(); - }); - - test("agentId null → null (no user selection, no launch)", async () => { - const build = await buildForkAgentLaunch({ - pending: pendingBase({ prompt: "hi", agentId: null }), - attachments: undefined, - agentConfigs, - }); - expect(build).toBeNull(); - }); - - test('agentId "none" → null (explicit opt-out)', async () => { - const build = await buildForkAgentLaunch({ - pending: pendingBase({ prompt: "hi", agentId: "none" }), - attachments: undefined, - agentConfigs, - }); - expect(build).toBeNull(); - }); -}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildForkAgentLaunch.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildForkAgentLaunch.ts deleted file mode 100644 index ee67e8a95fb..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildForkAgentLaunch.ts +++ /dev/null @@ -1,556 +0,0 @@ -import { - type AgentDefinitionId, - isTerminalAgentDefinition, -} from "@superset/shared/agent-catalog"; -import { - buildPromptCommandFromAgentConfig, - getCommandFromAgentConfig, - indexResolvedAgentConfigs, - type ResolvedAgentConfig, -} from "@superset/shared/agent-settings"; -import { apiTrpcClient } from "renderer/lib/api-trpc-client"; -import type { - PendingChatLaunch, - PendingTerminalLaunch, - PendingWorkspaceRow, -} from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema"; -import { buildLaunchSpec } from "shared/context/buildLaunchSpec"; -import { buildLaunchContext } from "shared/context/composer"; -import { defaultContributorRegistry } from "shared/context/contributors"; -import type { - AgentLaunchSpec, - AttachmentFile, - ContentPart, - LaunchSource, - ResolveCtx, -} from "shared/context/types"; - -export interface LoadedAttachment { - data: string; // base64 data URL - mediaType: string; - filename: string; -} - -export interface ResolvedPrContent { - number: number; - title: string; - body: string; - url: string; - branch: string; -} - -export interface BuildForkAgentLaunchInputs { - pending: Pick< - PendingWorkspaceRow, - "projectId" | "prompt" | "linkedIssues" | "linkedPR" | "agentId" - >; - attachments: LoadedAttachment[] | undefined; - agentConfigs: ResolvedAgentConfig[]; - /** - * Host-service client for fetching issue/PR bodies. When provided, - * the resolvers call `getGitHubIssueContent` / `getGitHubPullRequestContent` - * for full bodies. When null, falls back to title-only from the pending row. - */ - hostServiceClient?: { - workspaceCreation: { - getGitHubIssueContent: { - query: (input: { projectId: string; issueNumber: number }) => Promise<{ - number: number; - title: string; - body: string; - url: string; - state: string; - author: string | null; - }>; - }; - getGitHubPullRequestContent: { - query: (input: { projectId: string; prNumber: number }) => Promise<{ - number: number; - title: string; - body: string; - url: string; - state: string; - branch: string; - baseBranch: string; - headRepositoryOwner: string | null; - isCrossRepository: boolean; - author: string | null; - }>; - }; - }; - }; - /** - * Pre-resolved PR content. Used by the pr-checkout flow to avoid a - * redundant `getGitHubPullRequestContent` call — the pending page - * already fetched this once to build the mutation payload, so we - * thread it through rather than re-fetching inside `fetchPullRequest`. - */ - resolvedPr?: ResolvedPrContent; -} - -/** - * The pending page writes one of these to the pending row after - * host-service.create resolves; the V2 workspace page consumes it on - * mount. See apps/desktop/docs/V2_LAUNCH_CONTEXT.md. - */ -export type PendingLaunchBuild = - | { - kind: "terminal"; - launch: PendingTerminalLaunch; - /** - * Binary payloads to write to `/.superset/attachments/` - * via workspaceTrpc.filesystem before setting `row.terminalLaunch`. - * Already named with collision-safe filenames matching - * `launch.attachmentNames` and any inline refs in `launch.command`. - */ - attachmentsToWrite: Array<{ - filename: string; - mediaType: string; - data: Uint8Array; - }>; - } - | { kind: "chat"; launch: PendingChatLaunch }; - -/** - * Builds a PendingLaunchBuild record describing how the V2 workspace - * page should dispatch the agent once it mounts. The pending page owns - * applying this to the pending row (and writing terminal attachments - * to disk). Returns null for no-op launches (e.g. no sources, no agent - * enabled). - * - * When `hostServiceClient` is passed in, issues and PRs get full bodies - * fetched via host-service. Internal tasks get descriptions fetched via - * the cloud API (apiTrpcClient.task.byId). Either fetch failing - * degrades to title-only from the pending row — non-fatal. - */ -export async function buildForkAgentLaunch( - inputs: BuildForkAgentLaunchInputs, -): Promise { - const agentId = resolveAgentId(inputs.pending.agentId, inputs.agentConfigs); - if (!agentId) return null; - - const agentConfig = indexResolvedAgentConfigs(inputs.agentConfigs).get( - agentId, - ); - if (!agentConfig || !agentConfig.enabled) return null; - - const sources = buildLaunchSourcesFromPending( - inputs.pending, - inputs.attachments, - ); - if (sources.length === 0) return null; - - const ctx = await buildLaunchContext( - { - projectId: inputs.pending.projectId, - sources, - agent: { id: agentId }, - }, - { - contributors: defaultContributorRegistry, - resolveCtx: buildResolveCtxFromPending( - inputs.pending, - inputs.hostServiceClient, - inputs.resolvedPr, - ), - }, - ); - const spec = buildLaunchSpec(ctx, agentConfig); - if (!spec) return null; - - if (isTerminalAgentDefinition(agentConfig)) { - return buildTerminalLaunch(spec, agentConfig); - } - return buildChatLaunch(spec, agentConfig); -} - -function resolveAgentId( - selected: string | null, - configs: ResolvedAgentConfig[], -): AgentDefinitionId | null { - if (!selected || selected === "none") return null; - const match = indexResolvedAgentConfigs(configs).get( - selected as AgentDefinitionId, - ); - return match?.enabled ? match.id : null; -} - -// --------------------------------------------------------------------------- -// Terminal launch assembly -// --------------------------------------------------------------------------- - -function buildTerminalLaunch( - spec: AgentLaunchSpec, - agentConfig: Extract, -): PendingLaunchBuild | null { - const { attachmentsToWrite, inlineByIndex } = assignFilenamesAndCollect( - spec.user, - spec.attachments, - ); - const promptText = flattenUserContentForTerminal(spec.user, inlineByIndex); - - const command = promptText.trim() - ? buildPromptCommandFromAgentConfig({ - prompt: promptText, - randomId: crypto.randomUUID(), - config: agentConfig, - }) - : getCommandFromAgentConfig(agentConfig); - if (!command) return null; - - return { - kind: "terminal", - launch: { - command, - name: agentConfig.label, - attachmentNames: attachmentsToWrite.map((a) => a.filename), - }, - attachmentsToWrite, - }; -} - -function flattenUserContentForTerminal( - user: ContentPart[], - inlineByIndex: Map, -): string { - const out: string[] = []; - user.forEach((part, index) => { - if (part.type === "text") { - out.push(part.text); - return; - } - const filename = inlineByIndex.get(index); - if (!filename) return; - out.push(`![${filename}](.superset/attachments/${filename})`); - }); - return out.join("").trim(); -} - -// --------------------------------------------------------------------------- -// Chat launch assembly -// --------------------------------------------------------------------------- - -function buildChatLaunch( - spec: AgentLaunchSpec, - agentConfig: Extract, -): PendingLaunchBuild { - const initialPrompt = extractTextParts(spec.user).join("\n\n").trim(); - const binaries = [ - ...spec.user.filter((p) => p.type !== "text"), - ...spec.attachments.filter((p) => p.type !== "text"), - ]; - const initialFiles = binaries.length - ? binaries.map((part) => ({ - data: toBase64DataUrl(part), - mediaType: part.mediaType, - filename: part.type === "file" ? part.filename : undefined, - })) - : undefined; - - return { - kind: "chat", - launch: { - initialPrompt: initialPrompt || undefined, - initialFiles, - model: agentConfig.model, - taskSlug: spec.taskSlug, - }, - }; -} - -function extractTextParts(parts: ContentPart[]): string[] { - return parts - .filter( - (p): p is Extract => p.type === "text", - ) - .map((p) => p.text); -} - -function toBase64DataUrl(part: Exclude): string { - return `data:${part.mediaType};base64,${bytesToBase64(part.data)}`; -} - -function bytesToBase64(bytes: Uint8Array): string { - let binary = ""; - for (let i = 0; i < bytes.length; i++) { - binary += String.fromCharCode(bytes[i] ?? 0); - } - return btoa(binary); -} - -function base64ToBytes(b64: string): Uint8Array { - const binary = atob(b64); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); - return bytes; -} - -// --------------------------------------------------------------------------- -// Shared: collect binary parts into disk-ready attachments with stable names -// --------------------------------------------------------------------------- - -function assignFilenamesAndCollect( - user: ContentPart[], - attachments: ContentPart[], -): { - attachmentsToWrite: Array<{ - filename: string; - mediaType: string; - data: Uint8Array; - }>; - inlineByIndex: Map; -} { - const used = new Set(); - const out: Array<{ filename: string; mediaType: string; data: Uint8Array }> = - []; - const inlineByIndex = new Map(); - - user.forEach((part, index) => { - if (part.type === "text") return; - const filename = nextUniqueName(part, used, out.length); - inlineByIndex.set(index, filename); - out.push({ filename, mediaType: part.mediaType, data: part.data }); - }); - - for (const part of attachments) { - if (part.type === "text") continue; - const filename = nextUniqueName(part, used, out.length); - out.push({ filename, mediaType: part.mediaType, data: part.data }); - } - - return { attachmentsToWrite: out, inlineByIndex }; -} - -function nextUniqueName( - part: Exclude, - used: Set, - fallbackIndex: number, -): string { - const raw = part.type === "file" ? part.filename : undefined; - const sanitized = raw ? sanitizeFilename(raw) : ""; - let name = sanitized; - if (!name) { - let counter = fallbackIndex + 1; - do { - name = `attachment_${counter}`; - counter++; - } while (used.has(name)); - } else if (used.has(name)) { - const segs = name.split("."); - const ext = segs.length > 1 ? segs.pop() : undefined; - const base = segs.join("."); - let counter = 1; - let candidate: string; - do { - candidate = ext ? `${base}_${counter}.${ext}` : `${name}_${counter}`; - counter++; - } while (used.has(candidate)); - name = candidate; - } - used.add(name); - return name; -} - -function sanitizeFilename(filename: string): string { - const cleaned = filename.replace(/[^a-zA-Z0-9._-]/g, "_"); - return cleaned.trim() ? cleaned : ""; -} - -// --------------------------------------------------------------------------- -// Source + ResolveCtx (unchanged from prior implementation) -// --------------------------------------------------------------------------- - -export function buildLaunchSourcesFromPending( - pending: BuildForkAgentLaunchInputs["pending"], - attachments: LoadedAttachment[] | undefined, -): LaunchSource[] { - const sources: LaunchSource[] = []; - - const prompt = pending.prompt?.trim(); - if (prompt) { - sources.push({ - kind: "user-prompt", - content: [{ type: "text", text: prompt }], - }); - } - - for (const issue of pending.linkedIssues) { - if (issue.source === "internal" && issue.taskId) { - sources.push({ kind: "internal-task", id: issue.taskId }); - } else if (issue.source === "github" && issue.url) { - sources.push({ kind: "github-issue", url: issue.url }); - } - } - - if (pending.linkedPR?.url) { - sources.push({ kind: "github-pr", url: pending.linkedPR.url }); - } - - for (const attachment of attachments ?? []) { - sources.push({ - kind: "attachment", - file: dataUrlAttachmentToBytes(attachment), - }); - } - - return sources; -} - -function dataUrlAttachmentToBytes(loaded: LoadedAttachment): AttachmentFile { - const match = loaded.data.match(/^data:[^;]+;base64,(.+)$/); - const base64 = match?.[1] ?? ""; - return { - data: base64ToBytes(base64), - mediaType: loaded.mediaType, - filename: loaded.filename, - }; -} - -function slugifyTitle(title: string): string { - return title - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-|-$/g, "") - .slice(0, 80); -} - -function buildResolveCtxFromPending( - pending: BuildForkAgentLaunchInputs["pending"], - client?: BuildForkAgentLaunchInputs["hostServiceClient"], - resolvedPr?: ResolvedPrContent, -): ResolveCtx { - return { - projectId: pending.projectId, - signal: new AbortController().signal, - - fetchIssue: async (url) => { - const match = pending.linkedIssues.find( - (i) => i.source === "github" && i.url === url, - ); - if (!match) { - throw Object.assign(new Error(`Issue not found: ${url}`), { - status: 404, - }); - } - - // Try host-service for full body; fall back to pending-row metadata. - if (client && match.number) { - try { - const data = - await client.workspaceCreation.getGitHubIssueContent.query({ - projectId: pending.projectId, - issueNumber: match.number, - }); - return { - number: data.number, - url: data.url, - title: data.title, - body: data.body, - slug: match.slug || slugifyTitle(data.title), - }; - } catch (err) { - console.warn( - `[v2-launch] getGitHubIssueContent failed for #${match.number}, using title-only`, - err, - ); - } - } - - return { - number: match.number ?? 0, - url: match.url ?? url, - title: match.title, - body: "", - slug: match.slug, - }; - }, - - fetchPullRequest: async (url) => { - if (!pending.linkedPR || pending.linkedPR.url !== url) { - throw Object.assign(new Error(`PR not found: ${url}`), { - status: 404, - }); - } - - // Pre-resolved from the pending page (pr-checkout path) — skip - // the redundant host-service call. The mutation payload already - // used the same `getGitHubPullRequestContent` response. - if (resolvedPr && resolvedPr.url === url) { - return { - number: resolvedPr.number, - url: resolvedPr.url, - title: resolvedPr.title, - body: resolvedPr.body, - branch: resolvedPr.branch, - }; - } - - // Try host-service for full body + branch; fall back to pending-row. - if (client) { - try { - const data = - await client.workspaceCreation.getGitHubPullRequestContent.query({ - projectId: pending.projectId, - prNumber: pending.linkedPR.prNumber, - }); - return { - number: data.number, - url: data.url, - title: data.title, - body: data.body, - branch: data.branch, - }; - } catch (err) { - console.warn( - `[v2-launch] getGitHubPullRequestContent failed for #${pending.linkedPR.prNumber}, using title-only`, - err, - ); - } - } - - return { - number: pending.linkedPR.prNumber, - url: pending.linkedPR.url, - title: pending.linkedPR.title, - body: "", - branch: "", - }; - }, - - fetchInternalTask: async (id) => { - const match = pending.linkedIssues.find( - (i) => i.source === "internal" && i.taskId === id, - ); - if (!match) { - throw Object.assign(new Error(`Task not found: ${id}`), { - status: 404, - }); - } - - // Fetch full task from Superset cloud API (same source as task view). - try { - const task = await apiTrpcClient.task.byId.query(id); - if (task) { - return { - id: task.id, - slug: match.slug || slugifyTitle(task.title), - title: task.title, - description: task.description ?? null, - }; - } - } catch (err) { - console.warn( - `[v2-launch] task.byId failed for ${id}, using title-only`, - err, - ); - } - - return { - id, - slug: match.slug, - title: match.title, - description: null, - }; - }, - }; -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildIntentPayload.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildIntentPayload.test.ts deleted file mode 100644 index 9d51cc26f84..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildIntentPayload.test.ts +++ /dev/null @@ -1,324 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { PendingWorkspaceRow } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema"; -import { - buildAdoptPayload, - buildCheckoutPayload, - buildForkPayload, - buildPrCheckoutPayload, - mapLinkedContextFromPending, -} from "./buildIntentPayload"; - -function makePending( - overrides: Partial = {}, -): PendingWorkspaceRow { - return { - id: "11111111-1111-1111-1111-111111111111", - projectId: "22222222-2222-2222-2222-222222222222", - hostTarget: { kind: "local" }, - intent: "fork", - name: "my-workspace", - workspaceNameWasAutoGenerated: true, - branchName: "feature-foo", - status: "creating", - error: null, - workspaceId: null, - warnings: [], - terminals: [], - createdAt: new Date("2026-04-13T00:00:00Z"), - prompt: "", - baseBranch: null, - baseBranchSource: null, - linkedIssues: [], - linkedPR: null, - attachmentCount: 0, - runSetupScript: true, - terminalLaunch: null, - chatLaunch: null, - agentId: null, - ...overrides, - }; -} - -describe("mapLinkedContextFromPending", () => { - test("extracts internal task ids from linkedIssues", () => { - const mapped = mapLinkedContextFromPending({ - linkedIssues: [ - { slug: "SUP-1", title: "a", source: "internal", taskId: "t1" }, - { slug: "SUP-2", title: "b", source: "internal", taskId: "t2" }, - ], - linkedPR: null, - }); - expect(mapped.internalIssueIds).toEqual(["t1", "t2"]); - expect(mapped.githubIssueUrls).toBeUndefined(); - expect(mapped.linkedPrUrl).toBeUndefined(); - }); - - test("extracts github urls from linkedIssues", () => { - const mapped = mapLinkedContextFromPending({ - linkedIssues: [ - { - slug: "#1", - title: "a", - source: "github", - url: "https://github.com/o/r/issues/1", - }, - ], - linkedPR: null, - }); - expect(mapped.githubIssueUrls).toEqual(["https://github.com/o/r/issues/1"]); - expect(mapped.internalIssueIds).toBeUndefined(); - }); - - test("skips internal issues missing taskId and github issues missing url", () => { - const mapped = mapLinkedContextFromPending({ - linkedIssues: [ - { slug: "SUP-1", title: "no task id", source: "internal" }, - { slug: "#1", title: "no url", source: "github" }, - ], - linkedPR: null, - }); - expect(mapped.internalIssueIds).toBeUndefined(); - expect(mapped.githubIssueUrls).toBeUndefined(); - }); - - test("surfaces linkedPR.url", () => { - const mapped = mapLinkedContextFromPending({ - linkedIssues: [], - linkedPR: { - prNumber: 42, - title: "PR 42", - url: "https://github.com/o/r/pull/42", - state: "open", - }, - }); - expect(mapped.linkedPrUrl).toBe("https://github.com/o/r/pull/42"); - }); - - test("returns all undefined for empty input", () => { - const mapped = mapLinkedContextFromPending({ - linkedIssues: [], - linkedPR: null, - }); - expect(mapped).toEqual({ - internalIssueIds: undefined, - githubIssueUrls: undefined, - linkedPrUrl: undefined, - }); - }); -}); - -describe("buildForkPayload", () => { - test("passes fork-specific fields and linked context", () => { - const pending = makePending({ - intent: "fork", - prompt: "do the thing", - baseBranch: "main", - baseBranchSource: "local", - linkedIssues: [ - { slug: "SUP-1", title: "a", source: "internal", taskId: "t1" }, - ], - linkedPR: { - prNumber: 3, - title: "p", - url: "https://github.com/o/r/pull/3", - state: "open", - }, - }); - const payload = buildForkPayload("pid", pending, undefined); - expect(payload.pendingId).toBe("pid"); - expect(payload.projectId).toBe(pending.projectId); - expect(payload.hostTarget).toEqual({ kind: "local" }); - expect(payload.names).toEqual({ - workspaceName: "my-workspace", - branchName: "feature-foo", - workspaceNameWasAutoGenerated: true, - }); - expect(payload.composer.prompt).toBe("do the thing"); - expect(payload.composer.baseBranch).toBe("main"); - expect(payload.composer.baseBranchSource).toBe("local"); - expect(payload.linkedContext?.internalIssueIds).toEqual(["t1"]); - expect(payload.linkedContext?.linkedPrUrl).toBe( - "https://github.com/o/r/pull/3", - ); - }); - - test("empty prompt/baseBranch become undefined, not empty strings", () => { - const pending = makePending({ prompt: "", baseBranch: null }); - const payload = buildForkPayload("pid", pending, undefined); - expect(payload.composer.prompt).toBeUndefined(); - expect(payload.composer.baseBranch).toBeUndefined(); - }); - - test("attachments are plumbed through linkedContext", () => { - const pending = makePending(); - const payload = buildForkPayload("pid", pending, [ - { data: "b64", mediaType: "image/png", filename: "a.png" }, - ]); - expect(payload.linkedContext?.attachments).toHaveLength(1); - }); - - test("host-tracking hostTarget survives the map", () => { - const pending = makePending({ - hostTarget: { kind: "host", hostId: "h-1" }, - }); - const payload = buildForkPayload("pid", pending, undefined); - expect(payload.hostTarget).toEqual({ kind: "host", hostId: "h-1" }); - }); - - test("propagates workspaceNameWasAutoGenerated=false for user-typed names", () => { - const pending = makePending({ workspaceNameWasAutoGenerated: false }); - const payload = buildForkPayload("pid", pending, undefined); - expect(payload.names.workspaceNameWasAutoGenerated).toBe(false); - }); -}); - -describe("buildCheckoutPayload", () => { - test("sends branch + runSetupScript; no composer prompt/baseBranch", () => { - const pending = makePending({ - intent: "checkout", - branchName: "feature-foo", - runSetupScript: false, - }); - const payload = buildCheckoutPayload("pid", pending); - expect(payload.branch).toBe("feature-foo"); - expect(payload.workspaceName).toBe("my-workspace"); - expect(payload.composer).toEqual({ runSetupScript: false }); - }); -}); - -describe("buildPrCheckoutPayload", () => { - const prContent = { - number: 42, - url: "https://github.com/o/r/pull/42", - title: "Fix typo", - branch: "fix/typo", - baseBranch: "main", - headRepositoryOwner: "kietho", - isCrossRepository: true, - state: "open", - body: "body text", - }; - - test("maps PR content into the pr input with normalized state", () => { - const pending = makePending({ - intent: "pr-checkout", - prompt: "review this PR", - linkedPR: { - prNumber: 42, - title: "Fix typo", - url: "https://github.com/o/r/pull/42", - state: "open", - }, - }); - const payload = buildPrCheckoutPayload("pid", pending, prContent); - - expect(payload.pr).toEqual({ - number: 42, - url: "https://github.com/o/r/pull/42", - title: "Fix typo", - headRefName: "fix/typo", - baseRefName: "main", - headRepositoryOwner: "kietho", - isCrossRepository: true, - state: "open", - }); - expect(payload.branch).toBeUndefined(); - }); - - test("composer.baseBranch = PR's baseRefName (Changes-tab authority)", () => { - const pending = makePending({ intent: "pr-checkout" }); - const payload = buildPrCheckoutPayload("pid", pending, { - ...prContent, - baseBranch: "develop", - }); - expect(payload.composer.baseBranch).toBe("develop"); - }); - - test("preserves prompt and runSetupScript from pending row", () => { - const pending = makePending({ - intent: "pr-checkout", - prompt: "hey", - runSetupScript: false, - }); - const payload = buildPrCheckoutPayload("pid", pending, prContent); - expect(payload.composer.prompt).toBe("hey"); - expect(payload.composer.runSetupScript).toBe(false); - }); - - test("linkedPrUrl falls back to PR content URL when linkedPR-level missing", () => { - // linkedPR exists but for some reason url isn't in the linkedIssues map - // (shouldn't happen normally, but be resilient). - const pending = makePending({ - intent: "pr-checkout", - linkedPR: null, - }); - const payload = buildPrCheckoutPayload("pid", pending, prContent); - expect(payload.linkedContext?.linkedPrUrl).toBe( - "https://github.com/o/r/pull/42", - ); - }); - - test("closed state maps to closed", () => { - const pending = makePending({ intent: "pr-checkout" }); - const payload = buildPrCheckoutPayload("pid", pending, { - ...prContent, - state: "closed", - }); - expect(payload.pr?.state).toBe("closed"); - }); - - test("merged state maps to merged", () => { - const pending = makePending({ intent: "pr-checkout" }); - const payload = buildPrCheckoutPayload("pid", pending, { - ...prContent, - state: "merged", - }); - expect(payload.pr?.state).toBe("merged"); - }); - - test("unknown state falls back to open (safe default)", () => { - const pending = makePending({ intent: "pr-checkout" }); - const payload = buildPrCheckoutPayload("pid", pending, { - ...prContent, - state: "draft", - }); - expect(payload.pr?.state).toBe("open"); - }); - - test("throws clear error for cross-repo PR with deleted fork (null owner)", () => { - const pending = makePending({ intent: "pr-checkout" }); - expect(() => - buildPrCheckoutPayload("pid", pending, { - ...prContent, - headRepositoryOwner: null, - isCrossRepository: true, - }), - ).toThrow("head fork repository has been deleted"); - }); - - test("same-repo PR with null owner is fine (owner not needed)", () => { - const pending = makePending({ intent: "pr-checkout" }); - const payload = buildPrCheckoutPayload("pid", pending, { - ...prContent, - headRepositoryOwner: null, - isCrossRepository: false, - }); - expect(payload.pr?.headRepositoryOwner).toBe(""); - }); -}); - -describe("buildAdoptPayload", () => { - test("minimal payload: projectId + host + name + branch", () => { - const pending = makePending({ - intent: "adopt", - branchName: "agreeable-ermine", - }); - const payload = buildAdoptPayload(pending); - expect(payload).toEqual({ - projectId: pending.projectId, - hostTarget: { kind: "local" }, - workspaceName: "my-workspace", - branch: "agreeable-ermine", - }); - }); -}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildIntentPayload.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildIntentPayload.ts deleted file mode 100644 index 81c2f57df71..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildIntentPayload.ts +++ /dev/null @@ -1,163 +0,0 @@ -import type { AdoptWorktreeInput } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useAdoptWorktree/useAdoptWorktree"; -import type { CheckoutWorkspaceInput } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useCheckoutDashboardWorkspace/useCheckoutDashboardWorkspace"; -import type { CreateWorkspaceInput } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useCreateDashboardWorkspace/useCreateDashboardWorkspace"; -import type { PendingWorkspaceRow } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema"; - -/** - * Pure builders that translate a `PendingWorkspaceRow` into the input shape - * each host-service mutation expects. Kept pure (no React, no IO) so the - * dispatch logic in the pending page is testable in isolation. See - * `buildIntentPayload.test.ts` for the contract suite. - */ - -type Attachment = { data: string; mediaType: string; filename: string }; - -export function mapLinkedContextFromPending( - pending: Pick, -): { - internalIssueIds: string[] | undefined; - githubIssueUrls: string[] | undefined; - linkedPrUrl: string | undefined; -} { - const internalIssueIds = pending.linkedIssues - .filter((i) => i.source === "internal" && i.taskId) - .map((i) => i.taskId as string); - const githubIssueUrls = pending.linkedIssues - .filter((i) => i.source === "github" && i.url) - .map((i) => i.url as string); - return { - internalIssueIds: - internalIssueIds.length > 0 ? internalIssueIds : undefined, - githubIssueUrls: githubIssueUrls.length > 0 ? githubIssueUrls : undefined, - linkedPrUrl: pending.linkedPR?.url, - }; -} - -export function buildForkPayload( - pendingId: string, - pending: PendingWorkspaceRow, - attachments: Attachment[] | undefined, -): CreateWorkspaceInput { - const linked = mapLinkedContextFromPending(pending); - return { - pendingId, - projectId: pending.projectId, - hostTarget: pending.hostTarget, - names: { - workspaceName: pending.name, - branchName: pending.branchName, - workspaceNameWasAutoGenerated: pending.workspaceNameWasAutoGenerated, - }, - composer: { - prompt: pending.prompt || undefined, - baseBranch: pending.baseBranch || undefined, - baseBranchSource: pending.baseBranchSource ?? undefined, - runSetupScript: pending.runSetupScript, - }, - linkedContext: { - internalIssueIds: linked.internalIssueIds, - githubIssueUrls: linked.githubIssueUrls, - linkedPrUrl: linked.linkedPrUrl, - attachments, - }, - }; -} - -export function buildCheckoutPayload( - pendingId: string, - pending: PendingWorkspaceRow, -): CheckoutWorkspaceInput { - return { - pendingId, - projectId: pending.projectId, - hostTarget: pending.hostTarget, - workspaceName: pending.name, - branch: pending.branchName, - composer: { - baseBranch: pending.baseBranch || undefined, - runSetupScript: pending.runSetupScript, - }, - }; -} - -/** - * Builds the `workspaceCreation.checkout` payload for PR mode. Requires the - * resolved PR content fetched at pending-page time (not persisted in the - * pending row itself — kept narrow on purpose). - * - * The server derives the real local branch name from `pr.headRefName` + - * `pr.isCrossRepository`; the pending row's `branchName` is only a display - * placeholder in PR mode. - */ -export function buildPrCheckoutPayload( - pendingId: string, - pending: PendingWorkspaceRow, - prContent: { - number: number; - url: string; - title: string; - branch: string; // headRefName - baseBranch: string; // baseRefName - headRepositoryOwner: string | null; - isCrossRepository: boolean; - state: string; - }, -): CheckoutWorkspaceInput { - // Null owner on a cross-repo PR means the head fork repo has been - // deleted. We can't derive `/` without it, and - // `gh pr checkout` wouldn't have a fork to configure push against. - // Fail early with a clear error rather than a cryptic server-side - // "headRepositoryOwner is required". - if (prContent.isCrossRepository && !prContent.headRepositoryOwner) { - throw new Error( - `Cannot check out PR #${prContent.number}: the head fork repository has been deleted.`, - ); - } - const linked = mapLinkedContextFromPending(pending); - const normalizedState: "open" | "closed" | "merged" = - prContent.state === "closed" - ? "closed" - : prContent.state === "merged" - ? "merged" - : "open"; - return { - pendingId, - projectId: pending.projectId, - hostTarget: pending.hostTarget, - workspaceName: pending.name, - pr: { - number: prContent.number, - url: prContent.url, - title: prContent.title, - headRefName: prContent.branch, - baseRefName: prContent.baseBranch, - // Same-repo PRs don't need an owner for branch derivation; pass an - // empty string rather than leaking null into the server input. - headRepositoryOwner: prContent.headRepositoryOwner ?? "", - isCrossRepository: prContent.isCrossRepository, - state: normalizedState, - }, - composer: { - prompt: pending.prompt || undefined, - // PR's base is authoritative for the Changes tab — see plan §3. - baseBranch: prContent.baseBranch, - runSetupScript: pending.runSetupScript, - }, - linkedContext: { - internalIssueIds: linked.internalIssueIds, - githubIssueUrls: linked.githubIssueUrls, - linkedPrUrl: linked.linkedPrUrl ?? prContent.url, - }, - }; -} - -export function buildAdoptPayload( - pending: PendingWorkspaceRow, -): AdoptWorktreeInput { - return { - projectId: pending.projectId, - hostTarget: pending.hostTarget, - workspaceName: pending.name, - branch: pending.branchName, - }; -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildSetupPaneLayout.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildSetupPaneLayout.ts deleted file mode 100644 index 8221d5ff994..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildSetupPaneLayout.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { WorkspaceState } from "@superset/panes"; -import type { - PaneViewerData, - TerminalPaneData, -} from "../../v2-workspace/$workspaceId/types"; - -/** - * Build a pane layout from terminal descriptors returned by workspace creation. - * Each terminal becomes its own tab. The renderer just attaches — sessions are - * already running on the host-service. - */ -export function buildSetupPaneLayout( - terminals: Array<{ id: string; role: string; label: string }>, -): WorkspaceState { - const tabs = terminals.map((t) => { - const paneId = `pane-${crypto.randomUUID()}`; - const tabId = `tab-${crypto.randomUUID()}`; - return { - id: tabId, - createdAt: Date.now(), - activePaneId: paneId, - layout: { type: "pane" as const, paneId }, - panes: { - [paneId]: { - id: paneId, - kind: "terminal", - titleOverride: t.label, - data: { terminalId: t.id } as TerminalPaneData, - }, - }, - }; - }); - - return { - version: 1, - activeTabId: tabs[0]?.id ?? null, - tabs, - }; -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/dispatchForkLaunch.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/dispatchForkLaunch.ts deleted file mode 100644 index fe1a91848cc..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/dispatchForkLaunch.ts +++ /dev/null @@ -1,238 +0,0 @@ -import type { ResolvedAgentConfig } from "@superset/shared/agent-settings"; -import { buildHostRoutingKey } from "@superset/shared/host-routing"; -import { toast } from "@superset/ui/sonner"; -import { env } from "renderer/env.renderer"; -import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; -import type { - PendingChatLaunch, - PendingTerminalLaunch, - PendingWorkspaceRow, -} from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema"; -import { - buildForkAgentLaunch, - type LoadedAttachment, - type ResolvedPrContent, -} from "./buildForkAgentLaunch"; - -export interface DispatchForkLaunchInputs { - workspaceId: string; - pending: Pick< - PendingWorkspaceRow, - | "projectId" - | "prompt" - | "linkedIssues" - | "linkedPR" - | "hostTarget" - | "agentId" - >; - loadedAttachments: LoadedAttachment[] | undefined; - agentConfigs: ResolvedAgentConfig[]; - activeHostUrl: string | null; - activeOrganizationId: string | null; - /** - * Pre-resolved PR content from the pr-checkout flow. Threaded into - * `buildForkAgentLaunch` so the `fetchPullRequest` resolver skips a - * redundant `getGitHubPullRequestContent` call. - */ - resolvedPr?: ResolvedPrContent; - onApplyToRow: (patch: { - terminalLaunch?: PendingTerminalLaunch | null; - chatLaunch?: PendingChatLaunch | null; - }) => void; -} - -/** - * After host-service.create resolves, run the composer pipeline and - * stash the launch intent on the pending row. The V2 workspace page's - * useConsumePendingLaunch mount effect picks it up. - * - * For terminal launches we also write attachment bytes to - * `/.superset/attachments/` now — the worktree exists and - * workspaceTrpc.filesystem is available. Chat launches carry their - * binaries as base64 data URLs inline (existing ChatLaunchConfig shape). - */ -export async function dispatchForkLaunch({ - workspaceId, - pending, - loadedAttachments, - agentConfigs, - activeHostUrl, - activeOrganizationId, - resolvedPr, - onApplyToRow, -}: DispatchForkLaunchInputs): Promise { - console.log("[v2-launch] dispatchForkLaunch: start", { - workspaceId, - projectId: pending.projectId, - attachmentCount: loadedAttachments?.length ?? 0, - agentConfigCount: agentConfigs.length, - }); - - const hostUrl = resolveHostUrl( - pending.hostTarget, - activeHostUrl, - activeOrganizationId, - ); - const hostClient = hostUrl ? getHostServiceClientByUrl(hostUrl) : undefined; - - let build: Awaited>; - try { - build = await buildForkAgentLaunch({ - pending, - attachments: loadedAttachments, - agentConfigs, - hostServiceClient: hostClient, - resolvedPr, - }); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - console.warn("[v2-launch] buildForkAgentLaunch failed:", err); - toast.error("Couldn't prepare agent launch", { description: msg }); - return; - } - - console.log("[v2-launch] dispatchForkLaunch: built", { - kind: build?.kind ?? null, - terminalCommand: - build?.kind === "terminal" - ? build.launch.command.slice(0, 120) - : undefined, - chatPrompt: - build?.kind === "chat" - ? build.launch.initialPrompt?.slice(0, 120) - : undefined, - attachmentsToWrite: - build?.kind === "terminal" ? build.attachmentsToWrite.length : 0, - }); - - if (!build) { - console.warn( - "[v2-launch] dispatchForkLaunch: buildForkAgentLaunch returned null — no launch", - ); - // Only warn if the user gave input worth launching on (prompt text, - // linked context, or attachments). An empty workspace-create with no - // agent enabled is a valid case and shouldn't surface a toast. - const userGaveInput = - (pending.prompt?.trim().length ?? 0) > 0 || - pending.linkedIssues.length > 0 || - !!pending.linkedPR || - (loadedAttachments?.length ?? 0) > 0; - if (userGaveInput) { - toast.warning("Workspace created but no agent launched", { - description: - "Enable an agent in Settings → Agents to auto-launch on new workspaces.", - }); - } - return; - } - - if (build.kind === "chat") { - onApplyToRow({ chatLaunch: build.launch }); - console.log("[v2-launch] dispatchForkLaunch: chatLaunch applied to row"); - return; - } - - if (!hostUrl) { - console.warn("[v2-launch] host-service URL not resolved; skip launch"); - toast.error("Couldn't reach host service", { - description: "Agent didn't launch. Check your host connection.", - }); - return; - } - - try { - if (build.attachmentsToWrite.length > 0) { - await writeAttachmentsToWorktree({ - hostUrl, - workspaceId, - attachments: build.attachmentsToWrite, - }); - } - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - console.warn("[v2-launch] failed to write attachments:", err); - toast.warning("Attachments didn't save to the workspace", { - description: `Agent will launch without files. ${msg}`, - }); - // keep going — terminal launch still useful even without files - } - - onApplyToRow({ terminalLaunch: build.launch }); - console.log("[v2-launch] dispatchForkLaunch: terminalLaunch applied to row", { - workspaceId, - }); -} - -function resolveHostUrl( - hostTarget: PendingWorkspaceRow["hostTarget"], - activeHostUrl: string | null, - activeOrganizationId: string | null, -): string | null { - if (hostTarget.kind === "local") return activeHostUrl; - if (!activeOrganizationId) return null; - const routingKey = buildHostRoutingKey( - activeOrganizationId, - hostTarget.hostId, - ); - return `${env.RELAY_URL}/hosts/${routingKey}`; -} - -async function writeAttachmentsToWorktree({ - hostUrl, - workspaceId, - attachments, -}: { - hostUrl: string; - workspaceId: string; - attachments: Array<{ - filename: string; - mediaType: string; - data: Uint8Array; - }>; -}): Promise { - const client = getHostServiceClientByUrl(hostUrl); - const workspace = await client.workspace.get.query({ id: workspaceId }); - const worktreePath: string | undefined = ( - workspace as { worktreePath?: string } - ).worktreePath; - if (!worktreePath) { - console.warn( - "[v2-launch] workspace has no worktreePath; skipping attachments", - ); - throw new Error("Workspace has no worktreePath"); - } - - const dir = joinPath(worktreePath, ".superset/attachments"); - try { - await client.filesystem.createDirectory.mutate({ - workspaceId, - absolutePath: dir, - }); - } catch { - // directory may already exist; writeFile will fail loudly if it doesn't - } - - for (const attachment of attachments) { - await client.filesystem.writeFile.mutate({ - workspaceId, - absolutePath: joinPath(dir, attachment.filename), - content: { - kind: "base64", - data: bytesToBase64(attachment.data), - }, - }); - } -} - -function bytesToBase64(bytes: Uint8Array): string { - let binary = ""; - for (let i = 0; i < bytes.length; i++) { - binary += String.fromCharCode(bytes[i] ?? 0); - } - return btoa(binary); -} - -function joinPath(a: string, b: string): string { - if (a.endsWith("/")) return `${a}${b}`; - return `${a}/${b}`; -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/page.tsx deleted file mode 100644 index d144b347215..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/page.tsx +++ /dev/null @@ -1,553 +0,0 @@ -import { toast } from "@superset/ui/sonner"; -import { eq } from "@tanstack/db"; -import { useLiveQuery } from "@tanstack/react-db"; -import { useQuery } from "@tanstack/react-query"; -import { createFileRoute, useNavigate } from "@tanstack/react-router"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { GoGitBranch } from "react-icons/go"; -import { HiCheck, HiExclamationTriangle } from "react-icons/hi2"; -import { useHostTargetUrl } from "renderer/hooks/host-service/useHostTargetUrl"; -import { authClient } from "renderer/lib/auth-client"; -import { electronTrpc } from "renderer/lib/electron-trpc"; -import { formatRelativeTime } from "renderer/lib/formatRelativeTime"; -import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; -import { - clearAttachments, - loadAttachments, -} from "renderer/lib/pending-attachment-store"; -import { useAdoptWorktree } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useAdoptWorktree"; -import { useCheckoutDashboardWorkspace } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useCheckoutDashboardWorkspace"; -import { useCreateDashboardWorkspace } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useCreateDashboardWorkspace"; -import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; -import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; -import type { PendingWorkspaceRow } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema"; -import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; -import type { ResolvedPrContent } from "./buildForkAgentLaunch"; -import { - buildAdoptPayload, - buildCheckoutPayload, - buildForkPayload, - buildPrCheckoutPayload, -} from "./buildIntentPayload"; -import { buildSetupPaneLayout } from "./buildSetupPaneLayout"; -import { dispatchForkLaunch } from "./dispatchForkLaunch"; - -/** - * Pending workspace progress page. - * - * Lives at /_dashboard/pending/$pendingId (NOT under /v2-workspace/) because - * the v2-workspace layout wraps children in WorkspaceTrpcProvider. During route - * transitions away from a real workspace, the layout would strip the provider - * while the old workspace's TerminalPane is still mounted — causing a crash. - * Keeping this route outside v2-workspace avoids that entirely. - * - * The page is the single point of dispatch for all three workspace-creation - * intents (fork / checkout / adopt). The modal inserts a row tagged with - * `intent` and navigates here; this page calls the right host-service mutation - * on first mount and on retry. See `V2_WORKSPACE_CREATION.md` §3. - */ -export const Route = createFileRoute( - "/_authenticated/_dashboard/pending/$pendingId/", -)({ - component: PendingWorkspacePage, -}); - -function useFireIntent(pendingId: string, pending: PendingWorkspaceRow | null) { - const collections = useCollections(); - const createWorkspace = useCreateDashboardWorkspace(); - const checkoutWorkspace = useCheckoutDashboardWorkspace(); - const adoptWorktree = useAdoptWorktree(); - const trpcUtils = electronTrpc.useUtils(); - const { activeHostUrl } = useLocalHostService(); - const hostUrl = useHostTargetUrl(pending?.hostTarget ?? null); - const { data: session } = authClient.useSession(); - const activeOrganizationId = session?.session?.activeOrganizationId ?? null; - const { ensureWorkspaceInSidebar } = useDashboardSidebarState(); - - const fire = useCallback(async () => { - if (!pending) return; - - collections.pendingWorkspaces.update(pendingId, (draft) => { - draft.status = "creating"; - draft.error = null; - }); - - try { - let result: { - workspace?: { id?: string } | null; - terminals?: Array<{ id: string; role: string; label: string }>; - warnings?: string[]; - }; - let loadedAttachments: - | Array<{ data: string; mediaType: string; filename: string }> - | undefined; - // Populated in the pr-checkout path; threaded into dispatchForkLaunch - // so the agent-launch resolver reuses the data instead of re-fetching. - let resolvedPr: ResolvedPrContent | undefined; - - switch (pending.intent) { - case "fork": { - if (pending.attachmentCount > 0) { - try { - loadedAttachments = await loadAttachments(pendingId); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - console.warn("[v2-launch] loadAttachments failed:", err); - toast.warning("Couldn't load saved attachments", { - description: `Workspace will be created without files. ${msg}`, - }); - } - } - result = await createWorkspace( - buildForkPayload(pendingId, pending, loadedAttachments), - ); - break; - } - case "checkout": { - result = await checkoutWorkspace( - buildCheckoutPayload(pendingId, pending), - ); - break; - } - case "adopt": { - result = await adoptWorktree(buildAdoptPayload(pending)); - break; - } - case "pr-checkout": { - if (!pending.linkedPR) { - throw new Error("pr-checkout intent requires a linkedPR"); - } - if (!hostUrl) { - throw new Error("Host service not available"); - } - const hostClient = getHostServiceClientByUrl(hostUrl); - // Single fetch — reused by both the mutation payload and the - // agent-launch resolver (via resolvedPr). Zero net new fetches - // vs fork-with-PR, which fetches the same data at launch build. - const prContent = - await hostClient.workspaceCreation.getGitHubPullRequestContent.query( - { - projectId: pending.projectId, - prNumber: pending.linkedPR.prNumber, - }, - ); - resolvedPr = { - number: prContent.number, - url: prContent.url, - title: prContent.title, - body: prContent.body, - branch: prContent.branch, - }; - result = await checkoutWorkspace( - buildPrCheckoutPayload(pendingId, pending, prContent), - ); - break; - } - } - - // Register in the sidebar as soon as the workspace exists. The - // post-create navigate effect also calls this, but only fires while - // the user is still on the pending page and after workspace sync - // completes — calling it here guarantees the row appears even if the - // user has navigated away or sync is slow. - if (result.workspace?.id) { - ensureWorkspaceInSidebar(result.workspace.id, pending.projectId); - } - - // V2 dispatch: after host-service.create resolves, build the launch - // plan and stash it on the pending row. The V2 workspace page's - // useConsumePendingLaunch mount-effect picks it up and opens the - // pane. See apps/desktop/docs/V2_LAUNCH_CONTEXT.md. - // - // Fetch agent configs imperatively here rather than reading from - // a useQuery hook — a not-yet-resolved query would silently skip - // the dispatch, permanently losing the launch for a successful - // workspace create. - const needsLaunchDispatch = - (pending.intent === "fork" || pending.intent === "pr-checkout") && - !!result.workspace?.id; - if (needsLaunchDispatch && result.workspace?.id) { - const agentConfigs = await trpcUtils.settings.getAgentPresets.fetch(); - await dispatchForkLaunch({ - workspaceId: result.workspace.id, - pending, - loadedAttachments, - agentConfigs, - activeHostUrl, - activeOrganizationId, - resolvedPr, - onApplyToRow: (patch) => { - collections.pendingWorkspaces.update(pendingId, (draft) => { - if (patch.terminalLaunch !== undefined) { - draft.terminalLaunch = patch.terminalLaunch; - } - if (patch.chatLaunch !== undefined) { - draft.chatLaunch = patch.chatLaunch; - } - }); - }, - }); - } - - collections.pendingWorkspaces.update(pendingId, (draft) => { - draft.status = "succeeded"; - draft.workspaceId = result.workspace?.id ?? null; - draft.terminals = result.terminals ?? []; - draft.warnings = result.warnings ?? []; - }); - void clearAttachments(pendingId); - } catch (err) { - collections.pendingWorkspaces.update(pendingId, (draft) => { - draft.status = "failed"; - draft.error = - err instanceof Error ? err.message : "Failed to create workspace"; - }); - } - }, [ - collections, - createWorkspace, - checkoutWorkspace, - adoptWorktree, - ensureWorkspaceInSidebar, - pending, - pendingId, - trpcUtils, - activeHostUrl, - activeOrganizationId, - hostUrl, - ]); - - return fire; -} - -function PendingWorkspacePage() { - const { pendingId } = Route.useParams(); - const navigate = useNavigate(); - const collections = useCollections(); - const { ensureWorkspaceInSidebar } = useDashboardSidebarState(); - const navigatedRef = useRef(false); - const firedRef = useRef(false); - - // Route params can change under a mounted component (user navigates from - // one pending page to another). Reset the fire/nav guards so the new - // pendingId actually dispatches — otherwise the second page sticks in - // "creating" forever. - const prevPendingIdRef = useRef(pendingId); - const [syncTimedOut, setSyncTimedOut] = useState(false); - if (prevPendingIdRef.current !== pendingId) { - prevPendingIdRef.current = pendingId; - firedRef.current = false; - navigatedRef.current = false; - setSyncTimedOut(false); - } - - const { data: pendingRows } = useLiveQuery( - (q) => - q - .from({ pw: collections.pendingWorkspaces }) - .where(({ pw }) => eq(pw.id, pendingId)) - .select(({ pw }) => ({ ...pw })), - [collections, pendingId], - ); - const pending: PendingWorkspaceRow | null = - (pendingRows?.[0] as PendingWorkspaceRow | undefined) ?? null; - const fireIntent = useFireIntent(pendingId, pending); - - // Wait for the cloud row to appear in the local collection before - // navigating. Fast-path intents (adopt) can beat Electric sync to the - // punch, landing us on the workspace route before the row is visible — - // which shows "workspace not found". Fork's slow path hides this race. - const { data: workspaceRowMatch } = useLiveQuery( - (q) => - q - .from({ w: collections.v2Workspaces }) - .where(({ w }) => eq(w.id, pending?.workspaceId ?? "")) - .select(({ w }) => ({ id: w.id })), - [collections, pending?.workspaceId], - ); - const workspaceSynced = (workspaceRowMatch?.length ?? 0) > 0; - - // Fire the mutation once on first mount. The modal stores draft state in - // the pending row and navigates here — page owns the actual call so all - // three intents share one dispatch + retry path. - useEffect(() => { - if (!pending || pending.status !== "creating" || firedRef.current) return; - firedRef.current = true; - void fireIntent(); - }, [pending, fireIntent]); - - // Poll host-service for step-by-step progress (fork + checkout only; - // adopt is fast and doesn't instrument progress). - const intentHasProgress = - pending?.intent === "fork" || pending?.intent === "checkout"; - const hostUrl = useHostTargetUrl(pending?.hostTarget ?? null); - - const { data: progress } = useQuery({ - queryKey: ["workspaceCreation", "getProgress", pendingId, hostUrl], - queryFn: async () => { - if (!hostUrl) return null; - const client = getHostServiceClientByUrl(hostUrl); - return client.workspaceCreation.getProgress.query({ - pendingId, - }); - }, - refetchInterval: 500, - enabled: pending?.status === "creating" && !!hostUrl && intentHasProgress, - }); - - const steps = progress?.steps ?? []; - - const STALE_THRESHOLD_MS = 2 * 60 * 1000; - const [now, setNow] = useState(Date.now()); - useEffect(() => { - if (pending?.status !== "creating") return; - const interval = setInterval(() => setNow(Date.now()), 1000); - return () => clearInterval(interval); - }, [pending?.status]); - - const createdAtMs = pending?.createdAt - ? new Date(pending.createdAt).getTime() - : now; - const elapsedMs = Math.max(0, now - createdAtMs); - const elapsedLabel = formatRelativeTime(createdAtMs); - const isStale = - pending?.status === "creating" && elapsedMs > STALE_THRESHOLD_MS; - - // If sync stalls past this, swap the spinner for a recoverable stall UI - // rather than silently navigating into "Workspace not found". syncTimedOut - // must stay in the deps + guard below so "Keep waiting" (which flips it - // false) re-arms a fresh timer instead of leaving the user stranded. - const SYNC_TIMEOUT_MS = 10_000; - useEffect(() => { - if ( - pending?.status !== "succeeded" || - !pending.workspaceId || - workspaceSynced || - syncTimedOut || - navigatedRef.current - ) { - return; - } - const timer = setTimeout(() => setSyncTimedOut(true), SYNC_TIMEOUT_MS); - return () => clearTimeout(timer); - }, [pending?.status, pending?.workspaceId, workspaceSynced, syncTimedOut]); - - const doNavigate = useCallback(() => { - if (!pending?.workspaceId || navigatedRef.current) return; - navigatedRef.current = true; - ensureWorkspaceInSidebar(pending.workspaceId, pending.projectId); - - if (pending.terminals.length > 0) { - const paneLayout = buildSetupPaneLayout(pending.terminals); - collections.v2WorkspaceLocalState.update(pending.workspaceId, (draft) => { - draft.paneLayout = paneLayout; - }); - } - - void navigate({ - to: "/v2-workspace/$workspaceId", - params: { workspaceId: pending.workspaceId }, - }); - setTimeout(() => { - collections.pendingWorkspaces.delete(pendingId); - }, 1000); - }, [collections, ensureWorkspaceInSidebar, navigate, pending, pendingId]); - - useEffect(() => { - if ( - pending?.status === "succeeded" && - pending.workspaceId && - workspaceSynced - ) { - doNavigate(); - } - }, [pending?.status, pending?.workspaceId, workspaceSynced, doNavigate]); - - if (!pending) { - return ( -
- Workspace not found -
- ); - } - - const creatingLabel = - pending.intent === "adopt" - ? "Adopting worktree..." - : pending.intent === "checkout" - ? "Checking out branch..." - : "Creating workspace..."; - - return ( -
-
-
-

{pending.name}

-
- - {pending.branchName} -
-
- - {pending.status === "creating" && ( -
-
-

- {isStale - ? "This is taking longer than expected..." - : creatingLabel} -

- - {elapsedLabel} - -
- {intentHasProgress && steps.length > 0 ? ( -
- {steps.map((step) => ( -
- {step.status === "done" ? ( - - ) : step.status === "active" ? ( -
-
-
- ) : ( -
-
-
- )} - - {step.label} - -
- ))} -
- ) : ( - // Adopt has no host-side progress steps — show a generic spinner. -
-
-
-
-
- )} -
- -
-
- )} - - {pending.status === "succeeded" && - (syncTimedOut && !workspaceSynced ? ( -
-
- - - Workspace was created but hasn't synced to this device yet. - Check your connection. - -
-
- - - -
-
- ) : ( -
-
- - Workspace ready — opening... -
- {pending.warnings.length > 0 && ( -
    - {pending.warnings.map((w) => ( -
  • - - {w} -
  • - ))} -
- )} -
- ))} - - {pending.status === "failed" && ( -
-
- - - {pending.error ?? "Failed to create workspace"} - -
-
- - -
-
- )} -
-
- ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumePendingLaunch/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumePendingLaunch/index.ts deleted file mode 100644 index f12ad26d382..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumePendingLaunch/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { useConsumePendingLaunch } from "./useConsumePendingLaunch"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumePendingLaunch/useConsumePendingLaunch.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumePendingLaunch/useConsumePendingLaunch.ts deleted file mode 100644 index 41817df7650..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumePendingLaunch/useConsumePendingLaunch.ts +++ /dev/null @@ -1,189 +0,0 @@ -import type { WorkspaceStore } from "@superset/panes"; -import { toast } from "@superset/ui/sonner"; -import { eq } from "@tanstack/db"; -import { useLiveQuery } from "@tanstack/react-db"; -import { useCallback, useEffect, useRef } from "react"; -import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; -import type { PendingWorkspaceRow } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema"; -import type { StoreApi } from "zustand/vanilla"; -import type { - ChatPaneData, - PaneViewerData, - TerminalPaneData, -} from "../../types"; - -interface UseConsumePendingLaunchArgs { - workspaceId: string; - store: StoreApi>; -} - -/** - * Consumes a pending row's `terminalLaunch` / `chatLaunch` stashed by - * the pending page after host-service.create resolved. Opens the - * corresponding pane in the V2 `@superset/panes` store, clears the - * field so subsequent mounts don't re-dispatch. - * - * Pattern mirrors useV2PresetExecution: live-query a record, open a - * pane with the store, call workspaceTrpc for any PTY side effects. - * See apps/desktop/docs/V2_LAUNCH_CONTEXT.md "Dispatch architecture". - */ -export function useConsumePendingLaunch({ - workspaceId, - store, -}: UseConsumePendingLaunchArgs): void { - const collections = useCollections(); - const consumedRef = useRef>(new Set()); - - const { data: matches } = useLiveQuery( - (q) => - q - .from({ pw: collections.pendingWorkspaces }) - .where(({ pw }) => eq(pw.workspaceId, workspaceId)) - .select(({ pw }) => ({ ...pw })), - [collections, workspaceId], - ); - - const pending: PendingWorkspaceRow | null = - (matches?.[0] as PendingWorkspaceRow | undefined) ?? null; - - const updateRow = useCallback( - (patch: Partial) => { - if (!pending) return; - collections.pendingWorkspaces.update(pending.id, (draft) => { - Object.assign(draft, patch); - }); - }, - [collections, pending], - ); - - useEffect(() => { - if (!pending) { - return; - } - - const terminalKey = pending.terminalLaunch - ? `${pending.id}:terminal` - : null; - const chatKey = pending.chatLaunch ? `${pending.id}:chat` : null; - - console.log("[v2-launch] useConsumePendingLaunch: tick", { - workspaceId, - pendingId: pending.id, - status: pending.status, - hasTerminalLaunch: !!pending.terminalLaunch, - hasChatLaunch: !!pending.chatLaunch, - terminalConsumed: terminalKey - ? consumedRef.current.has(terminalKey) - : null, - chatConsumed: chatKey ? consumedRef.current.has(chatKey) : null, - }); - - if (terminalKey && !consumedRef.current.has(terminalKey)) { - consumedRef.current.add(terminalKey); - console.log("[v2-launch] useConsumePendingLaunch: consuming terminal", { - command: pending.terminalLaunch?.command.slice(0, 120), - }); - consumeTerminalLaunch({ - pending, - store, - clear: () => updateRow({ terminalLaunch: null }), - }); - } - - if (chatKey && !consumedRef.current.has(chatKey)) { - consumedRef.current.add(chatKey); - console.log("[v2-launch] useConsumePendingLaunch: consuming chat"); - consumeChatLaunch({ - pending, - store, - clear: () => updateRow({ chatLaunch: null }), - }); - } - }, [pending, store, updateRow, workspaceId]); -} - -function consumeTerminalLaunch({ - pending, - store, - clear, -}: { - pending: PendingWorkspaceRow; - store: StoreApi>; - clear: () => void; -}): void { - const launch = pending.terminalLaunch; - if (!launch || !pending.workspaceId) { - console.warn("[v2-launch] consumeTerminalLaunch: bailing", { - hasLaunch: !!launch, - hasWorkspaceId: !!pending.workspaceId, - }); - // Defensive — shouldn't happen if the caller checked terminalLaunch - // already. Worth a toast so we see it in practice. - toast.error("Couldn't open agent pane", { - description: - "Missing launch data — please retry from the workspace menu.", - }); - return; - } - - const terminalId = crypto.randomUUID(); - console.log("[v2-launch] consumeTerminalLaunch: addTab", { - terminalId, - workspaceId: pending.workspaceId, - commandPreview: launch.command.slice(0, 120), - }); - - const data: TerminalPaneData = { - terminalId, - initialCommand: launch.command, - }; - store.getState().addTab({ - panes: [ - { - kind: "terminal", - titleOverride: launch.name, - data: data as PaneViewerData, - }, - ], - }); - clear(); - console.log("[v2-launch] consumeTerminalLaunch: done + cleared"); -} - -function consumeChatLaunch({ - pending, - store, - clear, -}: { - pending: PendingWorkspaceRow; - store: StoreApi>; - clear: () => void; -}): void { - const launch = pending.chatLaunch; - if (!launch) return; - - const data: ChatPaneData = { - sessionId: null, - launchConfig: { - initialPrompt: launch.initialPrompt, - initialFiles: launch.initialFiles, - model: launch.model, - taskSlug: launch.taskSlug, - }, - }; - - console.log("[v2-launch] consumeChatLaunch: addTab", { - hasPrompt: !!launch.initialPrompt, - fileCount: launch.initialFiles?.length ?? 0, - }); - store.getState().addTab({ - panes: [ - { - kind: "chat", - data: data as PaneViewerData, - }, - ], - }); - clear(); - console.log("[v2-launch] consumeChatLaunch: done + cleared"); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx index 905da6d6121..d671d1cc466 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx @@ -10,6 +10,9 @@ import { useCollections } from "renderer/routes/_authenticated/providers/Collect import { CommandPalette } from "renderer/screens/main/components/CommandPalette"; import { ResizablePanel } from "renderer/screens/main/components/ResizablePanel"; import { getV2NotificationSourcesForTab } from "renderer/stores/v2-notifications"; +import { useWorkspaceCreatesStore } from "renderer/stores/workspace-creates"; +import { WorkspaceCreateErrorState } from "../components/WorkspaceCreateErrorState"; +import { WorkspaceCreatingState } from "../components/WorkspaceCreatingState"; import { WorkspaceNotFoundState } from "../components/WorkspaceNotFoundState"; import { AddTabMenu } from "./components/AddTabMenu"; import { V2NotificationStatusIndicator } from "./components/V2NotificationStatusIndicator"; @@ -20,7 +23,6 @@ import { useBrowserShellInteractionPassthrough } from "./hooks/useBrowserShellIn import { useClearActivePaneAttention } from "./hooks/useClearActivePaneAttention"; import { useConsumeAutomationRunLink } from "./hooks/useConsumeAutomationRunLink"; import { useConsumeOpenUrlRequest } from "./hooks/useConsumeOpenUrlRequest"; -import { useConsumePendingLaunch } from "./hooks/useConsumePendingLaunch"; import { useDefaultContextMenuActions } from "./hooks/useDefaultContextMenuActions"; import { useDefaultPaneActions } from "./hooks/useDefaultPaneActions"; import { useDirtyTabCloseGuard } from "./hooks/useDirtyTabCloseGuard"; @@ -89,12 +91,32 @@ function V2WorkspacePage() { [collections, workspaceId], ); const workspace = workspaces?.[0] ?? null; + const inFlight = useWorkspaceCreatesStore((store) => + store.entries.find((entry) => entry.snapshot.id === workspaceId), + ); if (!workspaces) { return
; } if (!workspace) { + if (inFlight?.state === "creating") { + return ( + + ); + } + if (inFlight?.state === "error") { + return ( + + ); + } return ; } @@ -150,7 +172,6 @@ function WorkspaceContent({ workspaceId, projectId, }); - useConsumePendingLaunch({ workspaceId, store }); useConsumeAutomationRunLink({ store, terminalId, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceCreateErrorState/WorkspaceCreateErrorState.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceCreateErrorState/WorkspaceCreateErrorState.tsx new file mode 100644 index 00000000000..63708d6c9d9 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceCreateErrorState/WorkspaceCreateErrorState.tsx @@ -0,0 +1,47 @@ +import { Button } from "@superset/ui/button"; +import { useNavigate } from "@tanstack/react-router"; +import { AlertCircle } from "lucide-react"; +import { useWorkspaceCreates } from "renderer/stores/workspace-creates"; + +interface WorkspaceCreateErrorStateProps { + workspaceId: string; + name: string; + error: string; +} + +export function WorkspaceCreateErrorState({ + workspaceId, + name, + error, +}: WorkspaceCreateErrorStateProps) { + const navigate = useNavigate(); + const { retry, dismiss } = useWorkspaceCreates(); + + const handleDismiss = () => { + dismiss(workspaceId); + void navigate({ to: "/v2-workspaces" }); + }; + + return ( +
+
+
+ +
+

+ Failed to create workspace +

+

{name}

+

{error}

+
+ + +
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceCreateErrorState/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceCreateErrorState/index.ts new file mode 100644 index 00000000000..39b3ccfa90c --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceCreateErrorState/index.ts @@ -0,0 +1 @@ +export { WorkspaceCreateErrorState } from "./WorkspaceCreateErrorState"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceCreatingState/WorkspaceCreatingState.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceCreatingState/WorkspaceCreatingState.tsx new file mode 100644 index 00000000000..7b90e130f79 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceCreatingState/WorkspaceCreatingState.tsx @@ -0,0 +1,30 @@ +import { Loader2 } from "lucide-react"; + +interface WorkspaceCreatingStateProps { + name: string; + branch?: string; +} + +export function WorkspaceCreatingState({ + name, + branch, +}: WorkspaceCreatingStateProps) { + return ( +
+
+
+ +
+

+ Creating workspace +

+

{name}

+ {branch && ( +

+ Branch: {branch} +

+ )} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceCreatingState/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceCreatingState/index.ts new file mode 100644 index 00000000000..e27c1a64657 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceCreatingState/index.ts @@ -0,0 +1 @@ +export { WorkspaceCreatingState } from "./WorkspaceCreatingState"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/layout.tsx index 9547e5e3cd1..3c17f503323 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/layout.tsx @@ -11,7 +11,6 @@ import { import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; -import { WorkspaceNotFoundState } from "./components/WorkspaceNotFoundState"; import { WorkspaceTrpcProvider } from "./providers/WorkspaceTrpcProvider"; export const Route = createFileRoute("/_authenticated/_dashboard/v2-workspace")( @@ -67,8 +66,12 @@ function V2WorkspaceLayout() { return null; } + // No real workspace row yet — render the page directly so it can show + // in-flight create state (creating / error) or NotFound. The trpc + // provider isn't usable without a hostUrl, and the in-flight UI doesn't + // need it. if (!workspace || !hostUrl) { - return ; + return ; } return ( diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/DashboardNewWorkspaceDraftContext.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/DashboardNewWorkspaceDraftContext.tsx index f50d50fb15e..baa4986b2bb 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/DashboardNewWorkspaceDraftContext.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/DashboardNewWorkspaceDraftContext.tsx @@ -1,227 +1,85 @@ -import { generateFriendlyBranchName } from "@superset/shared/workspace-launch"; -import { toast } from "@superset/ui/sonner"; import { createContext, type PropsWithChildren, useCallback, useContext, useMemo, - useState, } from "react"; -import type { WorkspaceHostTarget } from "./components/DashboardNewWorkspaceForm/components/DevicePicker"; -import { useCreateDashboardWorkspace } from "./hooks/useCreateDashboardWorkspace"; - -export type LinkedIssue = { - slug: string; // "#123" for GitHub, "SUP-123" for internal - title: string; - source?: "github" | "internal"; - url?: string; // GitHub issue URL - taskId?: string; // Internal task ID for navigation - number?: number; // GitHub issue number - state?: "open" | "closed"; -}; - -export type LinkedPR = { - prNumber: number; - title: string; - url: string; - state: string; -}; - -export type BaseBranchSource = "local" | "remote-tracking"; - -export interface DashboardNewWorkspaceDraft { - selectedProjectId: string | null; - hostTarget: WorkspaceHostTarget; - prompt: string; - baseBranch: string | null; - /** Picker hint: which form of `baseBranch` the user selected. */ - baseBranchSource: BaseBranchSource | null; - runSetupScript: boolean; - workspaceName: string; - workspaceNameEdited: boolean; - branchName: string; - branchNameEdited: boolean; - linkedIssues: LinkedIssue[]; - linkedPR: LinkedPR | null; - /** - * Random friendly name (e.g. `curious-otter`) generated once per draft. - * Used as the submit fallback AND the picker preview so the user sees the - * same name that will be committed. - */ - friendlyFallback: string; -} - -interface DashboardNewWorkspaceDraftState extends DashboardNewWorkspaceDraft { - draftVersion: number; - resetKey: number; -} - -const initialDraftWithoutFallback: Omit< - DashboardNewWorkspaceDraft, - "friendlyFallback" -> = { - selectedProjectId: null, - hostTarget: { kind: "local" }, - prompt: "", - baseBranch: null, - baseBranchSource: null, - runSetupScript: true, - workspaceName: "", - workspaceNameEdited: false, - branchName: "", - branchNameEdited: false, - linkedIssues: [], - linkedPR: null, -}; - -function buildInitialDraft(): DashboardNewWorkspaceDraft { - return { - ...initialDraftWithoutFallback, - friendlyFallback: generateFriendlyBranchName(), - }; -} - -function buildInitialDraftState(): DashboardNewWorkspaceDraftState { - return { - ...buildInitialDraft(), - draftVersion: 0, - resetKey: 0, - }; -} - -interface DashboardNewWorkspaceActionMessages { - loading: string; - success: string; - error: (err: unknown) => string; -} - -interface DashboardNewWorkspaceActionOptions { - closeAndReset?: boolean; -} - -interface DashboardNewWorkspaceDraftContextValue { - draft: DashboardNewWorkspaceDraft; - draftVersion: number; - resetKey: number; +import { + type NewWorkspaceDraft, + useNewWorkspaceDraftStore, +} from "renderer/stores/new-workspace-draft"; +import { useShallow } from "zustand/react/shallow"; + +export type { + BaseBranchSource, + LinkedIssue, + LinkedPR, +} from "renderer/stores/new-workspace-draft"; +export type DashboardNewWorkspaceDraft = NewWorkspaceDraft; + +interface DraftContextValue { closeModal: () => void; closeAndResetDraft: () => void; - createWorkspace: ReturnType; - runAsyncAction: ( - promise: Promise, - messages: DashboardNewWorkspaceActionMessages, - options?: DashboardNewWorkspaceActionOptions, - ) => Promise; - updateDraft: (patch: Partial) => void; - resetDraft: () => void; } -const DashboardNewWorkspaceDraftContext = - createContext(null); +const DraftContext = createContext(null); export function DashboardNewWorkspaceDraftProvider({ children, onClose, }: PropsWithChildren<{ onClose: () => void }>) { - const [state, setState] = useState(buildInitialDraftState); - - // Owned here so onSuccess survives Dialog unmounting content on close. - const createWorkspace = useCreateDashboardWorkspace(); - - const updateDraft = useCallback( - (patch: Partial) => { - setState((state) => ({ - ...state, - ...patch, - draftVersion: state.draftVersion + 1, - })); - }, - [], - ); - - const resetDraft = useCallback(() => { - setState((state) => ({ - ...buildInitialDraft(), - draftVersion: state.draftVersion + 1, - resetKey: state.resetKey + 1, - })); - }, []); - + const resetDraft = useNewWorkspaceDraftStore((store) => store.resetDraft); const closeAndResetDraft = useCallback(() => { resetDraft(); onClose(); }, [onClose, resetDraft]); - const runAsyncAction = useCallback( - ( - promise: Promise, - messages: DashboardNewWorkspaceActionMessages, - options?: DashboardNewWorkspaceActionOptions, - ) => { - if (options?.closeAndReset !== false) { - onClose(); - resetDraft(); - } - toast.promise(promise, { - loading: messages.loading, - success: messages.success, - error: (err) => messages.error(err), - }); - return promise; - }, - [onClose, resetDraft], - ); - - const value = useMemo( - () => ({ - draft: { - selectedProjectId: state.selectedProjectId, - hostTarget: state.hostTarget, - prompt: state.prompt, - baseBranch: state.baseBranch, - baseBranchSource: state.baseBranchSource, - runSetupScript: state.runSetupScript, - workspaceName: state.workspaceName, - workspaceNameEdited: state.workspaceNameEdited, - branchName: state.branchName, - branchNameEdited: state.branchNameEdited, - linkedIssues: state.linkedIssues, - linkedPR: state.linkedPR, - friendlyFallback: state.friendlyFallback, - }, - draftVersion: state.draftVersion, - resetKey: state.resetKey, - closeModal: onClose, - closeAndResetDraft, - createWorkspace, - runAsyncAction, - updateDraft, - resetDraft, - }), - [ - closeAndResetDraft, - createWorkspace, - onClose, - resetDraft, - runAsyncAction, - state, - updateDraft, - ], + const value = useMemo( + () => ({ closeModal: onClose, closeAndResetDraft }), + [onClose, closeAndResetDraft], ); return ( - - {children} - + {children} ); } export function useDashboardNewWorkspaceDraft() { - const context = useContext(DashboardNewWorkspaceDraftContext); - if (!context) { + const ctx = useContext(DraftContext); + if (!ctx) { throw new Error( "useDashboardNewWorkspaceDraft must be used within DashboardNewWorkspaceDraftProvider", ); } - return context; + const draft = useNewWorkspaceDraftStore( + useShallow((store) => ({ + selectedProjectId: store.selectedProjectId, + hostId: store.hostId, + prompt: store.prompt, + baseBranch: store.baseBranch, + baseBranchSource: store.baseBranchSource, + workspaceName: store.workspaceName, + workspaceNameEdited: store.workspaceNameEdited, + branchName: store.branchName, + branchNameEdited: store.branchNameEdited, + linkedIssues: store.linkedIssues, + linkedPR: store.linkedPR, + selectedAgentId: store.selectedAgentId, + attachments: store.attachments, + friendlyFallback: store.friendlyFallback, + })), + ); + const updateDraft = useNewWorkspaceDraftStore((store) => store.updateDraft); + const resetDraft = useNewWorkspaceDraftStore((store) => store.resetDraft); + const resetKey = useNewWorkspaceDraftStore((store) => store.resetKey); + + return { + draft, + updateDraft, + resetDraft, + resetKey, + closeModal: ctx.closeModal, + closeAndResetDraft: ctx.closeAndResetDraft, + }; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/PromptGroup.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/PromptGroup.tsx index c3f703a3abb..42989d74d4b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/PromptGroup.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/PromptGroup.tsx @@ -1,8 +1,6 @@ import { sanitizeUserBranchName } from "@superset/shared/workspace-launch"; import { PromptInput, - PromptInputAttachment, - PromptInputAttachments, PromptInputButton, PromptInputFooter, PromptInputSubmit, @@ -13,9 +11,9 @@ import { import { Button } from "@superset/ui/button"; import { Input } from "@superset/ui/input"; import { isEnterSubmit } from "@superset/ui/lib/keyboard"; +import { toast } from "@superset/ui/sonner"; import { cn } from "@superset/ui/utils"; import { useNavigate } from "@tanstack/react-router"; -import type { FileUIPart } from "ai"; import { AnimatePresence, motion } from "framer-motion"; import { ArrowUpIcon } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef } from "react"; @@ -25,13 +23,17 @@ import { SiLinear } from "react-icons/si"; import { AgentSelect } from "renderer/components/AgentSelect"; import { LinkedIssuePill } from "renderer/components/Chat/ChatInterface/components/ChatInputFooter/components/LinkedIssuePill"; import { IssueLinkCommand } from "renderer/components/Chat/ChatInterface/components/IssueLinkCommand"; +import { resolveHostUrl } from "renderer/hooks/host-service/useHostTargetUrl"; import { useAgentLaunchPreferences } from "renderer/hooks/useAgentLaunchPreferences"; import { useEnabledAgents } from "renderer/hooks/useEnabledAgents"; import { PLATFORM } from "renderer/hotkeys"; +import { authClient } from "renderer/lib/auth-client"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; import { useNewWorkspaceModalOpen } from "renderer/stores/new-workspace-modal"; import { useV2WorkspaceCreateDefaultsStore } from "renderer/stores/v2-workspace-create-defaults"; import { useDashboardNewWorkspaceDraft } from "../../../DashboardNewWorkspaceDraftContext"; import { DevicePicker } from "../components/DevicePicker"; +import { useWorkspaceHostOptions } from "../components/DevicePicker/hooks/useWorkspaceHostOptions"; import { AttachmentButtons } from "./components/AttachmentButtons"; import { CompareBaseBranchPicker } from "./components/CompareBaseBranchPicker"; import { GitHubIssueLinkCommand } from "./components/GitHubIssueLinkCommand"; @@ -39,12 +41,14 @@ import { LinkedGitHubIssuePill } from "./components/LinkedGitHubIssuePill"; import { LinkedPRPill } from "./components/LinkedPRPill"; import { PRLinkCommand } from "./components/PRLinkCommand"; import { ProjectPickerPill } from "./components/ProjectPickerPill"; +import { UploadingAttachmentPill } from "./components/UploadingAttachmentPill"; import { useBranchPickerController } from "./hooks/useBranchPickerController"; import { useLinkedContext } from "./hooks/useLinkedContext"; +import { useSubmitWorkspace } from "./hooks/useSubmitWorkspace"; import { - type SubmitAttachment, - useSubmitWorkspace, -} from "./hooks/useSubmitWorkspace"; + useFileIdsForHost, + useUploadAttachments, +} from "./hooks/useUploadAttachments"; import { AGENT_STORAGE_KEY, PILL_BUTTON_CLASS, @@ -70,6 +74,9 @@ export function PromptGroup({ const { closeModal, draft, updateDraft } = useDashboardNewWorkspaceDraft(); const navigate = useNavigate(); const attachments = useProviderAttachments(); + const { activeHostUrl, machineId } = useLocalHostService(); + const { data: session } = authClient.useSession(); + const activeOrganizationId = session?.session?.activeOrganizationId; const needsSetup = selectedProject?.needsSetup === true; const persistedBaseBranchDefault = useV2WorkspaceCreateDefaultsStore( (state) => @@ -81,8 +88,8 @@ export function PromptGroup({ const clearBaseBranchDefault = useV2WorkspaceCreateDefaultsStore( (state) => state.clearBaseBranchDefault, ); - const setLastHostTarget = useV2WorkspaceCreateDefaultsStore( - (state) => state.setLastHostTarget, + const setLastHostId = useV2WorkspaceCreateDefaultsStore( + (state) => state.setLastHostId, ); const handleGoToSetup = useCallback(() => { if (!selectedProject?.id) return; @@ -95,7 +102,7 @@ export function PromptGroup({ }, [closeModal, navigate, selectedProject?.id]); const { baseBranch, - hostTarget, + hostId, prompt, workspaceName, branchName, @@ -128,28 +135,26 @@ export function PromptGroup({ // Reset baseBranch on project or host change, defaulting to the user's // last selected branch for that project when one exists. const previousProjectIdRef = useRef(projectId); - const previousHostRef = useRef(JSON.stringify(hostTarget)); + const previousHostIdRef = useRef(hostId); useEffect(() => { - const nextHost = JSON.stringify(hostTarget); if ( previousProjectIdRef.current !== projectId || - previousHostRef.current !== nextHost + previousHostIdRef.current !== hostId ) { previousProjectIdRef.current = projectId; - previousHostRef.current = nextHost; + previousHostIdRef.current = hostId; updateDraft({ baseBranch: persistedBaseBranchDefault?.branchName ?? null, baseBranchSource: persistedBaseBranchDefault?.source ?? null, }); } - }, [projectId, hostTarget, persistedBaseBranchDefault, updateDraft]); + }, [projectId, hostId, persistedBaseBranchDefault, updateDraft]); // ── Branch picker controller ───────────────────────────────────── const { pickerProps } = useBranchPickerController({ projectId, - hostTarget, + hostId, baseBranch, - runSetupScript: draft.runSetupScript, typedWorkspaceName: workspaceName, onBaseBranchChange: (branch, source) => { if (projectId) { @@ -164,44 +169,74 @@ export function PromptGroup({ closeModal, }); + // ── Optimistic attachment upload ───────────────────────────────── + const uploadHostUrl = useMemo(() => { + const id = draft.hostId ?? machineId; + if (!id || !activeOrganizationId) return null; + return ( + resolveHostUrl({ + hostId: id, + machineId, + activeHostUrl, + organizationId: activeOrganizationId, + }) ?? null + ); + }, [draft.hostId, machineId, activeHostUrl, activeOrganizationId]); + const uploadAttachments = useUploadAttachments({ + files: attachments.files, + hostUrl: uploadHostUrl, + }); + + // File pills follow the picker: only files attached *while* on this host + // show, with previous-host attachments preserved silently in the upload + // store for return visits. + const fileIdsForCurrentHost = useFileIdsForHost(uploadHostUrl); + const visibleFiles = useMemo(() => { + const idSet = new Set(fileIdsForCurrentHost); + return attachments.files.filter((file) => idSet.has(file.id)); + }, [attachments.files, fileIdsForCurrentHost]); + + // Submit gating: surface preconditions inline next to the submit button + // instead of letting all three submit paths (button, Enter, Cmd+Enter) + // fall into a toast. + const { otherHosts } = useWorkspaceHostOptions(); + const submitBlocker = useMemo(() => { + if (!projectId) return "Select a project"; + const selectedHostId = draft.hostId ?? machineId; + if (!selectedHostId) return "No active host"; + if (selectedHostId !== machineId) { + const remote = otherHosts.find((h) => h.id === selectedHostId); + if (!remote?.isOnline) return "Host is offline"; + } else if (!activeHostUrl) { + return "Host service is not running"; + } + return null; + }, [projectId, draft.hostId, machineId, activeHostUrl, otherHosts]); + // ── Submit (fork) ──────────────────────────────────────────────── - const createWorkspace = useSubmitWorkspace(projectId, selectedAgent); - const handleSubmit = useCallback( - (files: SubmitAttachment[] = []) => { - if (needsSetup) { - handleGoToSetup(); - return; - } - void createWorkspace(files); - }, - [createWorkspace, handleGoToSetup, needsSetup], - ); - const handlePromptSubmit = useCallback( - (message: { text?: string; files?: FileUIPart[] }) => { - // Library converts blob: → data: URLs before calling us; pass them - // through. We intentionally do not read attachments from the - // provider here — the library clears + revokes before onSubmit, so - // the provider's state is stale by this point. - const files = (message.files ?? []) - .filter((f) => typeof f.url === "string" && f.url.length > 0) - .map((f) => ({ - url: f.url, - mediaType: f.mediaType, - filename: f.filename, - })); - handleSubmit(files); - }, - [handleSubmit], + const createWorkspace = useSubmitWorkspace( + projectId, + selectedAgent, + uploadAttachments, ); + const handleSubmit = useCallback(() => { + if (needsSetup) { + handleGoToSetup(); + return; + } + if (submitBlocker) { + toast.error(submitBlocker); + return; + } + void createWorkspace(); + }, [createWorkspace, handleGoToSetup, needsSetup, submitBlocker]); useEffect(() => { if (!isNewWorkspaceModalOpen) return; const handler = (e: KeyboardEvent) => { + if (e.repeat) return; if (!isEnterSubmit(e, { requireMod: true })) return; e.preventDefault(); - // Keyboard fallback: submit without attachments. Inside the - // modal's form focus, PromptInput's own Enter handler fires - // instead and routes through handlePromptSubmit with files. handleSubmit(); }; window.addEventListener("keydown", handler); @@ -262,15 +297,13 @@ export function PromptGroup({ {/* Prompt input */} - {(linkedPR || - linkedIssues.length > 0 || - attachments.files.length > 0) && ( + {(linkedPR || linkedIssues.length > 0 || visibleFiles.length > 0) && (
{linkedPR && ( @@ -316,9 +349,13 @@ export function PromptGroup({ ))} - - {(file) => } - + {visibleFiles.map((file) => ( + + ))}
)} updateDraft({ prompt: e.target.value })} + onKeyDown={(e) => { + // Disable the library's plain-Enter → submit. Submit only + // happens via the button or the window-level Cmd/Ctrl+Enter + // listener. Plain Enter inserts a newline (default). + if (e.key === "Enter" && !e.metaKey && !e.ctrlKey) return; + }} /> @@ -369,7 +412,7 @@ export function PromptGroup({ ) } projectId={projectId} - hostTarget={hostTarget} + hostId={hostId} tooltipLabel="Link GitHub issue" >
{ - setLastHostTarget(t); - updateDraft({ hostTarget: t }); + hostId={hostId} + onSelectHostId={(next) => { + setLastHostId(next); + updateDraft({ hostId: next }); }} /> void; onCheckoutBranch: (branchName: string) => void; onOpenExisting: (branchName: string) => void; - onAdoptWorktree: (branchName: string) => void; // Authoritative (cloud-synced) answer to "does a workspace row exist for // this branch on this host?". Computed from the v2Workspaces collection // so it stays in sync with soft-deletes. Trumps any server-side @@ -63,7 +62,6 @@ export function CompareBaseBranchPicker({ onSelectCompareBaseBranch, onCheckoutBranch, onOpenExisting, - onAdoptWorktree, hasWorkspaceForBranch, }: CompareBaseBranchPickerProps) { const [open, setOpen] = useState(false); @@ -211,7 +209,7 @@ export function CompareBaseBranchPicker({ if (hasWorkspace) { onOpenExisting(branch.name); } else { - onAdoptWorktree(branch.name); + onCheckoutBranch(branch.name); } }} > diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/GitHubIssueLinkCommand/GitHubIssueLinkCommand.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/GitHubIssueLinkCommand/GitHubIssueLinkCommand.tsx index bf488e7575b..a7502da5a5a 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/GitHubIssueLinkCommand/GitHubIssueLinkCommand.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/GitHubIssueLinkCommand/GitHubIssueLinkCommand.tsx @@ -12,14 +12,13 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { useQuery } from "@tanstack/react-query"; import type { ReactNode } from "react"; import { useId, useState } from "react"; -import { useHostTargetUrl } from "renderer/hooks/host-service/useHostTargetUrl"; +import { useHostUrl } from "renderer/hooks/host-service/useHostTargetUrl"; import { useDebouncedValue } from "renderer/hooks/useDebouncedValue"; import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; import { IssueIcon, type IssueState, } from "renderer/screens/main/components/IssueIcon/IssueIcon"; -import type { WorkspaceHostTarget } from "../../../components/DevicePicker"; const MAX_RESULTS = 30; @@ -38,7 +37,7 @@ interface GitHubIssueLinkCommandProps { tooltipLabel: string; onSelect: (issue: SelectedIssue) => void; projectId: string | null; - hostTarget: WorkspaceHostTarget; + hostId: string | null; } export function GitHubIssueLinkCommand({ @@ -46,14 +45,14 @@ export function GitHubIssueLinkCommand({ tooltipLabel, onSelect, projectId, - hostTarget, + hostId, }: GitHubIssueLinkCommandProps) { const [open, setOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [showClosed, setShowClosed] = useState(false); const showClosedId = useId(); const debouncedQuery = useDebouncedValue(searchQuery, 300); - const hostUrl = useHostTargetUrl(hostTarget); + const hostUrl = useHostUrl(hostId); const trimmedQuery = searchQuery.trim(); const debouncedTrimmed = debouncedQuery.trim(); diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/PRLinkCommand/PRLinkCommand.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/PRLinkCommand/PRLinkCommand.tsx index 2603272f665..2870c6c1568 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/PRLinkCommand/PRLinkCommand.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/PRLinkCommand/PRLinkCommand.tsx @@ -12,15 +12,13 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { useQuery } from "@tanstack/react-query"; import type { ReactNode } from "react"; import { useId, useState } from "react"; -import { useHostTargetUrl } from "renderer/hooks/host-service/useHostTargetUrl"; +import { useHostUrl } from "renderer/hooks/host-service/useHostTargetUrl"; import { useDebouncedValue } from "renderer/hooks/useDebouncedValue"; import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; import { PRIcon, type PRState, } from "renderer/screens/main/components/PRIcon/PRIcon"; -import type { WorkspaceHostTarget } from "../../../components/DevicePicker"; - export interface SelectedPR { prNumber: number; title: string; @@ -33,7 +31,7 @@ interface PRLinkCommandProps { tooltipLabel: string; onSelect: (pr: SelectedPR) => void; projectId: string | null; - hostTarget: WorkspaceHostTarget; + hostId: string | null; } function normalizeState(state: string, isDraft: boolean): string { @@ -47,14 +45,14 @@ export function PRLinkCommand({ tooltipLabel, onSelect, projectId, - hostTarget, + hostId, }: PRLinkCommandProps) { const [open, setOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [showClosed, setShowClosed] = useState(false); const showClosedId = useId(); const debouncedQuery = useDebouncedValue(searchQuery, 300); - const hostUrl = useHostTargetUrl(hostTarget); + const hostUrl = useHostUrl(hostId); const trimmedQuery = searchQuery.trim(); const debouncedTrimmed = debouncedQuery.trim(); diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/UploadingAttachmentPill/UploadingAttachmentPill.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/UploadingAttachmentPill/UploadingAttachmentPill.tsx new file mode 100644 index 00000000000..64f67f6505f --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/UploadingAttachmentPill/UploadingAttachmentPill.tsx @@ -0,0 +1,53 @@ +import { PromptInputAttachment } from "@superset/ui/ai-elements/prompt-input"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import type { FileUIPart } from "ai"; +import { Loader2, TriangleAlert } from "lucide-react"; +import { useUploadStateFor } from "../../hooks/useUploadAttachments"; + +interface UploadingAttachmentPillProps { + file: FileUIPart & { id: string }; + hostUrl: string | null; +} + +/** + * Wraps the prompt-input library's pill with subtle status overlays: + * a corner spinner while pending, a red-tinted thumbnail with a warning + * icon on error. The whole pill is the tooltip trigger when errored so + * users can hover anywhere on the row to read the message. + */ +export function UploadingAttachmentPill({ + file, + hostUrl, +}: UploadingAttachmentPillProps) { + const state = useUploadStateFor(file.id, hostUrl); + const isPending = !state || state.kind === "pending"; + const isError = state?.kind === "error"; + const errorMessage = state?.kind === "error" ? state.message : null; + + const body = ( +
+ + {isPending && ( +
+ +
+ )} + {isError && ( +
+ +
+ )} +
+ ); + + if (isError && errorMessage) { + return ( + + {body} + {errorMessage} + + ); + } + + return body; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/UploadingAttachmentPill/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/UploadingAttachmentPill/index.ts new file mode 100644 index 00000000000..c9c2acb111b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/UploadingAttachmentPill/index.ts @@ -0,0 +1 @@ +export { UploadingAttachmentPill } from "./UploadingAttachmentPill"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useBranchPickerController/useBranchPickerController.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useBranchPickerController/useBranchPickerController.ts index b3d54371850..5f52e7af7aa 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useBranchPickerController/useBranchPickerController.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useBranchPickerController/useBranchPickerController.ts @@ -4,8 +4,8 @@ import { useNavigate } from "@tanstack/react-router"; import { useCallback, useMemo, useState } from "react"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; +import { useWorkspaceCreates } from "renderer/stores/workspace-creates"; import type { BaseBranchSource } from "../../../../../DashboardNewWorkspaceDraftContext"; -import type { WorkspaceHostTarget } from "../../../components/DevicePicker"; import { type BranchFilter, useBranchContext, @@ -16,9 +16,8 @@ type PickerProps = React.ComponentProps; export interface UseBranchPickerControllerArgs { projectId: string | null; - hostTarget: WorkspaceHostTarget; + hostId: string | null; baseBranch: string | null; - runSetupScript: boolean; /** When set, used as the workspace name for picker actions; falls back to the branch name. */ typedWorkspaceName: string; onBaseBranchChange: ( @@ -31,18 +30,14 @@ export interface UseBranchPickerControllerArgs { /** * Owns all state + handlers for the branch picker: the search/filter inputs, * the branch-context query, the host-id resolution that gates Open/Create - * dispatch, and the three per-row action callbacks. Returns a single - * `pickerProps` object ready to spread into ``. - * - * See V2_WORKSPACE_CREATION.md §2 for the action model and §3 for the - * pending-row insert + navigate flow. + * dispatch, and the per-row action callbacks. Returns a single `pickerProps` + * object ready to spread into ``. */ export function useBranchPickerController(args: UseBranchPickerControllerArgs) { const { projectId, - hostTarget, + hostId, baseBranch, - runSetupScript, typedWorkspaceName, onBaseBranchChange, closeModal, @@ -51,9 +46,12 @@ export function useBranchPickerController(args: UseBranchPickerControllerArgs) { const navigate = useNavigate(); const collections = useCollections(); const { machineId } = useLocalHostService(); + const { submit } = useWorkspaceCreates(); + + // `null` means "local active machine" — pin to the device's own machineId + // so workspace lookups (which key by hostId) resolve against the right host. + const resolvedHostId = hostId ?? machineId; - // Branch list state — owned by the controller so the picker is purely - // presentational. const [branchSearch, setBranchSearch] = useState(""); const [branchFilter, setBranchFilter] = useState("branch"); @@ -65,114 +63,78 @@ export function useBranchPickerController(args: UseBranchPickerControllerArgs) { isFetchingNextPage, hasNextPage, fetchNextPage, - } = useBranchContext(projectId, hostTarget, branchSearch, branchFilter); + } = useBranchContext(projectId, hostId, branchSearch, branchFilter); const effectiveCompareBaseBranch = baseBranch || defaultBranch || null; - // Authoritative "does a workspace already exist for this (project, - // branch, host)?" — driven by the cloud-synced collection rather than - // the server's per-row hasWorkspace snapshot, which can be stale after - // a delete. See V2_WORKSPACE_CREATION.md §2. + // Authoritative "does a workspace already exist for this (project, branch, + // host)?" — driven by the cloud-synced collection rather than the server's + // per-row hasWorkspace snapshot, which can be stale after a delete. const { data: projectWorkspaces } = useLiveQuery( (q) => q.from({ workspaces: collections.v2Workspaces }), [collections], ); - const { data: allHosts } = useLiveQuery( - (q) => q.from({ hosts: collections.v2Hosts }), - [collections], - ); - - // `v2Workspaces` rows are keyed by host id; collapsing by branch alone - // would collide across hosts that happen to share a branch. - const targetHostId = useMemo(() => { - if (hostTarget.kind === "host") return hostTarget.hostId; - if (!machineId || !allHosts) return null; - return allHosts.find((h) => h.machineId === machineId)?.machineId ?? null; - }, [hostTarget, allHosts, machineId]); const workspaceByBranch = useMemo(() => { const map = new Map(); - if (!projectId || !projectWorkspaces || !targetHostId) return map; + if (!projectId || !projectWorkspaces || !resolvedHostId) return map; for (const w of projectWorkspaces) { - if (w.projectId === projectId && w.hostId === targetHostId && w.branch) { + if ( + w.projectId === projectId && + w.hostId === resolvedHostId && + w.branch + ) { map.set(w.branch, w.id); } } return map; - }, [projectId, projectWorkspaces, targetHostId]); + }, [projectId, projectWorkspaces, resolvedHostId]); const hasWorkspaceForBranch = useCallback( (name: string) => workspaceByBranch.has(name), [workspaceByBranch], ); - // Picker actions (Create / Check out) bypass the modal's submit, so they - // don't get the `resolveNames` pass — fall back to the branch name when - // the user hasn't typed a workspace name. + // Picker actions bypass the modal's submit, so they don't get the + // `resolveNames` pass — fall back to the branch name when the user hasn't + // typed a workspace name. const resolveActionWorkspaceName = useCallback( (branchName: string) => typedWorkspaceName.trim() || branchName, [typedWorkspaceName], ); - const insertPendingAndNavigate = useCallback( - (row: { - pendingId: string; - intent: "checkout" | "adopt"; - workspaceName: string; - branchName: string; - }) => { + const onCheckoutBranch = useCallback( + (branchName: string) => { if (!projectId) { toast.error("Select a project first"); return; } - collections.pendingWorkspaces.insert({ - id: row.pendingId, - projectId, - intent: row.intent, - name: row.workspaceName, - branchName: row.branchName, - prompt: "", - baseBranch: null, - baseBranchSource: null, - runSetupScript, - linkedIssues: [], - linkedPR: null, - hostTarget, - attachmentCount: 0, - status: "creating", - error: null, - workspaceId: null, - warnings: [], - createdAt: new Date(), - }); + if (!resolvedHostId) { + toast.error("No active host"); + return; + } + const workspaceId = crypto.randomUUID(); + const workspaceName = resolveActionWorkspaceName(branchName); closeModal(); - void navigate({ to: `/pending/${row.pendingId}` as string }); - }, - [projectId, collections, runSetupScript, hostTarget, closeModal, navigate], - ); - - const onAdoptWorktree = useCallback( - (branchName: string) => { - insertPendingAndNavigate({ - pendingId: crypto.randomUUID(), - intent: "adopt", - workspaceName: resolveActionWorkspaceName(branchName), - branchName, - }); - }, - [insertPendingAndNavigate, resolveActionWorkspaceName], - ); - - const onCheckoutBranch = useCallback( - (branchName: string) => { - insertPendingAndNavigate({ - pendingId: crypto.randomUUID(), - intent: "checkout", - workspaceName: resolveActionWorkspaceName(branchName), - branchName, + void navigate({ to: `/v2-workspace/${workspaceId}` as string }); + void submit({ + hostId: resolvedHostId, + snapshot: { + id: workspaceId, + projectId, + name: workspaceName, + branch: branchName, + }, }); }, - [insertPendingAndNavigate, resolveActionWorkspaceName], + [ + projectId, + resolvedHostId, + resolveActionWorkspaceName, + submit, + closeModal, + navigate, + ], ); const onOpenExisting = useCallback( @@ -218,7 +180,6 @@ export function useBranchPickerController(args: UseBranchPickerControllerArgs) { onSelectCompareBaseBranch, onCheckoutBranch, onOpenExisting, - onAdoptWorktree, hasWorkspaceForBranch, }; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useSubmitWorkspace/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useSubmitWorkspace/index.ts index 0e4f4443d3e..20473c95be6 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useSubmitWorkspace/index.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useSubmitWorkspace/index.ts @@ -1,4 +1 @@ -export { - type SubmitAttachment, - useSubmitWorkspace, -} from "./useSubmitWorkspace"; +export { useSubmitWorkspace } from "./useSubmitWorkspace"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useSubmitWorkspace/useSubmitWorkspace.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useSubmitWorkspace/useSubmitWorkspace.ts index cf7c1bba346..235120ad405 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useSubmitWorkspace/useSubmitWorkspace.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useSubmitWorkspace/useSubmitWorkspace.ts @@ -1,110 +1,113 @@ import { toast } from "@superset/ui/sonner"; import { useNavigate } from "@tanstack/react-router"; import { useCallback } from "react"; -import { storeAttachments } from "renderer/lib/pending-attachment-store"; -import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { authClient } from "renderer/lib/auth-client"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; +import { useWorkspaceCreates } from "renderer/stores/workspace-creates"; import { useDashboardNewWorkspaceDraft } from "../../../../../DashboardNewWorkspaceDraftContext"; import type { WorkspaceCreateAgent } from "../../types"; +import type { UseUploadAttachmentsApi } from "../useUploadAttachments"; import { resolveNames } from "./resolveNames"; -export interface SubmitAttachment { - url: string; // data: URL already (library converts blob→data before onSubmit) - mediaType: string; - filename?: string; -} - /** - * Returns a callback that submits a fork (new branch from base): - * resolve names → store attachments → insert pending row → close modal → - * navigate to pending page. The page owns the host-service mutation — - * see V2_WORKSPACE_CREATION.md §3. - * - * Files come via the PromptInput's `onSubmit({ text, files })` payload - * (already converted from blob: → data: by the library before it calls - * us). We do not read from `useProviderAttachments().takeFiles()` here: - * the library clears provider state + revokes blob URLs *before* - * invoking onSubmit, so the ref is stale by the time we'd see it. + * Submits a workspace create against the new `workspaces.create` host + * procedure. Attachment uploads run optimistically through `useUploadAttachments` + * — submit only blocks on whatever uploads are still in flight, then dispatches + * the create with the resulting `attachmentIds` on the agent launch sugar. */ export function useSubmitWorkspace( projectId: string | null, selectedAgent: WorkspaceCreateAgent, + uploadAttachments: UseUploadAttachmentsApi, ) { const navigate = useNavigate(); const { closeAndResetDraft, draft } = useDashboardNewWorkspaceDraft(); - const collections = useCollections(); + const { submit } = useWorkspaceCreates(); + const { machineId } = useLocalHostService(); + const { data: session } = authClient.useSession(); + const activeOrganizationId = session?.session?.activeOrganizationId; + + return useCallback(async () => { + if (!projectId) { + toast.error("Select a project first"); + return; + } + if (!activeOrganizationId) { + toast.error("No active organization"); + return; + } - return useCallback( - async (files: SubmitAttachment[] = []) => { - if (!projectId) { - toast.error("Select a project first"); - return; - } + const hostId = draft.hostId ?? machineId; + if (!hostId) { + toast.error("No active host"); + return; + } - const { branchName, workspaceName, workspaceNameWasAutoGenerated } = - resolveNames(draft); - const pendingId = crypto.randomUUID(); + const { readyIds: attachmentIds, errors } = + await uploadAttachments.awaitUploads(); + if (errors.length > 0) { + const first = errors[0]; + toast.error( + first.filename + ? `Attachment upload failed (${first.filename}): ${first.message}` + : `Attachment upload failed: ${first.message}`, + ); + return; + } - // PR mode: route to pr-checkout intent. Pending page fetches full - // PR details (getGitHubPullRequestContent) before firing the - // mutation, and derives the real branch name server-side from the - // resolved PR data. The `branchName` field here is a display - // placeholder; workspaceName similarly falls back to the PR title. - const isPrCheckout = draft.linkedPR !== null; - const prPlaceholderBranch = isPrCheckout - ? `pr-${draft.linkedPR?.prNumber}` - : null; - const prPlaceholderName = isPrCheckout - ? draft.linkedPR?.title || `PR #${draft.linkedPR?.prNumber}` - : null; + const { branchName, workspaceName, workspaceNameWasAutoGenerated } = + resolveNames(draft); - if (files.length > 0) { - try { - await storeAttachments(pendingId, files); - } catch (err) { - toast.error( - err instanceof Error ? err.message : "Failed to store attachments", - ); - return; - } - } + const isPrCheckout = draft.linkedPR !== null; - collections.pendingWorkspaces.insert({ - id: pendingId, - projectId, - intent: isPrCheckout ? "pr-checkout" : "fork", - name: prPlaceholderName ?? workspaceName, - // PR-checkout names come from the PR title — never auto-rename. - // Fork names follow the user-typed-vs-friendly-fallback split. - workspaceNameWasAutoGenerated: isPrCheckout - ? false - : workspaceNameWasAutoGenerated, - branchName: prPlaceholderBranch ?? branchName, - prompt: draft.prompt, - baseBranch: draft.baseBranch ?? null, - baseBranchSource: draft.baseBranchSource ?? null, - runSetupScript: draft.runSetupScript, - linkedIssues: draft.linkedIssues, - linkedPR: draft.linkedPR, - hostTarget: draft.hostTarget, - attachmentCount: files.length, - agentId: selectedAgent, - status: "creating", - error: null, - workspaceId: null, - warnings: [], - createdAt: new Date(), - }); + const taskIds = draft.linkedIssues + .filter((issue) => issue.source === "internal" && issue.taskId) + .map((issue) => issue.taskId as string); - closeAndResetDraft(); - void navigate({ to: `/pending/${pendingId}` as string }); - }, - [ - closeAndResetDraft, - collections, - draft, - navigate, + const agents = + selectedAgent !== "none" && draft.prompt.trim() + ? [ + { + agent: selectedAgent, + prompt: draft.prompt, + attachmentIds: + attachmentIds.length > 0 ? attachmentIds : undefined, + }, + ] + : undefined; + + const displayName = isPrCheckout + ? draft.linkedPR?.title || `PR #${draft.linkedPR?.prNumber}` + : workspaceName; + + const workspaceId = crypto.randomUUID(); + const snapshot = { + id: workspaceId, projectId, - selectedAgent, - ], - ); + name: isPrCheckout ? displayName : workspaceName, + branch: isPrCheckout ? undefined : branchName, + pr: isPrCheckout ? draft.linkedPR?.prNumber : undefined, + baseBranch: draft.baseBranch ?? undefined, + taskIds: taskIds.length > 0 ? taskIds : undefined, + autogenerateName: + !isPrCheckout && + workspaceNameWasAutoGenerated && + Boolean(draft.prompt.trim()), + agents, + }; + + closeAndResetDraft(); + void navigate({ to: `/v2-workspace/${workspaceId}` as string }); + void submit({ hostId, snapshot }); + }, [ + activeOrganizationId, + closeAndResetDraft, + draft, + machineId, + navigate, + projectId, + selectedAgent, + submit, + uploadAttachments, + ]); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useUploadAttachments/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useUploadAttachments/index.ts new file mode 100644 index 00000000000..5eb6a71bfcc --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useUploadAttachments/index.ts @@ -0,0 +1,10 @@ +export { + type UploadState, + useFileIdsForHost, + useUploadStateFor, +} from "./store"; +export { + type UploadFailure, + type UseUploadAttachmentsApi, + useUploadAttachments, +} from "./useUploadAttachments"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useUploadAttachments/store.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useUploadAttachments/store.ts new file mode 100644 index 00000000000..a567ae41b80 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useUploadAttachments/store.ts @@ -0,0 +1,180 @@ +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { create } from "zustand"; +import { useShallow } from "zustand/react/shallow"; + +export type UploadState = + | { kind: "pending" } + | { kind: "ready"; attachmentId: string } + | { kind: "error"; message: string }; + +interface UploadStoreState { + // Outer key: fileId. Inner key: hostUrl. Nested so we can prune by + // fileId without parsing a composite string key. + entries: Record>; +} + +export const useAttachmentUploadsStore = create(() => ({ + entries: {}, +})); + +// Promises live outside the store — they aren't serializable and aren't +// observed by React. Keyed identically to entries: outer fileId, inner hostUrl. +const promiseMap = new Map>>(); + +async function fetchBase64(url: string): Promise { + if (url.startsWith("data:")) { + const commaIndex = url.indexOf(","); + if (commaIndex === -1) return ""; + return url.slice(commaIndex + 1); + } + const response = await fetch(url); + const buffer = await response.arrayBuffer(); + let binary = ""; + const bytes = new Uint8Array(buffer); + for (let i = 0; i < bytes.length; i += 1) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); +} + +function setEntry(fileId: string, hostUrl: string, state: UploadState): void { + useAttachmentUploadsStore.setState((s) => ({ + entries: { + ...s.entries, + [fileId]: { ...(s.entries[fileId] ?? {}), [hostUrl]: state }, + }, + })); +} + +export interface StartUploadInput { + id: string; + url: string; + mediaType: string; + filename?: string; +} + +/** + * Idempotent: if an upload for `(hostUrl, file.id)` is already in flight or + * settled, this is a no-op. The store only persists the upload status — + * filename/mediaType live in the prompt-input library and are joined at + * read time by the hook. + */ +export function startUpload(hostUrl: string, file: StartUploadInput): void { + let byHost = promiseMap.get(file.id); + if (byHost?.has(hostUrl)) return; + if (!byHost) { + byHost = new Map(); + promiseMap.set(file.id, byHost); + } + + setEntry(file.id, hostUrl, { kind: "pending" }); + + const promise = (async (): Promise => { + try { + const data = await fetchBase64(file.url); + const result = await getHostServiceClientByUrl( + hostUrl, + ).attachments.upload.mutate({ + data: { kind: "base64", data }, + mediaType: file.mediaType, + originalFilename: file.filename, + }); + const next: UploadState = { + kind: "ready", + attachmentId: result.attachmentId, + }; + setEntry(file.id, hostUrl, next); + return next; + } catch (err) { + const next: UploadState = { + kind: "error", + message: err instanceof Error ? err.message : String(err), + }; + setEntry(file.id, hostUrl, next); + return next; + } + })(); + byHost.set(hostUrl, promise); +} + +/** + * Resolves once every requested `(hostUrl, fileId)` upload has settled. + * Returns ready ids and failures keyed back to fileId so callers can join + * with the prompt-input library's metadata for messaging. + */ +export async function awaitUploads( + hostUrl: string, + fileIds: string[], +): Promise<{ + readyIds: string[]; + failures: { fileId: string; message: string }[]; +}> { + const tasks: { fileId: string; promise: Promise }[] = []; + for (const fileId of fileIds) { + const promise = promiseMap.get(fileId)?.get(hostUrl); + if (promise) tasks.push({ fileId, promise }); + } + const settled = await Promise.all(tasks.map((t) => t.promise)); + const readyIds: string[] = []; + const failures: { fileId: string; message: string }[] = []; + settled.forEach((state, i) => { + if (state.kind === "ready") readyIds.push(state.attachmentId); + else if (state.kind === "error") { + failures.push({ fileId: tasks[i].fileId, message: state.message }); + } + }); + return { readyIds, failures }; +} + +/** + * Subscribes to the upload status of a single `(fileId, hostUrl)` slice. + * Each pill subscribes to its own slot, so unrelated upload state changes + * don't trigger re-renders elsewhere in the modal. + */ +export function useUploadStateFor( + fileId: string, + hostUrl: string | null, +): UploadState | null { + return useAttachmentUploadsStore((s) => { + if (!hostUrl) return null; + return s.entries[fileId]?.[hostUrl] ?? null; + }); +} + +/** + * Returns the file ids that have an upload entry under `hostUrl` — i.e. the + * files attached *while* on that host. Used to filter the prompt-input + * library's flat file list down to a per-host view: switching hosts hides + * other hosts' files without revoking their blob URLs or upload state. + */ +export function useFileIdsForHost(hostUrl: string | null): string[] { + return useAttachmentUploadsStore( + useShallow((s) => { + if (!hostUrl) return []; + const ids: string[] = []; + for (const [fileId, byHost] of Object.entries(s.entries)) { + if (byHost[hostUrl]) ids.push(fileId); + } + return ids; + }), + ); +} + +/** + * Drops cached upload state for any fileId not in `liveFileIds`. Called by + * the hook on every re-render so the store stays a strict downstream of the + * prompt-input library's `attachments.files` — clearing the library + * automatically empties the store on the next effect tick. + */ +export function pruneAttachmentUploads(liveFileIds: Set): void { + for (const fileId of promiseMap.keys()) { + if (!liveFileIds.has(fileId)) promiseMap.delete(fileId); + } + useAttachmentUploadsStore.setState((s) => { + const next: Record> = {}; + for (const [fileId, byHost] of Object.entries(s.entries)) { + if (liveFileIds.has(fileId)) next[fileId] = byHost; + } + return { entries: next }; + }); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useUploadAttachments/useUploadAttachments.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useUploadAttachments/useUploadAttachments.ts new file mode 100644 index 00000000000..d4b864858ab --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useUploadAttachments/useUploadAttachments.ts @@ -0,0 +1,70 @@ +import type { FileUIPart } from "ai"; +import { useCallback, useEffect, useRef } from "react"; +import { awaitUploads, pruneAttachmentUploads, startUpload } from "./store"; + +export interface UploadFailure { + filename?: string; + message: string; +} + +export interface UseUploadAttachmentsApi { + awaitUploads: () => Promise<{ + readyIds: string[]; + errors: UploadFailure[]; + }>; +} + +/** + * Drives background attachment uploads. Each file uploads exactly once, to + * whichever host was active when the user added it; switching hosts does not + * re-upload. The upload store keys results by `(fileId, hostUrl)` so the + * visible pill list (filtered via `useFileIdsForHost`) follows the picker + * while previous-host attachments stay cached for return visits. + */ +export function useUploadAttachments({ + files, + hostUrl, +}: { + files: (FileUIPart & { id: string })[]; + hostUrl: string | null; +}): UseUploadAttachmentsApi { + // File ids we've already kicked off an upload for. Prevents re-upload on + // host swap; keyed by fileId so a removed-and-re-added file (new id from + // the library) does start fresh. + const seenFileIdsRef = useRef>(new Set()); + + useEffect(() => { + if (hostUrl) { + for (const file of files) { + if (seenFileIdsRef.current.has(file.id)) continue; + seenFileIdsRef.current.add(file.id); + startUpload(hostUrl, { + id: file.id, + url: file.url, + mediaType: file.mediaType, + filename: file.filename, + }); + } + } + const liveIds = new Set(files.map((f) => f.id)); + for (const id of seenFileIdsRef.current) { + if (!liveIds.has(id)) seenFileIdsRef.current.delete(id); + } + pruneAttachmentUploads(liveIds); + }, [files, hostUrl]); + + const awaitForCurrent = useCallback(async () => { + if (!hostUrl) return { readyIds: [], errors: [] }; + const result = await awaitUploads( + hostUrl, + files.map((f) => f.id), + ); + const errors: UploadFailure[] = result.failures.map((failure) => { + const file = files.find((f) => f.id === failure.fileId); + return { filename: file?.filename, message: failure.message }; + }); + return { readyIds: result.readyIds, errors }; + }, [hostUrl, files]); + + return { awaitUploads: awaitForCurrent }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/DevicePicker.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/DevicePicker.tsx index a0754f1d187..a35017fc648 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/DevicePicker.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/DevicePicker.tsx @@ -15,12 +15,12 @@ import { HiOutlineComputerDesktop, HiOutlineServer, } from "react-icons/hi2"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; import { FormPickerTrigger } from "../../PromptGroup/components/FormPickerTrigger"; import { useWorkspaceHostOptions, type WorkspaceHostOption, } from "./hooks/useWorkspaceHostOptions"; -import type { WorkspaceHostTarget } from "./types"; function OnlineDot({ online }: { online: boolean }) { return ( @@ -36,52 +36,49 @@ function OnlineDot({ online }: { online: boolean }) { } interface DevicePickerProps { - hostTarget: WorkspaceHostTarget; - onSelectHostTarget: (target: WorkspaceHostTarget) => void; + hostId: string | null; + onSelectHostId: (hostId: string | null) => void; className?: string; } function getSelectedLabel( - hostTarget: WorkspaceHostTarget, + hostId: string | null, + machineId: string | null, currentDeviceName: string | null, otherHosts: WorkspaceHostOption[], ) { - if (hostTarget.kind === "local") { + if (hostId === null || hostId === machineId) { return currentDeviceName ?? "Local Device"; } - - return ( - otherHosts.find((host) => host.id === hostTarget.hostId)?.name ?? - "Unknown Host" - ); + return otherHosts.find((host) => host.id === hostId)?.name ?? "Unknown Host"; } -function getSelectedIcon(hostTarget: WorkspaceHostTarget) { - if (hostTarget.kind === "local") { +function getSelectedIcon(hostId: string | null, machineId: string | null) { + if (hostId === null || hostId === machineId) { return ; } - return ; } export function DevicePicker({ - hostTarget, - onSelectHostTarget, + hostId, + onSelectHostId, className, }: DevicePickerProps) { + const { machineId } = useLocalHostService(); const { currentDeviceName, otherHosts } = useWorkspaceHostOptions(); + const isLocal = hostId === null || hostId === machineId; const selectedLabel = getSelectedLabel( - hostTarget, + hostId, + machineId, currentDeviceName, otherHosts, ); // Only remote hosts have a meaningful online indicator — the app itself // is the local host, so it's tautologically online. - const selectedRemoteOnline = - hostTarget.kind === "host" - ? (otherHosts.find((host) => host.id === hostTarget.hostId)?.isOnline ?? - false) - : null; + const selectedRemoteOnline = isLocal + ? null + : (otherHosts.find((host) => host.id === hostId)?.isOnline ?? false); return ( @@ -91,7 +88,7 @@ export function DevicePicker({ aria-label={`Device: ${selectedLabel}`} title={selectedLabel} > - {getSelectedIcon(hostTarget)} + {getSelectedIcon(hostId, machineId)} {selectedLabel} {selectedRemoteOnline !== null && ( @@ -100,12 +97,10 @@ export function DevicePicker({ - onSelectHostTarget({ kind: "local" })} - > + onSelectHostId(machineId)}> Local Device - {hostTarget.kind === "local" && } + {isLocal && } {otherHosts.length > 0 && ( <> @@ -117,18 +112,12 @@ export function DevicePicker({ {otherHosts.map((host) => { - const isSelected = - hostTarget.kind === "host" && hostTarget.hostId === host.id; + const isSelected = hostId === host.id; return ( - onSelectHostTarget({ - kind: "host", - hostId: host.id, - }) - } + onSelect={() => onSelectHostId(host.id)} > {host.name} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/index.ts index 3d709c110f4..7fca45abb81 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/index.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/index.ts @@ -1,2 +1 @@ export { DevicePicker } from "./DevicePicker"; -export type { WorkspaceHostTarget } from "./types"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/types.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/types.ts deleted file mode 100644 index f57a4966055..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/types.ts +++ /dev/null @@ -1,3 +0,0 @@ -export type WorkspaceHostTarget = - | { kind: "local" } - | { kind: "host"; hostId: string }; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/hooks/useBranchContext/useBranchContext.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/hooks/useBranchContext/useBranchContext.ts index 96c4dfa879b..cd81004b3cf 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/hooks/useBranchContext/useBranchContext.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/hooks/useBranchContext/useBranchContext.ts @@ -2,9 +2,8 @@ import type { AppRouter } from "@superset/host-service"; import { useInfiniteQuery } from "@tanstack/react-query"; import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server"; import { useMemo } from "react"; -import { useHostTargetUrl } from "renderer/hooks/host-service/useHostTargetUrl"; +import { useHostUrl } from "renderer/hooks/host-service/useHostTargetUrl"; import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; -import type { WorkspaceHostTarget } from "../../components/DevicePicker"; type SearchBranchesInput = inferRouterInputs["workspaceCreation"]["searchBranches"]; @@ -24,11 +23,11 @@ const PAGE_SIZE = 50; */ export function useBranchContext( projectId: string | null, - hostTarget: WorkspaceHostTarget, + hostId: string | null, query: string, filter: BranchFilter = "branch", ) { - const hostUrl = useHostTargetUrl(hostTarget); + const hostUrl = useHostUrl(hostId); const q = useInfiniteQuery({ queryKey: [ diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceModalContent/DashboardNewWorkspaceModalContent.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceModalContent/DashboardNewWorkspaceModalContent.tsx index 732d237d44d..5edc75bdf61 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceModalContent/DashboardNewWorkspaceModalContent.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceModalContent/DashboardNewWorkspaceModalContent.tsx @@ -57,7 +57,7 @@ export function DashboardNewWorkspaceModalContent({ [collections], ); - const setUpProjectIds = useSelectedHostProjectIds(draft.hostTarget); + const setUpProjectIds = useSelectedHostProjectIds(draft.hostId); const recentProjects = useMemo(() => { const repoById = new Map( @@ -80,29 +80,22 @@ export function DashboardNewWorkspaceModalContent({ const areProjectsReady = v2Projects !== undefined; const appliedPreSelectionRef = useRef(null); - const appliedHostTargetRef = useRef(false); + const appliedHostIdRef = useRef(false); const hasInitializedSelectionRef = useRef(false); useEffect(() => { if (!isOpen) { appliedPreSelectionRef.current = null; - appliedHostTargetRef.current = false; + appliedHostIdRef.current = false; hasInitializedSelectionRef.current = false; return; } - if (appliedHostTargetRef.current) return; - appliedHostTargetRef.current = true; - const persistedHostTarget = - useV2WorkspaceCreateDefaultsStore.getState().lastHostTarget; - const validHostTarget = - persistedHostTarget?.kind === "local" - ? persistedHostTarget - : persistedHostTarget?.kind === "host" && - typeof persistedHostTarget.hostId === "string" - ? persistedHostTarget - : null; - if (validHostTarget) { - updateDraft({ hostTarget: validHostTarget }); + if (appliedHostIdRef.current) return; + appliedHostIdRef.current = true; + const persistedHostId = + useV2WorkspaceCreateDefaultsStore.getState().lastHostId; + if (typeof persistedHostId === "string") { + updateDraft({ hostId: persistedHostId }); } }, [isOpen, updateDraft]); diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceModalContent/hooks/useSelectedHostProjectIds/useSelectedHostProjectIds.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceModalContent/hooks/useSelectedHostProjectIds/useSelectedHostProjectIds.ts index b35d0c79b18..a659ea1a4cd 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceModalContent/hooks/useSelectedHostProjectIds/useSelectedHostProjectIds.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceModalContent/hooks/useSelectedHostProjectIds/useSelectedHostProjectIds.ts @@ -1,9 +1,8 @@ -import { useHostTargetUrl } from "renderer/hooks/host-service/useHostTargetUrl"; +import { useHostUrl } from "renderer/hooks/host-service/useHostTargetUrl"; import { useHostProjectIds } from "renderer/react-query/projects"; -import type { WorkspaceHostTarget } from "../../../DashboardNewWorkspaceForm/components/DevicePicker/types"; export function useSelectedHostProjectIds( - hostTarget: WorkspaceHostTarget, + hostId: string | null, ): Set | null { - return useHostProjectIds(useHostTargetUrl(hostTarget)); + return useHostProjectIds(useHostUrl(hostId)); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useAdoptWorktree/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useAdoptWorktree/index.ts deleted file mode 100644 index ee3dc065fd5..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useAdoptWorktree/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { useAdoptWorktree } from "./useAdoptWorktree"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useAdoptWorktree/useAdoptWorktree.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useAdoptWorktree/useAdoptWorktree.ts deleted file mode 100644 index b3415730c9e..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useAdoptWorktree/useAdoptWorktree.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { buildHostRoutingKey } from "@superset/shared/host-routing"; -import { useCallback } from "react"; -import { env } from "renderer/env.renderer"; -import { authClient } from "renderer/lib/auth-client"; -import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; -import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; -import type { WorkspaceHostTarget } from "../../components/DashboardNewWorkspaceForm/components/DevicePicker"; - -export interface AdoptWorktreeInput { - projectId: string; - hostTarget: WorkspaceHostTarget; - workspaceName: string; - branch: string; -} - -/** - * Registers a workspace row for an existing `.worktrees/` directory - * that has no matching workspaces row. No git ops — just cloud + local DB. - */ -export function useAdoptWorktree() { - const { activeHostUrl } = useLocalHostService(); - const { data: session } = authClient.useSession(); - const activeOrganizationId = session?.session?.activeOrganizationId ?? null; - - return useCallback( - async (input: AdoptWorktreeInput) => { - const hostUrl = - input.hostTarget.kind === "local" - ? activeHostUrl - : activeOrganizationId - ? `${env.RELAY_URL}/hosts/${buildHostRoutingKey(activeOrganizationId, input.hostTarget.hostId)}` - : null; - if (!hostUrl) throw new Error("Host service not available"); - const client = getHostServiceClientByUrl(hostUrl); - return client.workspaceCreation.adopt.mutate({ - projectId: input.projectId, - workspaceName: input.workspaceName, - branch: input.branch, - }); - }, - [activeHostUrl, activeOrganizationId], - ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useCheckoutDashboardWorkspace/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useCheckoutDashboardWorkspace/index.ts deleted file mode 100644 index 9aa66b1b71a..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useCheckoutDashboardWorkspace/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { useCheckoutDashboardWorkspace } from "./useCheckoutDashboardWorkspace"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useCheckoutDashboardWorkspace/useCheckoutDashboardWorkspace.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useCheckoutDashboardWorkspace/useCheckoutDashboardWorkspace.ts deleted file mode 100644 index f0718e1f921..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useCheckoutDashboardWorkspace/useCheckoutDashboardWorkspace.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { buildHostRoutingKey } from "@superset/shared/host-routing"; -import { useCallback } from "react"; -import { env } from "renderer/env.renderer"; -import { authClient } from "renderer/lib/auth-client"; -import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; -import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; -import type { WorkspaceHostTarget } from "../../components/DashboardNewWorkspaceForm/components/DevicePicker"; - -export interface CheckoutWorkspaceInput { - pendingId: string; - projectId: string; - hostTarget: WorkspaceHostTarget; - workspaceName: string; - // Exactly one of `branch` or `pr` must be set — enforced server-side - // via zod refine. Branch mode: materialize an existing local/remote - // branch. PR mode: materialize a PR's branch via `gh pr checkout`. - branch?: string; - pr?: { - number: number; - url: string; - title: string; - headRefName: string; - baseRefName: string; - headRepositoryOwner: string; - isCrossRepository: boolean; - state: "open" | "closed" | "merged"; - }; - composer: { - prompt?: string; - // Written to `branch..base` for the Changes tab. Filled from - // picker selection in branch mode, `pr.baseRefName` in PR mode. - baseBranch?: string; - runSetupScript?: boolean; - }; - linkedContext?: { - internalIssueIds?: string[]; - githubIssueUrls?: string[]; - linkedPrUrl?: string; - attachments?: Array<{ - data: string; - mediaType: string; - filename?: string; - }>; - }; -} - -/** - * Thin wrapper around the host-service `workspaceCreation.checkout` mutation. - * Two modes: - * - Branch mode (`branch` set): reuse an existing local/remote branch. - * - PR mode (`pr` set): materialize a PR's branch via `gh pr checkout`; - * idempotent (returns `alreadyExists: true` if a workspace already exists - * for the derived branch). - */ -export function useCheckoutDashboardWorkspace() { - const { activeHostUrl } = useLocalHostService(); - const { data: session } = authClient.useSession(); - const activeOrganizationId = session?.session?.activeOrganizationId ?? null; - - return useCallback( - async (input: CheckoutWorkspaceInput) => { - const hostUrl = - input.hostTarget.kind === "local" - ? activeHostUrl - : activeOrganizationId - ? `${env.RELAY_URL}/hosts/${buildHostRoutingKey(activeOrganizationId, input.hostTarget.hostId)}` - : null; - - if (!hostUrl) { - throw new Error("Host service not available"); - } - - const client = getHostServiceClientByUrl(hostUrl); - - return client.workspaceCreation.checkout.mutate({ - pendingId: input.pendingId, - projectId: input.projectId, - workspaceName: input.workspaceName, - branch: input.branch, - pr: input.pr, - composer: input.composer, - linkedContext: input.linkedContext, - }); - }, - [activeHostUrl, activeOrganizationId], - ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useCreateDashboardWorkspace/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useCreateDashboardWorkspace/index.ts deleted file mode 100644 index 8e685c5fa3a..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useCreateDashboardWorkspace/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { useCreateDashboardWorkspace } from "./useCreateDashboardWorkspace"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useCreateDashboardWorkspace/useCreateDashboardWorkspace.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useCreateDashboardWorkspace/useCreateDashboardWorkspace.ts deleted file mode 100644 index ed890338300..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useCreateDashboardWorkspace/useCreateDashboardWorkspace.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { buildHostRoutingKey } from "@superset/shared/host-routing"; -import { useCallback } from "react"; -import { env } from "renderer/env.renderer"; -import { authClient } from "renderer/lib/auth-client"; -import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; -import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; -import type { WorkspaceHostTarget } from "../../components/DashboardNewWorkspaceForm/components/DevicePicker"; - -export interface CreateWorkspaceInput { - pendingId: string; - projectId: string; - hostTarget: WorkspaceHostTarget; - names: { - workspaceName: string; - branchName: string; - /** - * When true, host-service may replace the workspace title with an - * AI-generated name post-create. Default true preserves the rename - * path for any legacy caller that omits the field. - */ - workspaceNameWasAutoGenerated?: boolean; - }; - composer: { - prompt?: string; - baseBranch?: string; - baseBranchSource?: "local" | "remote-tracking"; - runSetupScript?: boolean; - }; - linkedContext?: { - internalIssueIds?: string[]; - githubIssueUrls?: string[]; - linkedPrUrl?: string; - attachments?: Array<{ - data: string; - mediaType: string; - filename?: string; - }>; - }; -} - -/** - * Thin wrapper around the host-service `workspaceCreation.create` mutation. - * The caller is responsible for pending state, toasts, and draft management. - */ -export function useCreateDashboardWorkspace() { - const { activeHostUrl } = useLocalHostService(); - const { data: session } = authClient.useSession(); - const activeOrganizationId = session?.session?.activeOrganizationId ?? null; - - return useCallback( - async (input: CreateWorkspaceInput) => { - const hostUrl = - input.hostTarget.kind === "local" - ? activeHostUrl - : activeOrganizationId - ? `${env.RELAY_URL}/hosts/${buildHostRoutingKey(activeOrganizationId, input.hostTarget.hostId)}` - : null; - - if (!hostUrl) { - throw new Error("Host service not available"); - } - - const client = getHostServiceClientByUrl(hostUrl); - - return client.workspaceCreation.create.mutate({ - pendingId: input.pendingId, - projectId: input.projectId, - names: input.names, - composer: input.composer, - linkedContext: input.linkedContext, - }); - }, - [activeHostUrl, activeOrganizationId], - ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts index d969fd12eff..737078f4fc9 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts @@ -47,8 +47,6 @@ import { type DashboardSidebarSectionRow, dashboardSidebarProjectSchema, dashboardSidebarSectionSchema, - type PendingWorkspaceRow, - pendingWorkspaceSchema, type V2TerminalPresetRow, type V2UserPreferencesRow, v2TerminalPresetSchema, @@ -155,13 +153,6 @@ export interface OrgCollections { typeof v2TerminalPresetSchema, z.input >; - pendingWorkspaces: Collection< - PendingWorkspaceRow, - string, - LocalStorageCollectionUtils, - typeof pendingWorkspaceSchema, - z.input - >; v2UserPreferences: Collection< V2UserPreferencesRow, string, @@ -676,15 +667,6 @@ function createOrgCollections(organizationId: string): OrgCollections { }), ); - const pendingWorkspaces = createIndexedCollection( - localStorageCollectionOptions({ - id: `pending_workspaces-${organizationId}`, - storageKey: `pending-workspaces-${organizationId}`, - schema: pendingWorkspaceSchema, - getKey: (item) => item.id, - }), - ); - const v2UserPreferences = createCollection( localStorageCollectionOptions({ id: `v2_user_preferences-${organizationId}`, @@ -723,7 +705,6 @@ function createOrgCollections(organizationId: string): OrgCollections { v2WorkspaceLocalState, v2SidebarSections, v2TerminalPresets, - pendingWorkspaces, v2UserPreferences, }; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.ts b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.ts index 8f7819fe9e6..6fbae786c21 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.ts @@ -85,123 +85,6 @@ export const v2TerminalPresetSchema = z.object({ createdAt: persistedDateSchema, }); -// Structured shapes for pending-row payload fields. Previously these were -// `z.unknown()` which forced `as`-casts at every read site and hid malformed -// rows until they crashed a later consumer. Typing them here gives the -// collection real validation and lets consumers read fields directly. -const pendingHostTargetSchema = z.discriminatedUnion("kind", [ - z.object({ kind: z.literal("local") }), - z.object({ kind: z.literal("host"), hostId: z.string() }), -]); - -const pendingLinkedIssueSchema = z.object({ - slug: z.string(), - title: z.string(), - source: z.enum(["github", "internal"]).optional(), - url: z.string().optional(), - taskId: z.string().optional(), - number: z.number().optional(), - state: z.enum(["open", "closed"]).optional(), -}); - -const pendingLinkedPRSchema = z.object({ - prNumber: z.number(), - title: z.string(), - url: z.string(), - state: z.string(), -}); - -/** - * Transient dispatch intents written by the pending page after - * host-service.create resolves. Consumed by the V2 workspace page's - * useConsumePendingLaunch mount effect, then cleared. See - * apps/desktop/docs/V2_LAUNCH_CONTEXT.md "Dispatch architecture". - */ -const pendingTerminalLaunchSchema = z.object({ - command: z.string(), - name: z.string().optional(), - // Attachment filenames, already written to .superset/attachments/ - // by the pending page via workspaceTrpc.filesystem.writeFile. - attachmentNames: z.array(z.string()).default([]), -}); - -const pendingChatLaunchSchema = z.object({ - initialPrompt: z.string().optional(), - initialFiles: z - .array( - z.object({ - data: z.string(), - mediaType: z.string(), - filename: z.string().optional(), - }), - ) - .optional(), - model: z.string().optional(), - taskSlug: z.string().optional(), -}); - -export type PendingHostTarget = z.infer; -export type PendingLinkedIssue = z.infer; -export type PendingLinkedPR = z.infer; -export type PendingTerminalLaunch = z.infer; -export type PendingChatLaunch = z.infer; - -export const pendingWorkspaceSchema = z.object({ - // Shared - id: z.string().uuid(), - projectId: z.string().uuid(), - hostTarget: pendingHostTargetSchema, - // Which mutation the pending page should run. See V2_WORKSPACE_CREATION.md §3. - // Defaults to "fork" for any rows that predate this field. - intent: z.enum(["fork", "checkout", "adopt", "pr-checkout"]).default("fork"), - name: z.string(), - // True iff `name` came from the friendly-random fallback (no user-typed - // title). Host-service uses this to decide whether to run the post-create - // AI rename — a user-typed title wins. Defaults to true for pre-field - // rows so behavior matches the unedited-name path. - workspaceNameWasAutoGenerated: z.boolean().default(true), - // fork: derived branch name from prompt; checkout/adopt: existing branch. - branchName: z.string(), - status: z.enum(["creating", "failed", "succeeded"]).default("creating"), - error: z.string().nullable().default(null), - workspaceId: z.string().nullable().default(null), - // Non-fatal messages from the procedure (e.g. "setup terminal failed"). - // Pending page renders these on success. - warnings: z.array(z.string()).default([]), - terminals: z - .array(z.object({ id: z.string(), role: z.string(), label: z.string() })) - .default([]), - createdAt: persistedDateSchema, - - // Fork-only (left at defaults for checkout/adopt). - prompt: z.string().default(""), - baseBranch: z.string().nullable().default(null), - // Picker hint: which form of `baseBranch` was selected. Lets the host- - // service skip re-resolution at create time so it can't be misled by a - // stale cached remote ref. Null when the caller didn't specify. - baseBranchSource: z - .enum(["local", "remote-tracking"]) - .nullable() - .default(null), - linkedIssues: z.array(pendingLinkedIssueSchema).default([]), - linkedPR: pendingLinkedPRSchema.nullable().default(null), - attachmentCount: z.number().int().default(0), - // User-selected agent from the modal. `"none"` = user explicitly chose not - // to launch; any other string = `AgentDefinitionId`; null = legacy rows - // (predating this field), treated as "use fallback". - agentId: z.string().nullable().default(null), - - // fork + checkout (irrelevant for adopt — worktree already exists). - runSetupScript: z.boolean().default(true), - - // Transient dispatch intents written after host-service.create resolves; - // consumed by the V2 workspace page on mount, then cleared to null. - terminalLaunch: pendingTerminalLaunchSchema.nullable().default(null), - chatLaunch: pendingChatLaunchSchema.nullable().default(null), -}); - -export type PendingWorkspaceRow = z.infer; - export type DashboardSidebarProjectRow = z.infer< typeof dashboardSidebarProjectSchema >; diff --git a/apps/desktop/src/renderer/stores/new-workspace-draft.ts b/apps/desktop/src/renderer/stores/new-workspace-draft.ts new file mode 100644 index 00000000000..38bcc830911 --- /dev/null +++ b/apps/desktop/src/renderer/stores/new-workspace-draft.ts @@ -0,0 +1,111 @@ +import { generateFriendlyBranchName } from "@superset/shared/workspace-launch"; +import { create } from "zustand"; + +export type LinkedIssue = { + slug: string; + title: string; + source?: "github" | "internal"; + url?: string; + taskId?: string; + number?: number; + state?: "open" | "closed"; +}; + +export type LinkedPR = { + prNumber: number; + title: string; + url: string; + state: string; +}; + +export type BaseBranchSource = "local" | "remote-tracking"; + +export interface DraftAttachment { + localId: string; + state: "uploading" | "ready" | "error"; + file: { name: string; size: number; mediaType: string }; + attachmentId?: string; + error?: string; +} + +export interface NewWorkspaceDraft { + selectedProjectId: string | null; + hostId: string | null; + prompt: string; + baseBranch: string | null; + baseBranchSource: BaseBranchSource | null; + workspaceName: string; + workspaceNameEdited: boolean; + branchName: string; + branchNameEdited: boolean; + linkedIssues: LinkedIssue[]; + linkedPR: LinkedPR | null; + selectedAgentId: string | null; + attachments: DraftAttachment[]; + friendlyFallback: string; +} + +interface NewWorkspaceDraftState extends NewWorkspaceDraft { + resetKey: number; + updateDraft: (patch: Partial) => void; + addAttachment: (attachment: DraftAttachment) => void; + updateAttachment: (localId: string, patch: Partial) => void; + removeAttachment: (localId: string) => void; + resetDraft: () => void; +} + +function buildInitialDraft(): NewWorkspaceDraft { + return { + selectedProjectId: null, + hostId: null, + prompt: "", + baseBranch: null, + baseBranchSource: null, + workspaceName: "", + workspaceNameEdited: false, + branchName: "", + branchNameEdited: false, + linkedIssues: [], + linkedPR: null, + selectedAgentId: null, + attachments: [], + friendlyFallback: generateFriendlyBranchName(), + }; +} + +export const useNewWorkspaceDraftStore = create( + (set) => ({ + ...buildInitialDraft(), + resetKey: 0, + updateDraft: (patch) => set((state) => ({ ...state, ...patch })), + addAttachment: (attachment) => + set((state) => ({ + ...state, + attachments: [...state.attachments, attachment], + })), + updateAttachment: (localId, patch) => + set((state) => ({ + ...state, + attachments: state.attachments.map((entry) => + entry.localId === localId ? { ...entry, ...patch } : entry, + ), + })), + removeAttachment: (localId) => + set((state) => ({ + ...state, + attachments: state.attachments.filter( + (entry) => entry.localId !== localId, + ), + })), + resetDraft: () => + set((state) => ({ + ...buildInitialDraft(), + resetKey: state.resetKey + 1, + updateDraft: state.updateDraft, + addAttachment: state.addAttachment, + updateAttachment: state.updateAttachment, + removeAttachment: state.removeAttachment, + resetDraft: state.resetDraft, + })), + }), +); diff --git a/apps/desktop/src/renderer/stores/v2-workspace-create-defaults.ts b/apps/desktop/src/renderer/stores/v2-workspace-create-defaults.ts index 25f40660eb3..6b1c304dfc2 100644 --- a/apps/desktop/src/renderer/stores/v2-workspace-create-defaults.ts +++ b/apps/desktop/src/renderer/stores/v2-workspace-create-defaults.ts @@ -8,14 +8,10 @@ export interface V2WorkspaceCreateBaseBranchDefault { source: V2WorkspaceCreateBaseBranchSource; } -export type V2WorkspaceCreateHostTarget = - | { kind: "local" } - | { kind: "host"; hostId: string }; - interface V2WorkspaceCreateDefaultsState { lastProjectId: string | null; baseBranchesByProjectId: Record; - lastHostTarget: V2WorkspaceCreateHostTarget | null; + lastHostId: string | null; setLastProjectId: (projectId: string | null) => void; setBaseBranchDefault: ( @@ -24,7 +20,7 @@ interface V2WorkspaceCreateDefaultsState { source: V2WorkspaceCreateBaseBranchSource, ) => void; clearBaseBranchDefault: (projectId: string) => void; - setLastHostTarget: (target: V2WorkspaceCreateHostTarget) => void; + setLastHostId: (hostId: string | null) => void; } export const useV2WorkspaceCreateDefaultsStore = @@ -34,7 +30,7 @@ export const useV2WorkspaceCreateDefaultsStore = (set) => ({ lastProjectId: null, baseBranchesByProjectId: {}, - lastHostTarget: null, + lastHostId: null, setLastProjectId: (projectId) => set({ lastProjectId: projectId }), @@ -57,11 +53,28 @@ export const useV2WorkspaceCreateDefaultsStore = return { baseBranchesByProjectId: next }; }), - setLastHostTarget: (target) => set({ lastHostTarget: target }), + setLastHostId: (hostId) => set({ lastHostId: hostId }), }), { name: "v2-workspace-create-defaults", - version: 1, + version: 2, + migrate: (state, fromVersion) => { + if (fromVersion < 2 && state && typeof state === "object") { + const prev = state as Record; + const oldTarget = prev.lastHostTarget as + | { kind: "local" } + | { kind: "host"; hostId: string } + | null + | undefined; + const lastHostId = + oldTarget && oldTarget.kind === "host" + ? oldTarget.hostId + : null; + const { lastHostTarget: _omit, ...rest } = prev; + return { ...rest, lastHostId }; + } + return state; + }, }, ), { name: "V2WorkspaceCreateDefaultsStore" }, diff --git a/apps/desktop/src/renderer/stores/workspace-creates/Manager.tsx b/apps/desktop/src/renderer/stores/workspace-creates/Manager.tsx new file mode 100644 index 00000000000..34910a81670 --- /dev/null +++ b/apps/desktop/src/renderer/stores/workspace-creates/Manager.tsx @@ -0,0 +1,30 @@ +import { useLiveQuery } from "@tanstack/react-db"; +import { useEffect } from "react"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { useWorkspaceCreatesStore } from "./store"; + +export function WorkspaceCreatesManager() { + const collections = useCollections(); + const { data: workspaces = [] } = useLiveQuery( + (q) => + q.from({ ws: collections.v2Workspaces }).select(({ ws }) => ({ + id: ws.id, + })), + [collections], + ); + const entries = useWorkspaceCreatesStore((store) => store.entries); + + useEffect(() => { + if (workspaces.length === 0 || entries.length === 0) return; + const realIds = new Set(workspaces.map((w) => w.id)); + const remove = useWorkspaceCreatesStore.getState().remove; + for (const entry of entries) { + const id = entry.snapshot.id; + if (id && realIds.has(id)) { + remove(id); + } + } + }, [workspaces, entries]); + + return null; +} diff --git a/apps/desktop/src/renderer/stores/workspace-creates/index.ts b/apps/desktop/src/renderer/stores/workspace-creates/index.ts new file mode 100644 index 00000000000..30418e4d0aa --- /dev/null +++ b/apps/desktop/src/renderer/stores/workspace-creates/index.ts @@ -0,0 +1,11 @@ +export { WorkspaceCreatesManager } from "./Manager"; +export { + type InFlightEntry, + useWorkspaceCreatesStore, + type WorkspacesCreateInput, +} from "./store"; +export { + type SubmitArgs, + type UseWorkspaceCreatesApi, + useWorkspaceCreates, +} from "./useWorkspaceCreates"; diff --git a/apps/desktop/src/renderer/stores/workspace-creates/store.ts b/apps/desktop/src/renderer/stores/workspace-creates/store.ts new file mode 100644 index 00000000000..3842407936f --- /dev/null +++ b/apps/desktop/src/renderer/stores/workspace-creates/store.ts @@ -0,0 +1,54 @@ +import type { AppRouter } from "@superset/host-service"; +import type { inferRouterInputs } from "@trpc/server"; +import { create } from "zustand"; + +export type WorkspacesCreateInput = + inferRouterInputs["workspaces"]["create"]; + +export interface InFlightEntry { + hostId: string; + snapshot: WorkspacesCreateInput; + state: "creating" | "error"; + error?: string; + startedAt: number; +} + +interface WorkspaceCreatesState { + entries: InFlightEntry[]; + add: (entry: Omit) => void; + markError: (workspaceId: string, error: string) => void; + markCreating: (workspaceId: string) => void; + remove: (workspaceId: string) => void; +} + +export const useWorkspaceCreatesStore = create( + (set) => ({ + entries: [], + add: (entry) => + set((state) => ({ + entries: [...state.entries, { ...entry, startedAt: Date.now() }], + })), + markError: (workspaceId, error) => + set((state) => ({ + entries: state.entries.map((entry) => + entry.snapshot.id === workspaceId + ? { ...entry, state: "error", error } + : entry, + ), + })), + markCreating: (workspaceId) => + set((state) => ({ + entries: state.entries.map((entry) => + entry.snapshot.id === workspaceId + ? { ...entry, state: "creating", error: undefined } + : entry, + ), + })), + remove: (workspaceId) => + set((state) => ({ + entries: state.entries.filter( + (entry) => entry.snapshot.id !== workspaceId, + ), + })), + }), +); diff --git a/apps/desktop/src/renderer/stores/workspace-creates/useWorkspaceCreates.ts b/apps/desktop/src/renderer/stores/workspace-creates/useWorkspaceCreates.ts new file mode 100644 index 00000000000..f4c1b5ee2b7 --- /dev/null +++ b/apps/desktop/src/renderer/stores/workspace-creates/useWorkspaceCreates.ts @@ -0,0 +1,130 @@ +import { useCallback } from "react"; +import { resolveHostUrl } from "renderer/hooks/host-service/useHostTargetUrl"; +import { authClient } from "renderer/lib/auth-client"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { addLaunchPanes } from "renderer/lib/workspace-pane-registry"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; +import { + type InFlightEntry, + useWorkspaceCreatesStore, + type WorkspacesCreateInput, +} from "./store"; + +export interface SubmitArgs { + hostId: string; + snapshot: WorkspacesCreateInput; +} + +export interface UseWorkspaceCreatesApi { + entries: InFlightEntry[]; + submit: (args: SubmitArgs) => Promise; + retry: (workspaceId: string) => Promise; + dismiss: (workspaceId: string) => void; +} + +export function useWorkspaceCreates(): UseWorkspaceCreatesApi { + const entries = useWorkspaceCreatesStore((s) => s.entries); + const { machineId, activeHostUrl } = useLocalHostService(); + const { data: session } = authClient.useSession(); + const organizationId = session?.session?.activeOrganizationId; + + const dispatch = useCallback( + async (args: SubmitArgs) => { + const workspaceId = args.snapshot.id; + if (!workspaceId) { + throw new Error( + "workspaces.create requires `id` for in-flight tracking", + ); + } + if (!organizationId) { + useWorkspaceCreatesStore + .getState() + .markError(workspaceId, "No active organization"); + return; + } + const hostUrl = resolveHostUrl({ + hostId: args.hostId, + machineId, + activeHostUrl, + organizationId, + }); + if (!hostUrl) { + useWorkspaceCreatesStore + .getState() + .markError(workspaceId, "Host service not available"); + return; + } + try { + const client = getHostServiceClientByUrl(hostUrl); + const result = await client.workspaces.create.mutate(args.snapshot); + const launchPanes: Array< + | { kind: "terminal"; terminalId: string; label?: string } + | { kind: "chat"; chatSessionId: string; label?: string } + > = [ + ...result.terminals.map((entry) => ({ + kind: "terminal" as const, + terminalId: entry.terminalId, + label: entry.label, + })), + ...result.agents + .filter((entry) => entry.ok) + .map((entry) => ({ + kind: "terminal" as const, + terminalId: entry.sessionId, + label: entry.label, + })), + ]; + if (launchPanes.length > 0) { + addLaunchPanes(result.workspace.id, launchPanes); + } + // Don't remove on success — the Manager removes the entry once the + // v2Workspaces Electric collection has the matching id, closing the + // gap between cloud confirmation and renderer collection update. + } catch (err) { + useWorkspaceCreatesStore + .getState() + .markError( + workspaceId, + err instanceof Error ? err.message : String(err), + ); + } + }, + [machineId, activeHostUrl, organizationId], + ); + + const submit = useCallback( + async (args: SubmitArgs) => { + const workspaceId = args.snapshot.id; + if (!workspaceId) { + throw new Error( + "workspaces.create requires `id` for in-flight tracking", + ); + } + useWorkspaceCreatesStore.getState().add({ + hostId: args.hostId, + snapshot: args.snapshot, + state: "creating", + }); + await dispatch(args); + }, + [dispatch], + ); + + const retry = useCallback( + async (workspaceId: string) => { + const entry = useWorkspaceCreatesStore + .getState() + .entries.find((e) => e.snapshot.id === workspaceId); + if (!entry) return; + useWorkspaceCreatesStore.getState().markCreating(workspaceId); + await dispatch({ hostId: entry.hostId, snapshot: entry.snapshot }); + }, + [dispatch], + ); + + const dismiss = useCallback((workspaceId: string) => { + useWorkspaceCreatesStore.getState().remove(workspaceId); + }, []); + + return { entries, submit, retry, dismiss }; +} diff --git a/bun.lock b/bun.lock index a5e0573d07c..179fecfc426 100644 --- a/bun.lock +++ b/bun.lock @@ -111,7 +111,7 @@ }, "apps/desktop": { "name": "@superset/desktop", - "version": "1.7.3", + "version": "1.8.0", "dependencies": { "@ai-sdk/anthropic": "^3.0.43", "@ai-sdk/openai": "3.0.36", diff --git a/packages/cli/src/commands/workspaces/create/command.ts b/packages/cli/src/commands/workspaces/create/command.ts index 2b3e6139f7c..8d2d93d2a50 100644 --- a/packages/cli/src/commands/workspaces/create/command.ts +++ b/packages/cli/src/commands/workspaces/create/command.ts @@ -1,4 +1,4 @@ -import { boolean, CLIError, string } from "@superset/cli-framework"; +import { boolean, CLIError, number, string } from "@superset/cli-framework"; import { command } from "../../../lib/command"; import { requireHostTarget, resolveHostTarget } from "../../../lib/host-target"; @@ -9,7 +9,11 @@ export default command({ local: boolean().desc("Target this machine"), project: string().required().desc("Project ID"), name: string().required().desc("Workspace name"), - branch: string().required().desc("Git branch"), + branch: string().desc("Git branch (required unless --pr is set)"), + pr: number().desc("PR number — derives branch via gh pr checkout"), + baseBranch: string().desc( + "Branch to fork from when `branch` does not exist (defaults to project default)", + ), }, run: async ({ ctx, options }) => { const organizationId = ctx.config.organizationId; @@ -17,6 +21,13 @@ export default command({ throw new CLIError("No active organization", "Run: superset auth login"); } + if (Boolean(options.branch) === Boolean(options.pr)) { + throw new CLIError( + "Specify exactly one of --branch or --pr", + "Use --branch or --pr ", + ); + } + const hostId = requireHostTarget({ host: options.host ?? undefined, local: options.local ?? undefined, @@ -28,15 +39,19 @@ export default command({ userJwt: ctx.bearer, }); - const workspace = await target.client.workspace.create.mutate({ + const result = await target.client.workspaces.create.mutate({ projectId: options.project, name: options.name, branch: options.branch, + pr: options.pr, + baseBranch: options.baseBranch, }); return { - data: workspace, - message: `Created workspace "${options.name}" on host ${target.hostId}`, + data: result, + message: result.alreadyExists + ? `Reused existing workspace "${result.workspace.name}" on host ${target.hostId}` + : `Created workspace "${result.workspace.name}" on host ${target.hostId}`, }; }, }); diff --git a/packages/db/drizzle/0043_add_workspace_tasks.sql b/packages/db/drizzle/0043_add_workspace_tasks.sql new file mode 100644 index 00000000000..2a21cf679c8 --- /dev/null +++ b/packages/db/drizzle/0043_add_workspace_tasks.sql @@ -0,0 +1,10 @@ +CREATE TABLE "workspace_tasks" ( + "workspace_id" uuid NOT NULL, + "task_id" uuid NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "workspace_tasks_workspace_id_task_id_pk" PRIMARY KEY("workspace_id","task_id") +); +--> statement-breakpoint +ALTER TABLE "workspace_tasks" ADD CONSTRAINT "workspace_tasks_workspace_id_v2_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."v2_workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "workspace_tasks" ADD CONSTRAINT "workspace_tasks_task_id_tasks_id_fk" FOREIGN KEY ("task_id") REFERENCES "public"."tasks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "workspace_tasks_task_idx" ON "workspace_tasks" USING btree ("task_id"); \ No newline at end of file diff --git a/packages/db/drizzle/meta/0043_snapshot.json b/packages/db/drizzle/meta/0043_snapshot.json new file mode 100644 index 00000000000..6beb9835967 --- /dev/null +++ b/packages/db/drizzle/meta/0043_snapshot.json @@ -0,0 +1,5912 @@ +{ + "id": "476fad90-c469-4f15-b3d1-b27e084899c2", + "prevId": "1875cb9e-d6b5-4ba5-93fe-83a4f9fff27e", + "version": "7", + "dialect": "postgresql", + "tables": { + "auth.accounts": { + "name": "accounts", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "accounts_user_id_idx": { + "name": "accounts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.apikeys": { + "name": "apikeys", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 86400000 + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_request": { + "name": "last_request", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "apikeys_configId_idx": { + "name": "apikeys_configId_idx", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "apikeys_referenceId_idx": { + "name": "apikeys_referenceId_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "apikeys_key_idx": { + "name": "apikeys_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.device_codes": { + "name": "device_codes", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "device_code": { + "name": "device_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_code": { + "name": "user_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_polled_at": { + "name": "last_polled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "polling_interval": { + "name": "polling_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.invitations": { + "name": "invitations", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "inviter_id": { + "name": "inviter_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "invitations_organization_id_idx": { + "name": "invitations_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitations_email_idx": { + "name": "invitations_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitations_organization_id_organizations_id_fk": { + "name": "invitations_organization_id_organizations_id_fk", + "tableFrom": "invitations", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitations_inviter_id_users_id_fk": { + "name": "invitations_inviter_id_users_id_fk", + "tableFrom": "invitations", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.jwkss": { + "name": "jwkss", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.members": { + "name": "members", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "members_organization_id_idx": { + "name": "members_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "members_user_id_idx": { + "name": "members_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "members_organization_id_organizations_id_fk": { + "name": "members_organization_id_organizations_id_fk", + "tableFrom": "members", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "members_user_id_users_id_fk": { + "name": "members_user_id_users_id_fk", + "tableFrom": "members", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.oauth_access_tokens": { + "name": "oauth_access_tokens", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_id": { + "name": "refresh_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_access_tokens_client_id_oauth_clients_client_id_fk": { + "name": "oauth_access_tokens_client_id_oauth_clients_client_id_fk", + "tableFrom": "oauth_access_tokens", + "tableTo": "oauth_clients", + "schemaTo": "auth", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_tokens_session_id_sessions_id_fk": { + "name": "oauth_access_tokens_session_id_sessions_id_fk", + "tableFrom": "oauth_access_tokens", + "tableTo": "sessions", + "schemaTo": "auth", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_tokens_user_id_users_id_fk": { + "name": "oauth_access_tokens_user_id_users_id_fk", + "tableFrom": "oauth_access_tokens", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_tokens_refresh_id_oauth_refresh_tokens_id_fk": { + "name": "oauth_access_tokens_refresh_id_oauth_refresh_tokens_id_fk", + "tableFrom": "oauth_access_tokens", + "tableTo": "oauth_refresh_tokens", + "schemaTo": "auth", + "columnsFrom": [ + "refresh_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_tokens_token_unique": { + "name": "oauth_access_tokens_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.oauth_clients": { + "name": "oauth_clients", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contacts": { + "name": "contacts", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_types": { + "name": "grant_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "response_types": { + "name": "response_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "require_pkce": { + "name": "require_pkce", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_clients_user_id_users_id_fk": { + "name": "oauth_clients_user_id_users_id_fk", + "tableFrom": "oauth_clients", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_clients_client_id_unique": { + "name": "oauth_clients_client_id_unique", + "nullsNotDistinct": false, + "columns": [ + "client_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.oauth_consents": { + "name": "oauth_consents", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_consents_client_id_oauth_clients_client_id_fk": { + "name": "oauth_consents_client_id_oauth_clients_client_id_fk", + "tableFrom": "oauth_consents", + "tableTo": "oauth_clients", + "schemaTo": "auth", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consents_user_id_users_id_fk": { + "name": "oauth_consents_user_id_users_id_fk", + "tableFrom": "oauth_consents", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.oauth_refresh_tokens": { + "name": "oauth_refresh_tokens", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked": { + "name": "revoked", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "auth_time": { + "name": "auth_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_refresh_tokens_client_id_oauth_clients_client_id_fk": { + "name": "oauth_refresh_tokens_client_id_oauth_clients_client_id_fk", + "tableFrom": "oauth_refresh_tokens", + "tableTo": "oauth_clients", + "schemaTo": "auth", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_tokens_session_id_sessions_id_fk": { + "name": "oauth_refresh_tokens_session_id_sessions_id_fk", + "tableFrom": "oauth_refresh_tokens", + "tableTo": "sessions", + "schemaTo": "auth", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_tokens_user_id_users_id_fk": { + "name": "oauth_refresh_tokens_user_id_users_id_fk", + "tableFrom": "oauth_refresh_tokens", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.organizations": { + "name": "organizations", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_domains": { + "name": "allowed_domains", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + } + }, + "indexes": { + "organizations_slug_idx": { + "name": "organizations_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organizations_allowed_domains_idx": { + "name": "organizations_allowed_domains_idx", + "columns": [ + { + "expression": "allowed_domains", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.sessions": { + "name": "sessions", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.users": { + "name": "users", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_ids": { + "name": "organization_ids", + "type": "uuid[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.verifications": { + "name": "verifications", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verifications_identifier_idx": { + "name": "verifications_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_installations": { + "name": "github_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "connected_by_user_id": { + "name": "connected_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_login": { + "name": "account_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_type": { + "name": "account_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "suspended": { + "name": "suspended", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_installations_installation_id_idx": { + "name": "github_installations_installation_id_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_installations_organization_id_organizations_id_fk": { + "name": "github_installations_organization_id_organizations_id_fk", + "tableFrom": "github_installations", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_installations_connected_by_user_id_users_id_fk": { + "name": "github_installations_connected_by_user_id_users_id_fk", + "tableFrom": "github_installations", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "connected_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_installations_installation_id_unique": { + "name": "github_installations_installation_id_unique", + "nullsNotDistinct": false, + "columns": [ + "installation_id" + ] + }, + "github_installations_org_unique": { + "name": "github_installations_org_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_pull_requests": { + "name": "github_pull_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "node_id": { + "name": "node_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "head_branch": { + "name": "head_branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_login": { + "name": "author_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_avatar_url": { + "name": "author_avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_draft": { + "name": "is_draft", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "additions": { + "name": "additions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "deletions": { + "name": "deletions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "changed_files": { + "name": "changed_files", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "review_decision": { + "name": "review_decision", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checks_status": { + "name": "checks_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "checks": { + "name": "checks", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "merged_at": { + "name": "merged_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_pull_requests_repository_id_idx": { + "name": "github_pull_requests_repository_id_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_pull_requests_state_idx": { + "name": "github_pull_requests_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_pull_requests_head_branch_idx": { + "name": "github_pull_requests_head_branch_idx", + "columns": [ + { + "expression": "head_branch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_pull_requests_org_id_idx": { + "name": "github_pull_requests_org_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_pull_requests_repository_id_github_repositories_id_fk": { + "name": "github_pull_requests_repository_id_github_repositories_id_fk", + "tableFrom": "github_pull_requests", + "tableTo": "github_repositories", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_pull_requests_organization_id_organizations_id_fk": { + "name": "github_pull_requests_organization_id_organizations_id_fk", + "tableFrom": "github_pull_requests", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_pull_requests_repo_pr_unique": { + "name": "github_pull_requests_repo_pr_unique", + "nullsNotDistinct": false, + "columns": [ + "repository_id", + "pr_number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_repositories": { + "name": "github_repositories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "installation_id": { + "name": "installation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "repo_id": { + "name": "repo_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "full_name": { + "name": "full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "is_private": { + "name": "is_private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_repositories_installation_id_idx": { + "name": "github_repositories_installation_id_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_repositories_full_name_idx": { + "name": "github_repositories_full_name_idx", + "columns": [ + { + "expression": "full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_repositories_org_id_idx": { + "name": "github_repositories_org_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_repositories_installation_id_github_installations_id_fk": { + "name": "github_repositories_installation_id_github_installations_id_fk", + "tableFrom": "github_repositories", + "tableTo": "github_installations", + "columnsFrom": [ + "installation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_repositories_organization_id_organizations_id_fk": { + "name": "github_repositories_organization_id_organizations_id_fk", + "tableFrom": "github_repositories", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_repositories_repo_id_unique": { + "name": "github_repositories_repo_id_unique", + "nullsNotDistinct": false, + "columns": [ + "repo_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "ingest.webhook_events": { + "name": "webhook_events", + "schema": "ingest", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "integration_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "received_at": { + "name": "received_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_events_provider_status_idx": { + "name": "webhook_events_provider_status_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_events_provider_event_id_idx": { + "name": "webhook_events_provider_event_id_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_events_received_at_idx": { + "name": "webhook_events_received_at_idx", + "columns": [ + { + "expression": "received_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_commands": { + "name": "agent_commands", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_device_id": { + "name": "target_device_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_device_type": { + "name": "target_device_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool": { + "name": "tool", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "parent_command_id": { + "name": "parent_command_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "command_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "executed_at": { + "name": "executed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "timeout_at": { + "name": "timeout_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "agent_commands_user_status_idx": { + "name": "agent_commands_user_status_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_commands_target_device_status_idx": { + "name": "agent_commands_target_device_status_idx", + "columns": [ + { + "expression": "target_device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_commands_org_created_idx": { + "name": "agent_commands_org_created_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_commands_user_id_users_id_fk": { + "name": "agent_commands_user_id_users_id_fk", + "tableFrom": "agent_commands", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_commands_organization_id_organizations_id_fk": { + "name": "agent_commands_organization_id_organizations_id_fk", + "tableFrom": "agent_commands", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automation_prompt_versions": { + "name": "automation_prompt_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "automation_id": { + "name": "automation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_user_id": { + "name": "author_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "window_bucket": { + "name": "window_bucket", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "automation_prompt_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "restored_from_version_id": { + "name": "restored_from_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "automation_prompt_versions_bucket_uniq": { + "name": "automation_prompt_versions_bucket_uniq", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "author_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "window_bucket", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"automation_prompt_versions\".\"source\" <> 'restore'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_prompt_versions_automation_idx": { + "name": "automation_prompt_versions_automation_idx", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "automation_prompt_versions_automation_id_automations_id_fk": { + "name": "automation_prompt_versions_automation_id_automations_id_fk", + "tableFrom": "automation_prompt_versions", + "tableTo": "automations", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "automation_prompt_versions_author_user_id_users_id_fk": { + "name": "automation_prompt_versions_author_user_id_users_id_fk", + "tableFrom": "automation_prompt_versions", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "author_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "automation_prompt_versions_restored_from_version_id_fk": { + "name": "automation_prompt_versions_restored_from_version_id_fk", + "tableFrom": "automation_prompt_versions", + "tableTo": "automation_prompt_versions", + "columnsFrom": [ + "restored_from_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automation_runs": { + "name": "automation_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "automation_id": { + "name": "automation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scheduled_for": { + "name": "scheduled_for", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "v2_workspace_id": { + "name": "v2_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "session_kind": { + "name": "session_kind", + "type": "automation_session_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "chat_session_id": { + "name": "chat_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "terminal_session_id": { + "name": "terminal_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "automation_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dispatched_at": { + "name": "dispatched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "automation_runs_dedup_idx": { + "name": "automation_runs_dedup_idx", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduled_for", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_runs_history_idx": { + "name": "automation_runs_history_idx", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_runs_status_idx": { + "name": "automation_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_runs_workspace_idx": { + "name": "automation_runs_workspace_idx", + "columns": [ + { + "expression": "v2_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "automation_runs_automation_id_automations_id_fk": { + "name": "automation_runs_automation_id_automations_id_fk", + "tableFrom": "automation_runs", + "tableTo": "automations", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "automation_runs_organization_id_organizations_id_fk": { + "name": "automation_runs_organization_id_organizations_id_fk", + "tableFrom": "automation_runs", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "automation_runs_chat_session_id_chat_sessions_id_fk": { + "name": "automation_runs_chat_session_id_chat_sessions_id_fk", + "tableFrom": "automation_runs", + "tableTo": "chat_sessions", + "columnsFrom": [ + "chat_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automations": { + "name": "automations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_config": { + "name": "agent_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "target_host_id": { + "name": "target_host_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "v2_project_id": { + "name": "v2_project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "v2_workspace_id": { + "name": "v2_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "rrule": { + "name": "rrule", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dtstart": { + "name": "dtstart", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "mcp_scope": { + "name": "mcp_scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "automations_dispatcher_idx": { + "name": "automations_dispatcher_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "automations_owner_idx": { + "name": "automations_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "automations_organization_idx": { + "name": "automations_organization_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "automations_organization_id_organizations_id_fk": { + "name": "automations_organization_id_organizations_id_fk", + "tableFrom": "automations", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "automations_owner_user_id_users_id_fk": { + "name": "automations_owner_user_id_users_id_fk", + "tableFrom": "automations", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "automations_v2_project_id_v2_projects_id_fk": { + "name": "automations_v2_project_id_v2_projects_id_fk", + "tableFrom": "automations", + "tableTo": "v2_projects", + "columnsFrom": [ + "v2_project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_sessions": { + "name": "chat_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "v2_workspace_id": { + "name": "v2_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_sessions_org_idx": { + "name": "chat_sessions_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_sessions_created_by_idx": { + "name": "chat_sessions_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_sessions_last_active_idx": { + "name": "chat_sessions_last_active_idx", + "columns": [ + { + "expression": "last_active_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_sessions_organization_id_organizations_id_fk": { + "name": "chat_sessions_organization_id_organizations_id_fk", + "tableFrom": "chat_sessions", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_sessions_created_by_users_id_fk": { + "name": "chat_sessions_created_by_users_id_fk", + "tableFrom": "chat_sessions", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_sessions_workspace_id_workspaces_id_fk": { + "name": "chat_sessions_workspace_id_workspaces_id_fk", + "tableFrom": "chat_sessions", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "chat_sessions_v2_workspace_id_v2_workspaces_id_fk": { + "name": "chat_sessions_v2_workspace_id_v2_workspaces_id_fk", + "tableFrom": "chat_sessions", + "tableTo": "v2_workspaces", + "columnsFrom": [ + "v2_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_presence": { + "name": "device_presence", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_name": { + "name": "device_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "device_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "device_presence_user_org_idx": { + "name": "device_presence_user_org_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_presence_user_device_idx": { + "name": "device_presence_user_device_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_presence_last_seen_idx": { + "name": "device_presence_last_seen_idx", + "columns": [ + { + "expression": "last_seen_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "device_presence_user_id_users_id_fk": { + "name": "device_presence_user_id_users_id_fk", + "tableFrom": "device_presence", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "device_presence_organization_id_organizations_id_fk": { + "name": "device_presence_organization_id_organizations_id_fk", + "tableFrom": "device_presence", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration_connections": { + "name": "integration_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "connected_by_user_id": { + "name": "connected_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "integration_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_expires_at": { + "name": "token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "disconnected_at": { + "name": "disconnected_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "disconnect_reason": { + "name": "disconnect_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_org_id": { + "name": "external_org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_org_name": { + "name": "external_org_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "integration_connections_org_idx": { + "name": "integration_connections_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "integration_connections_organization_id_organizations_id_fk": { + "name": "integration_connections_organization_id_organizations_id_fk", + "tableFrom": "integration_connections", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integration_connections_connected_by_user_id_users_id_fk": { + "name": "integration_connections_connected_by_user_id_users_id_fk", + "tableFrom": "integration_connections", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "connected_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "integration_connections_unique": { + "name": "integration_connections_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "provider" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_repository_id": { + "name": "github_repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "repo_owner": { + "name": "repo_owner", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_name": { + "name": "repo_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "projects_organization_id_idx": { + "name": "projects_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_organization_id_organizations_id_fk": { + "name": "projects_organization_id_organizations_id_fk", + "tableFrom": "projects", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "projects_github_repository_id_github_repositories_id_fk": { + "name": "projects_github_repository_id_github_repositories_id_fk", + "tableFrom": "projects", + "tableTo": "github_repositories", + "columnsFrom": [ + "github_repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "projects_org_slug_unique": { + "name": "projects_org_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandbox_images": { + "name": "sandbox_images", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "setup_commands": { + "name": "setup_commands", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "base_image": { + "name": "base_image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "system_packages": { + "name": "system_packages", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sandbox_images_organization_id_idx": { + "name": "sandbox_images_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sandbox_images_organization_id_organizations_id_fk": { + "name": "sandbox_images_organization_id_organizations_id_fk", + "tableFrom": "sandbox_images", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sandbox_images_project_id_projects_id_fk": { + "name": "sandbox_images_project_id_projects_id_fk", + "tableFrom": "sandbox_images", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sandbox_images_project_unique": { + "name": "sandbox_images_project_unique", + "nullsNotDistinct": false, + "columns": [ + "project_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.secrets": { + "name": "secrets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sensitive": { + "name": "sensitive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "secrets_project_id_idx": { + "name": "secrets_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "secrets_organization_id_idx": { + "name": "secrets_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "secrets_organization_id_organizations_id_fk": { + "name": "secrets_organization_id_organizations_id_fk", + "tableFrom": "secrets", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "secrets_project_id_projects_id_fk": { + "name": "secrets_project_id_projects_id_fk", + "tableFrom": "secrets", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "secrets_created_by_user_id_users_id_fk": { + "name": "secrets_created_by_user_id_users_id_fk", + "tableFrom": "secrets", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "secrets_project_key_unique": { + "name": "secrets_project_key_unique", + "nullsNotDistinct": false, + "columns": [ + "project_id", + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscriptions": { + "name": "subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'incomplete'" + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "subscriptions_reference_id_idx": { + "name": "subscriptions_reference_id_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscriptions_stripe_customer_id_idx": { + "name": "subscriptions_stripe_customer_id_idx", + "columns": [ + { + "expression": "stripe_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscriptions_status_idx": { + "name": "subscriptions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "subscriptions_reference_id_organizations_id_fk": { + "name": "subscriptions_reference_id_organizations_id_fk", + "tableFrom": "subscriptions", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "reference_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_statuses": { + "name": "task_statuses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "progress_percent": { + "name": "progress_percent", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "external_provider": { + "name": "external_provider", + "type": "integration_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_statuses_organization_id_idx": { + "name": "task_statuses_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_statuses_type_idx": { + "name": "task_statuses_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_statuses_organization_id_organizations_id_fk": { + "name": "task_statuses_organization_id_organizations_id_fk", + "tableFrom": "task_statuses", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "task_statuses_org_external_unique": { + "name": "task_statuses_org_external_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "external_provider", + "external_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_id": { + "name": "status_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "task_priority", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "assignee_id": { + "name": "assignee_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "creator_id": { + "name": "creator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "estimate": { + "name": "estimate", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "due_date": { + "name": "due_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "labels": { + "name": "labels", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_provider": { + "name": "external_provider", + "type": "integration_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_key": { + "name": "external_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_url": { + "name": "external_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignee_external_id": { + "name": "assignee_external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignee_display_name": { + "name": "assignee_display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignee_avatar_url": { + "name": "assignee_avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tasks_slug_idx": { + "name": "tasks_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_organization_id_idx": { + "name": "tasks_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_assignee_id_idx": { + "name": "tasks_assignee_id_idx", + "columns": [ + { + "expression": "assignee_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_creator_id_idx": { + "name": "tasks_creator_id_idx", + "columns": [ + { + "expression": "creator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_status_id_idx": { + "name": "tasks_status_id_idx", + "columns": [ + { + "expression": "status_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_created_at_idx": { + "name": "tasks_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_external_provider_idx": { + "name": "tasks_external_provider_idx", + "columns": [ + { + "expression": "external_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_assignee_external_id_idx": { + "name": "tasks_assignee_external_id_idx", + "columns": [ + { + "expression": "assignee_external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tasks_status_id_task_statuses_id_fk": { + "name": "tasks_status_id_task_statuses_id_fk", + "tableFrom": "tasks", + "tableTo": "task_statuses", + "columnsFrom": [ + "status_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "tasks_organization_id_organizations_id_fk": { + "name": "tasks_organization_id_organizations_id_fk", + "tableFrom": "tasks", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_assignee_id_users_id_fk": { + "name": "tasks_assignee_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tasks_creator_id_users_id_fk": { + "name": "tasks_creator_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tasks_external_unique": { + "name": "tasks_external_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "external_provider", + "external_id" + ] + }, + "tasks_org_slug_unique": { + "name": "tasks_org_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users__slack_users": { + "name": "users__slack_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slack_user_id": { + "name": "slack_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "model_preference": { + "name": "model_preference", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users__slack_users_user_idx": { + "name": "users__slack_users_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users__slack_users_org_idx": { + "name": "users__slack_users_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "users__slack_users_user_id_users_id_fk": { + "name": "users__slack_users_user_id_users_id_fk", + "tableFrom": "users__slack_users", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "users__slack_users_organization_id_organizations_id_fk": { + "name": "users__slack_users_organization_id_organizations_id_fk", + "tableFrom": "users__slack_users", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users__slack_users_unique": { + "name": "users__slack_users_unique", + "nullsNotDistinct": false, + "columns": [ + "slack_user_id", + "team_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.v2_clients": { + "name": "v2_clients", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "machine_id": { + "name": "machine_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "v2_client_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "v2_clients_organization_id_idx": { + "name": "v2_clients_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "v2_clients_user_id_idx": { + "name": "v2_clients_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "v2_clients_organization_id_organizations_id_fk": { + "name": "v2_clients_organization_id_organizations_id_fk", + "tableFrom": "v2_clients", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "v2_clients_user_id_users_id_fk": { + "name": "v2_clients_user_id_users_id_fk", + "tableFrom": "v2_clients", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "v2_clients_organization_id_user_id_machine_id_pk": { + "name": "v2_clients_organization_id_user_id_machine_id_pk", + "columns": [ + "organization_id", + "user_id", + "machine_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.v2_hosts": { + "name": "v2_hosts", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "machine_id": { + "name": "machine_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_online": { + "name": "is_online", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "v2_hosts_organization_id_idx": { + "name": "v2_hosts_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "v2_hosts_organization_id_organizations_id_fk": { + "name": "v2_hosts_organization_id_organizations_id_fk", + "tableFrom": "v2_hosts", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "v2_hosts_created_by_user_id_users_id_fk": { + "name": "v2_hosts_created_by_user_id_users_id_fk", + "tableFrom": "v2_hosts", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "v2_hosts_organization_id_machine_id_pk": { + "name": "v2_hosts_organization_id_machine_id_pk", + "columns": [ + "organization_id", + "machine_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.v2_projects": { + "name": "v2_projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_clone_url": { + "name": "repo_clone_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_repository_id": { + "name": "github_repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "v2_projects_organization_id_idx": { + "name": "v2_projects_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "v2_projects_organization_id_organizations_id_fk": { + "name": "v2_projects_organization_id_organizations_id_fk", + "tableFrom": "v2_projects", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "v2_projects_github_repository_id_github_repositories_id_fk": { + "name": "v2_projects_github_repository_id_github_repositories_id_fk", + "tableFrom": "v2_projects", + "tableTo": "github_repositories", + "columnsFrom": [ + "github_repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "v2_projects_org_slug_unique": { + "name": "v2_projects_org_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.v2_users_hosts": { + "name": "v2_users_hosts", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "v2_users_host_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "v2_users_hosts_organization_id_idx": { + "name": "v2_users_hosts_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "v2_users_hosts_user_id_idx": { + "name": "v2_users_hosts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "v2_users_hosts_host_id_idx": { + "name": "v2_users_hosts_host_id_idx", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "v2_users_hosts_organization_id_organizations_id_fk": { + "name": "v2_users_hosts_organization_id_organizations_id_fk", + "tableFrom": "v2_users_hosts", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "v2_users_hosts_user_id_users_id_fk": { + "name": "v2_users_hosts_user_id_users_id_fk", + "tableFrom": "v2_users_hosts", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "v2_users_hosts_host_fk": { + "name": "v2_users_hosts_host_fk", + "tableFrom": "v2_users_hosts", + "tableTo": "v2_hosts", + "columnsFrom": [ + "organization_id", + "host_id" + ], + "columnsTo": [ + "organization_id", + "machine_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "v2_users_hosts_organization_id_user_id_host_id_pk": { + "name": "v2_users_hosts_organization_id_user_id_host_id_pk", + "columns": [ + "organization_id", + "user_id", + "host_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.v2_workspaces": { + "name": "v2_workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "v2_workspace_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'worktree'" + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "v2_workspaces_project_id_idx": { + "name": "v2_workspaces_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "v2_workspaces_organization_id_idx": { + "name": "v2_workspaces_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "v2_workspaces_host_id_idx": { + "name": "v2_workspaces_host_id_idx", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "v2_workspaces_one_main_per_host": { + "name": "v2_workspaces_one_main_per_host", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"v2_workspaces\".\"type\" = 'main'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "v2_workspaces_organization_id_organizations_id_fk": { + "name": "v2_workspaces_organization_id_organizations_id_fk", + "tableFrom": "v2_workspaces", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "v2_workspaces_project_id_v2_projects_id_fk": { + "name": "v2_workspaces_project_id_v2_projects_id_fk", + "tableFrom": "v2_workspaces", + "tableTo": "v2_projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "v2_workspaces_created_by_user_id_users_id_fk": { + "name": "v2_workspaces_created_by_user_id_users_id_fk", + "tableFrom": "v2_workspaces", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "v2_workspaces_host_fk": { + "name": "v2_workspaces_host_fk", + "tableFrom": "v2_workspaces", + "tableTo": "v2_hosts", + "columnsFrom": [ + "organization_id", + "host_id" + ], + "columnsTo": [ + "organization_id", + "machine_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_tasks": { + "name": "workspace_tasks", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_tasks_task_idx": { + "name": "workspace_tasks_task_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_tasks_workspace_id_v2_workspaces_id_fk": { + "name": "workspace_tasks_workspace_id_v2_workspaces_id_fk", + "tableFrom": "workspace_tasks", + "tableTo": "v2_workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_tasks_task_id_tasks_id_fk": { + "name": "workspace_tasks_task_id_tasks_id_fk", + "tableFrom": "workspace_tasks", + "tableTo": "tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_tasks_workspace_id_task_id_pk": { + "name": "workspace_tasks_workspace_id_task_id_pk", + "columns": [ + "workspace_id", + "task_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "workspace_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspaces_project_id_idx": { + "name": "workspaces_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspaces_organization_id_idx": { + "name": "workspaces_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspaces_type_idx": { + "name": "workspaces_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspaces_organization_id_organizations_id_fk": { + "name": "workspaces_organization_id_organizations_id_fk", + "tableFrom": "workspaces", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspaces_project_id_projects_id_fk": { + "name": "workspaces_project_id_projects_id_fk", + "tableFrom": "workspaces", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspaces_created_by_user_id_users_id_fk": { + "name": "workspaces_created_by_user_id_users_id_fk", + "tableFrom": "workspaces", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.automation_prompt_source": { + "name": "automation_prompt_source", + "schema": "public", + "values": [ + "human", + "agent", + "restore" + ] + }, + "public.automation_run_status": { + "name": "automation_run_status", + "schema": "public", + "values": [ + "dispatching", + "dispatched", + "skipped_offline", + "dispatch_failed" + ] + }, + "public.automation_session_kind": { + "name": "automation_session_kind", + "schema": "public", + "values": [ + "chat", + "terminal" + ] + }, + "public.command_status": { + "name": "command_status", + "schema": "public", + "values": [ + "pending", + "completed", + "failed", + "timeout" + ] + }, + "public.device_type": { + "name": "device_type", + "schema": "public", + "values": [ + "desktop", + "mobile", + "web" + ] + }, + "public.integration_provider": { + "name": "integration_provider", + "schema": "public", + "values": [ + "linear", + "github", + "slack" + ] + }, + "public.task_priority": { + "name": "task_priority", + "schema": "public", + "values": [ + "urgent", + "high", + "medium", + "low", + "none" + ] + }, + "public.task_status": { + "name": "task_status", + "schema": "public", + "values": [ + "backlog", + "todo", + "planning", + "working", + "needs-feedback", + "ready-to-merge", + "completed", + "canceled" + ] + }, + "public.v2_client_type": { + "name": "v2_client_type", + "schema": "public", + "values": [ + "desktop", + "mobile", + "web" + ] + }, + "public.v2_users_host_role": { + "name": "v2_users_host_role", + "schema": "public", + "values": [ + "owner", + "member" + ] + }, + "public.v2_workspace_type": { + "name": "v2_workspace_type", + "schema": "public", + "values": [ + "main", + "worktree" + ] + }, + "public.workspace_type": { + "name": "workspace_type", + "schema": "public", + "values": [ + "local", + "cloud" + ] + } + }, + "schemas": { + "auth": "auth", + "ingest": "ingest" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 2f62e8121a7..09abb030f33 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -302,6 +302,13 @@ "when": 1777771854049, "tag": "0042_linear_disconnect_state", "breakpoints": true + }, + { + "idx": 43, + "version": "7", + "when": 1777778648171, + "tag": "0043_add_workspace_tasks", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/relations.ts b/packages/db/src/schema/relations.ts index 49992b6e750..2b13d1bf559 100644 --- a/packages/db/src/schema/relations.ts +++ b/packages/db/src/schema/relations.ts @@ -31,6 +31,7 @@ import { v2UsersHosts, v2Workspaces, workspaces, + workspaceTasks, } from "./schema"; export const usersRelations = relations(users, ({ many }) => ({ @@ -118,7 +119,7 @@ export const subscriptionsRelations = relations(subscriptions, ({ one }) => ({ }), })); -export const tasksRelations = relations(tasks, ({ one }) => ({ +export const tasksRelations = relations(tasks, ({ one, many }) => ({ organization: one(organizations, { fields: [tasks.organizationId], references: [organizations.id], @@ -137,6 +138,7 @@ export const tasksRelations = relations(tasks, ({ one }) => ({ references: [users.id], relationName: "creator", }), + workspaces: many(workspaceTasks), })); export const taskStatusesRelations = relations( @@ -338,9 +340,21 @@ export const v2WorkspacesRelations = relations( references: [users.id], }), chatSessions: many(chatSessions), + tasks: many(workspaceTasks), }), ); +export const workspaceTasksRelations = relations(workspaceTasks, ({ one }) => ({ + workspace: one(v2Workspaces, { + fields: [workspaceTasks.workspaceId], + references: [v2Workspaces.id], + }), + task: one(tasks, { + fields: [workspaceTasks.taskId], + references: [tasks.id], + }), +})); + export const secretsRelations = relations(secrets, ({ one }) => ({ organization: one(organizations, { fields: [secrets.organizationId], diff --git a/packages/db/src/schema/schema.ts b/packages/db/src/schema/schema.ts index e2854b73969..845c8588dee 100644 --- a/packages/db/src/schema/schema.ts +++ b/packages/db/src/schema/schema.ts @@ -566,6 +566,28 @@ export const v2Workspaces = pgTable( export type InsertV2Workspace = typeof v2Workspaces.$inferInsert; export type SelectV2Workspace = typeof v2Workspaces.$inferSelect; +export const workspaceTasks = pgTable( + "workspace_tasks", + { + workspaceId: uuid("workspace_id") + .notNull() + .references(() => v2Workspaces.id, { onDelete: "cascade" }), + taskId: uuid("task_id") + .notNull() + .references(() => tasks.id, { onDelete: "cascade" }), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => [ + primaryKey({ columns: [table.workspaceId, table.taskId] }), + index("workspace_tasks_task_idx").on(table.taskId), + ], +); + +export type InsertWorkspaceTask = typeof workspaceTasks.$inferInsert; +export type SelectWorkspaceTask = typeof workspaceTasks.$inferSelect; + export const secrets = pgTable( "secrets", { diff --git a/packages/host-service/src/trpc/router/agents/agents.ts b/packages/host-service/src/trpc/router/agents/agents.ts new file mode 100644 index 00000000000..09838787f3c --- /dev/null +++ b/packages/host-service/src/trpc/router/agents/agents.ts @@ -0,0 +1,231 @@ +import { TRPCError } from "@trpc/server"; +import { asc, eq } from "drizzle-orm"; +import { z } from "zod"; +import type { HostDb } from "../../../db"; +import { hostAgentConfigs } from "../../../db/schema"; +import { createTerminalSessionInternal } from "../../../terminal/terminal"; +import { protectedProcedure, router } from "../../index"; +import { resolveAttachmentPath } from "../attachments/storage"; + +interface ResolvedHostAgentConfig { + id: string; + presetId: string; + label: string; + command: string; + args: string[]; + promptTransport: "argv" | "stdin"; + promptArgs: string[]; + env: Record; +} + +function parseArgv(value: string): string[] { + try { + const parsed = JSON.parse(value); + if ( + !Array.isArray(parsed) || + parsed.some((entry) => typeof entry !== "string") + ) { + return []; + } + return parsed as string[]; + } catch { + return []; + } +} + +function parseEnv(value: string): Record { + try { + const parsed = JSON.parse(value); + if ( + parsed === null || + typeof parsed !== "object" || + Array.isArray(parsed) || + Object.values(parsed).some((entry) => typeof entry !== "string") + ) { + return {}; + } + return parsed as Record; + } catch { + return {}; + } +} + +function rowToConfig( + row: typeof hostAgentConfigs.$inferSelect, +): ResolvedHostAgentConfig { + return { + id: row.id, + presetId: row.presetId, + label: row.label, + command: row.command, + args: parseArgv(row.argsJson), + promptTransport: row.promptTransport as "argv" | "stdin", + promptArgs: parseArgv(row.promptArgsJson), + env: parseEnv(row.envJson), + }; +} + +/** + * Look up a HostAgentConfig by its instance id first, then fall back to the + * lowest-`order` row matching by presetId. Preset ids are short slugs; + * instance ids are UUIDs — they don't collide. + */ +export function resolveHostAgentConfig( + db: HostDb, + agent: string, +): ResolvedHostAgentConfig | null { + const byId = db + .select() + .from(hostAgentConfigs) + .where(eq(hostAgentConfigs.id, agent)) + .get(); + if (byId) return rowToConfig(byId); + + const byPreset = db + .select() + .from(hostAgentConfigs) + .where(eq(hostAgentConfigs.presetId, agent)) + .orderBy(asc(hostAgentConfigs.displayOrder)) + .get(); + if (byPreset) return rowToConfig(byPreset); + + return null; +} + +function quoteSingleShell(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'`; +} + +function buildArgvCommand(argv: string[]): string { + return argv.map(quoteSingleShell).join(" "); +} + +/** + * Build a shell command string that runs the resolved agent config with the + * given prompt. argv transport appends the prompt as the final positional; + * stdin transport pipes the prompt via a heredoc so the agent can read from + * fd 0. + * + * Empty prompts drop `promptArgs` so codex/opencode/copilot don't get stray + * prompt-mode flags during promptless launches. + */ +export function buildAgentCommandString( + config: ResolvedHostAgentConfig, + prompt: string, +): string { + const baseArgv = [config.command, ...config.args, ...config.promptArgs]; + + if (config.promptTransport === "argv") { + return buildArgvCommand([...baseArgv, prompt]); + } + + // stdin: pipe the prompt to the spawned process via heredoc. Delimiter is + // constructed to avoid collision with any line in the prompt content. + const baseDelimiter = "SUPERSET_PROMPT"; + let delimiter = baseDelimiter; + let counter = 0; + while (prompt.split("\n").some((line) => line === delimiter)) { + counter += 1; + delimiter = `${baseDelimiter}_${counter}`; + } + return `${buildArgvCommand(baseArgv)} <<'${delimiter}'\n${prompt}\n${delimiter}`; +} + +function envOverlayPrefix(env: Record): string { + const entries = Object.entries(env); + if (entries.length === 0) return ""; + const assignments = entries + .map(([key, value]) => `${key}=${quoteSingleShell(value)}`) + .join(" "); + return `${assignments} `; +} + +function buildAttachmentBlock( + prompt: string, + resolved: Array<{ attachmentId: string; path: string }>, +): string { + if (resolved.length === 0) return prompt; + const lines = resolved.map((item) => `- ${item.path}`); + const block = `\n\n# Attached files\n\nThe user attached these files. They are available on this host at:\n\n${lines.join("\n")}`; + return prompt + block; +} + +export interface AgentRunInput { + workspaceId: string; + agent: string; + prompt: string; + attachmentIds?: string[]; +} + +export interface AgentRunResult { + sessionId: string; + label: string; +} + +/** + * Launch an agent against a workspace. Pure function over (db, eventBus, + * input) so `workspaces.create` can invoke it directly for the `agents` + * sugar without going back through tRPC. + */ +export async function runAgentInWorkspace( + ctx: { db: HostDb; eventBus: import("../../../events").EventBus }, + input: AgentRunInput, +): Promise { + const config = resolveHostAgentConfig(ctx.db, input.agent); + if (!config) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `No host agent config matching '${input.agent}' (tried instance id then preset id).`, + }); + } + + const resolvedAttachments: Array<{ attachmentId: string; path: string }> = []; + for (const attachmentId of input.attachmentIds ?? []) { + const resolved = resolveAttachmentPath(attachmentId); + if (!resolved) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `Attachment not found: ${attachmentId}`, + }); + } + resolvedAttachments.push({ attachmentId, path: resolved.path }); + } + + const prompt = buildAttachmentBlock(input.prompt, resolvedAttachments); + const command = buildAgentCommandString(config, prompt); + const fullCommand = `${envOverlayPrefix(config.env)}${command}`; + + const terminalId = crypto.randomUUID(); + const result = await createTerminalSessionInternal({ + terminalId, + workspaceId: input.workspaceId, + db: ctx.db, + eventBus: ctx.eventBus, + initialCommand: fullCommand, + }); + + if ("error" in result) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: result.error, + }); + } + + return { + sessionId: result.terminalId, + label: config.label, + }; +} + +export const agentsRouter = router({ + run: protectedProcedure + .input( + z.object({ + workspaceId: z.string().uuid(), + agent: z.string().min(1), + prompt: z.string().min(1), + attachmentIds: z.array(z.string().uuid()).optional(), + }), + ) + .mutation(async ({ ctx, input }) => runAgentInWorkspace(ctx, input)), +}); diff --git a/packages/host-service/src/trpc/router/agents/index.ts b/packages/host-service/src/trpc/router/agents/index.ts new file mode 100644 index 00000000000..a34715e854a --- /dev/null +++ b/packages/host-service/src/trpc/router/agents/index.ts @@ -0,0 +1,8 @@ +export { + type AgentRunInput, + type AgentRunResult, + agentsRouter, + buildAgentCommandString, + resolveHostAgentConfig, + runAgentInWorkspace, +} from "./agents"; diff --git a/packages/host-service/src/trpc/router/attachments/storage.ts b/packages/host-service/src/trpc/router/attachments/storage.ts index e351da58350..7320422d534 100644 --- a/packages/host-service/src/trpc/router/attachments/storage.ts +++ b/packages/host-service/src/trpc/router/attachments/storage.ts @@ -1,4 +1,10 @@ -import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { + existsSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import mimeTypes from "mime-types"; @@ -89,3 +95,35 @@ export function deleteAttachment( const dir = getAttachmentDir(attachmentId, baseDirOverride); rmSync(dir, { recursive: true, force: true }); } + +export function readAttachmentMetadata( + attachmentId: string, + baseDirOverride?: string, +): AttachmentMetadata | null { + const path = getAttachmentMetadataPath(attachmentId, baseDirOverride); + if (!existsSync(path)) return null; + try { + return JSON.parse(readFileSync(path, "utf-8")) as AttachmentMetadata; + } catch { + return null; + } +} + +/** + * Resolves an attachment id to its on-disk file path, or null when missing. + * Used by agents.run to materialize host-readable paths in the prompt + * attachment block. Renderer never sees these paths. + */ +export function resolveAttachmentPath( + attachmentId: string, + baseDirOverride?: string, +): { path: string; metadata: AttachmentMetadata } | null { + const metadata = readAttachmentMetadata(attachmentId, baseDirOverride); + if (!metadata) return null; + const path = getAttachmentFilePath( + attachmentId, + metadata.mediaType, + baseDirOverride, + ); + return existsSync(path) ? { path, metadata } : null; +} diff --git a/packages/host-service/src/trpc/router/router.ts b/packages/host-service/src/trpc/router/router.ts index 94566b79a46..6736d308592 100644 --- a/packages/host-service/src/trpc/router/router.ts +++ b/packages/host-service/src/trpc/router/router.ts @@ -1,4 +1,5 @@ import { router } from "../index"; +import { agentsRouter } from "./agents"; import { attachmentsRouter } from "./attachments"; import { authRouter } from "./auth"; import { chatRouter } from "./chat"; @@ -17,8 +18,10 @@ import { terminalRouter } from "./terminal"; import { workspaceRouter } from "./workspace"; import { workspaceCleanupRouter } from "./workspace-cleanup"; import { workspaceCreationRouter } from "./workspace-creation"; +import { workspacesRouter } from "./workspaces"; export const appRouter = router({ + agents: agentsRouter, attachments: attachmentsRouter, auth: authRouter, health: healthRouter, @@ -35,6 +38,7 @@ export const appRouter = router({ settings: settingsRouter, terminal: terminalRouter, workspace: workspaceRouter, + workspaces: workspacesRouter, workspaceCleanup: workspaceCleanupRouter, workspaceCreation: workspaceCreationRouter, }); diff --git a/packages/host-service/src/trpc/router/workspace-creation/procedures/checkout.ts b/packages/host-service/src/trpc/router/workspace-creation/procedures/checkout.ts deleted file mode 100644 index 3ca06bdc60f..00000000000 --- a/packages/host-service/src/trpc/router/workspace-creation/procedures/checkout.ts +++ /dev/null @@ -1,285 +0,0 @@ -import { mkdirSync } from "node:fs"; -import { dirname } from "node:path"; -import { TRPCError } from "@trpc/server"; -import { and, eq } from "drizzle-orm"; -import { workspaces } from "../../../../db/schema"; -import { resolveRef } from "../../../../runtime/git/refs"; -import { protectedProcedure } from "../../../index"; -import { ensureMainWorkspace } from "../../project/utils/ensure-main-workspace"; -import { checkoutInputSchema } from "../schemas"; -import { finishCheckout } from "../shared/finish-checkout"; -import { enablePushAutoSetupRemote } from "../shared/git-config"; -import { requireLocalProject } from "../shared/local-project"; -import { clearProgress, setProgress } from "../shared/progress-store"; -import { safeResolveWorktreePath } from "../shared/worktree-paths"; -import { execGh } from "../utils/exec-gh"; -import { derivePrLocalBranchName } from "../utils/pr-branch-name"; - -export const checkout = protectedProcedure - .input(checkoutInputSchema) - .mutation(async ({ ctx, input }) => { - // Single seam for clearing progress on every throw path. Mirrors - // `workspaceCreation.create`. The scattered clearProgress calls - // inside are now redundant but harmless (idempotent). - try { - setProgress(input.pendingId, "ensuring_repo"); - - const localProject = requireLocalProject(ctx, input.projectId); - await ensureMainWorkspace(ctx, input.projectId, localProject.repoPath); - - setProgress(input.pendingId, "creating_worktree"); - - // ── PR path ──────────────────────────────────────────────────────── - if (input.pr) { - const branch = derivePrLocalBranchName(input.pr); - - // Idempotency: existing workspace for this PR's branch → - // return it. Renderer navigates to it via `alreadyExists: true` - // instead of treating as a new create. - const existing = ctx.db.query.workspaces - .findFirst({ - where: and( - eq(workspaces.projectId, input.projectId), - eq(workspaces.branch, branch), - ), - }) - .sync(); - if (existing) { - clearProgress(input.pendingId); - return { - workspace: { id: existing.id }, - terminals: [], - warnings: [], - alreadyExists: true as const, - }; - } - - let worktreePath: string; - try { - worktreePath = safeResolveWorktreePath(localProject.id, branch); - } catch (err) { - clearProgress(input.pendingId); - throw err; - } - let git: Awaited>; - try { - mkdirSync(dirname(worktreePath), { recursive: true }); - git = await ctx.git(localProject.repoPath); - } catch (err) { - clearProgress(input.pendingId); - throw err; - } - - // Detect a pre-existing local branch with the same derived name - // BEFORE running `gh pr checkout --force`. The idempotency check - // above rules out Superset-managed worktrees, but a branch can - // exist outside any workspace — e.g., from a prior manual - // `gh pr checkout` in the primary working tree. `--force` would - // reset it to the PR HEAD, silently losing any unpushed commits. - // We surface a warning pointing at reflog for recovery rather - // than blocking, so the point-and-click flow stays smooth. - let preExistingLocalBranch = false; - try { - await git.raw([ - "show-ref", - "--verify", - "--quiet", - `refs/heads/${branch}`, - ]); - preExistingLocalBranch = true; - } catch { - // Non-zero exit = branch doesn't exist. Expected path. - } - - // Detached worktree first — `gh pr checkout` inside it creates the - // branch with correct fork-remote + upstream config. Mirrors v1's - // `createWorktreeFromPr`. - try { - await git.raw(["worktree", "add", "--detach", worktreePath]); - } catch (err) { - clearProgress(input.pendingId); - throw new TRPCError({ - code: "CONFLICT", - message: - err instanceof Error - ? err.message - : "Failed to add detached worktree", - }); - } - - try { - await execGh( - [ - "pr", - "checkout", - String(input.pr.number), - "--branch", - branch, - "--force", - ], - { cwd: worktreePath, timeout: 120_000 }, - ); - } catch (err) { - await git - .raw(["worktree", "remove", "--force", worktreePath]) - .catch((rollbackErr) => { - console.warn( - "[workspaceCreation.checkout] failed to rollback PR worktree", - { worktreePath, err: rollbackErr }, - ); - }); - clearProgress(input.pendingId); - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `gh pr checkout failed: ${ - err instanceof Error ? err.message : String(err) - }`, - }); - } - - // Push ergonomics. `gh pr checkout` sets per-branch push config - // to the fork URL for cross-repo PRs; this covers the same-repo - // case where upstream isn't auto-set. - await enablePushAutoSetupRemote( - git, - worktreePath, - "[workspaceCreation.checkout]", - ); - - const extraWarnings: string[] = []; - if (input.pr.state !== "open") { - extraWarnings.push( - `PR is ${input.pr.state} — commits are included, but the PR may not merge.`, - ); - } - if (preExistingLocalBranch) { - extraWarnings.push( - `Reset existing local branch "${branch}" to PR HEAD. If you had unpushed commits there, recover them via \`git reflog show ${branch}\`.`, - ); - } - - return await finishCheckout(ctx, { - pendingId: input.pendingId, - projectId: input.projectId, - workspaceName: input.workspaceName, - branch, - worktreePath, - baseBranch: input.composer.baseBranch, - runSetupScript: input.composer.runSetupScript ?? false, - git, - extraWarnings, - }); - } - - // ── Branch path ──────────────────────────────────────────────────── - const branch = (input.branch ?? "").trim(); - if (!branch) { - clearProgress(input.pendingId); - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Branch name is empty", - }); - } - - let worktreePath: string; - try { - worktreePath = safeResolveWorktreePath(localProject.id, branch); - } catch (err) { - clearProgress(input.pendingId); - throw err; - } - let git: Awaited>; - try { - mkdirSync(dirname(worktreePath), { recursive: true }); - git = await ctx.git(localProject.repoPath); - } catch (err) { - clearProgress(input.pendingId); - throw err; - } - - // Resolve via the discriminated-ref helper so we don't infer kind - // from a refname string (a local branch named `origin/foo` would - // otherwise be misclassified). See GIT_REFS.md. - const resolved = await resolveRef(git, branch); - if (!resolved || resolved.kind === "head" || resolved.kind === "tag") { - clearProgress(input.pendingId); - throw new TRPCError({ - code: "BAD_REQUEST", - message: - resolved?.kind === "tag" - ? `"${branch}" is a tag, not a branch — cannot check out into a workspace` - : `Branch "${branch}" does not exist locally or on origin`, - }); - } - - if (resolved.kind === "remote-tracking") { - try { - await git.fetch([ - resolved.remote, - resolved.shortName, - "--quiet", - "--no-tags", - ]); - } catch (err) { - console.warn( - `[workspaceCreation.checkout] fetch ${resolved.remoteShortName} failed:`, - err, - ); - } - } - - try { - // For a remote-only branch, create a local tracking branch - // explicitly. `git worktree add origin/` without - // --track/-b produces a detached HEAD because the fully-qualified - // ref is treated as a commit-ish, not a branch shorthand. - await git.raw( - resolved.kind === "remote-tracking" - ? [ - "worktree", - "add", - "--track", - "-b", - branch, - worktreePath, - resolved.remoteShortName, - ] - : ["worktree", "add", worktreePath, resolved.shortName], - ); - } catch (err) { - clearProgress(input.pendingId); - const message = - err instanceof Error ? err.message : "Failed to add worktree"; - // Most common cause here is "branch already checked out elsewhere". - // Client disables the button for known cases via isCheckedOut, but - // we still get here for races. - throw new TRPCError({ code: "CONFLICT", message }); - } - - // Enable autoSetupRemote so the first terminal `git push` on a - // local-only branch creates origin/ without requiring -u. - // Branches checked out from a remote already have upstream set - // via --track above, so this config is a no-op for them. - // `--local` in a linked worktree writes to the shared repo config, - // so this applies repo-wide — intentional. - await enablePushAutoSetupRemote( - git, - worktreePath, - "[workspaceCreation.checkout]", - ); - - return await finishCheckout(ctx, { - pendingId: input.pendingId, - projectId: input.projectId, - workspaceName: input.workspaceName, - branch, - worktreePath, - baseBranch: input.composer.baseBranch, - runSetupScript: input.composer.runSetupScript ?? false, - git, - extraWarnings: [], - }); - } finally { - clearProgress(input.pendingId); - } - }); diff --git a/packages/host-service/src/trpc/router/workspace-creation/procedures/create.ts b/packages/host-service/src/trpc/router/workspace-creation/procedures/create.ts deleted file mode 100644 index d9853b69d76..00000000000 --- a/packages/host-service/src/trpc/router/workspace-creation/procedures/create.ts +++ /dev/null @@ -1,362 +0,0 @@ -import { mkdirSync } from "node:fs"; -import { dirname } from "node:path"; -import { getHostId, getHostName } from "@superset/shared/host-info"; -import { TRPCError } from "@trpc/server"; -import { workspaces } from "../../../../db/schema"; -import { - asRemoteRef, - type ResolvedRef, - resolveDefaultBranchName, - resolveUpstream, -} from "../../../../runtime/git/refs"; -import { protectedProcedure } from "../../../index"; -import { gitConfigWrite } from "../../git/utils/config-write"; -import { ensureMainWorkspace } from "../../project/utils/ensure-main-workspace"; -import { createInputSchema } from "../schemas"; -import { enablePushAutoSetupRemote } from "../shared/git-config"; -import { requireLocalProject } from "../shared/local-project"; -import { clearProgress, setProgress } from "../shared/progress-store"; -import { startSetupTerminalIfPresent } from "../shared/setup-terminal"; -import { buildStartPointFromHint } from "../shared/start-point"; -import type { TerminalDescriptor } from "../shared/types"; -import { safeResolveWorktreePath } from "../shared/worktree-paths"; -import { applyAiWorkspaceRename } from "../utils/ai-workspace-names"; -import { listBranchNames } from "../utils/list-branch-names"; -import { resolveStartPoint } from "../utils/resolve-start-point"; -import { deduplicateBranchName } from "../utils/sanitize-branch"; - -export const create = protectedProcedure - .input(createInputSchema) - .mutation(async ({ ctx, input }) => { - // Single seam for clearing the progress entry: any throw inside - // the body funnels through this finally so the renderer never - // sees a stuck "active" step. The scattered clearProgress calls - // inside are now redundant but harmless (the call is idempotent). - try { - const machineId = getHostId(); - const hostName = getHostName(); - setProgress(input.pendingId, "ensuring_repo"); - - const localProject = requireLocalProject(ctx, input.projectId); - await ensureMainWorkspace(ctx, input.projectId, localProject.repoPath); - - setProgress(input.pendingId, "creating_worktree"); - - // Renderer already sanitized/slugified. Host-service only validates - // and deduplicates — doesn't re-sanitize (which would strip case, - // slashes, etc. the user intended). - if (!input.names.branchName.trim()) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Branch name is empty", - }); - } - - const existingBranches = await listBranchNames( - ctx, - localProject.repoPath, - ); - const branchName = deduplicateBranchName( - input.names.branchName, - existingBranches, - ); - - const worktreePath = safeResolveWorktreePath(localProject.id, branchName); - mkdirSync(dirname(worktreePath), { recursive: true }); - - const git = await ctx.git(localProject.repoPath); - - // Trust the picker's hint when provided: it knows whether the row - // the user clicked was local or remote-only. Re-resolving here - // races against stale cached refs (a workspace branch with an - // incidental `refs/remotes/origin/` cache would silently win). - // Falls back to probing for callers that don't pass the hint. - let startPoint: ResolvedRef = - input.composer.baseBranch && input.composer.baseBranchSource - ? buildStartPointFromHint( - input.composer.baseBranch, - input.composer.baseBranchSource, - ) - : await resolveStartPoint(git, input.composer.baseBranch); - - // Local default branches are rarely fast-forwarded; swap to the - // branch's configured upstream so we fork from the real tip, not a - // stale local ref. Non-default branches stay local-first by design. - if (startPoint.kind === "local") { - const defaultBranchName = await resolveDefaultBranchName(git); - if (startPoint.shortName === defaultBranchName) { - const upstream = await resolveUpstream(git, defaultBranchName); - if (upstream) { - const remoteRef = asRemoteRef( - upstream.remote, - upstream.remoteBranch, - ); - const remoteExists = await git - .raw([ - "rev-parse", - "--verify", - "--quiet", - `${remoteRef}^{commit}`, - ]) - .then(() => true) - .catch(() => false); - if (remoteExists) { - startPoint = { - kind: "remote-tracking", - fullRef: remoteRef, - shortName: upstream.remoteBranch, - remote: upstream.remote, - remoteShortName: `${upstream.remote}/${upstream.remoteBranch}`, - }; - } - } - } - } - - console.log( - `[workspaceCreation.create] start point: ${startPoint.kind} (${ - input.composer.baseBranchSource ? "from hint" : "resolved" - })`, - ); - - // If we resolved to a remote-tracking ref, fetch just that branch - // to ensure we're branching from the latest remote state. - if (startPoint.kind === "remote-tracking") { - try { - await git.fetch([ - startPoint.remote, - startPoint.shortName, - "--quiet", - "--no-tags", - ]); - } catch (err) { - console.warn( - `[workspaceCreation.create] fetch ${startPoint.remoteShortName} failed, proceeding with local ref:`, - err, - ); - } - } - - // Always create a new branch — never check out an existing one. - // Checking out existing branches is a separate intent (createFromPr, - // or the picker's Check out action via the `checkout` procedure). - // --no-track keeps `git pull` / ahead-behind counts from treating - // the start point as the branch's home. Push targeting is handled - // separately by push.autoSetupRemote (set below). - const startPointArg = - startPoint.kind === "head" ? "HEAD" : startPoint.shortName; - try { - await git.raw([ - "worktree", - "add", - "--no-track", - "-b", - branchName, - worktreePath, - startPoint.kind === "remote-tracking" - ? startPoint.remoteShortName - : startPointArg, - ]); - } catch (err) { - clearProgress(input.pendingId); - throw new TRPCError({ - code: "CONFLICT", - message: - err instanceof Error ? err.message : "Failed to add worktree", - }); - } - - // Past worktree-add: any throw must roll back the on-disk worktree - // before bubbling, otherwise the user is left with an orphaned - // `/.worktrees/` and a dangling local branch - // the next create attempt will collide with. - const rollbackWorktree = async () => { - try { - await git.raw(["worktree", "remove", worktreePath]); - } catch (err) { - console.warn( - "[workspaceCreation.create] failed to rollback worktree", - { - worktreePath, - err, - }, - ); - } - }; - - try { - // Enable autoSetupRemote so the first terminal `git push` - // creates origin/ and sets it as upstream without - // requiring `-u`. `--local` in a linked worktree writes to the - // shared repo config, so this applies repo-wide — intentional, - // every workspace worktree wants the same ergonomics. Safe - // against wrong-upstream targeting because --no-track above - // guarantees no upstream exists at first push, so auto-create - // always wins and always uses the branch's own name (never - // the base branch). - await enablePushAutoSetupRemote( - git, - worktreePath, - "[workspaceCreation.create]", - ); - - // Record the base branch in git config so the Changes tab - // knows what to compare against on first open. - // startPoint.shortName is the ref we actually forked from - // (user selection, resolved against local / remote). Skipped - // for "head" start point — no meaningful base. - if (startPoint.kind !== "head") { - await gitConfigWrite(git, [ - "config", - `branch.${branchName}.base`, - startPoint.shortName, - ]).catch((err) => { - console.warn( - `[workspaceCreation.create] failed to record base branch ${startPoint.shortName}:`, - err, - ); - }); - } - } catch (err) { - await rollbackWorktree(); - throw err; - } - - setProgress(input.pendingId, "registering"); - - let host: { machineId: string }; - try { - host = await ctx.api.host.ensure.mutate({ - organizationId: ctx.organizationId, - machineId, - name: hostName, - }); - } catch (err) { - console.error("[workspaceCreation.create] host.ensure failed", err); - clearProgress(input.pendingId); - await rollbackWorktree(); - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `Failed to register host: ${err instanceof Error ? err.message : String(err)}`, - }); - } - - const cloudRow = await ctx.api.v2Workspace.create - .mutate({ - organizationId: ctx.organizationId, - projectId: input.projectId, - name: input.names.workspaceName, - branch: branchName, - hostId: host.machineId, - }) - .catch(async (err) => { - console.error( - "[workspaceCreation.create] v2Workspace.create failed", - err, - ); - clearProgress(input.pendingId); - await rollbackWorktree(); - throw err; - }); - - if (!cloudRow) { - clearProgress(input.pendingId); - await rollbackWorktree(); - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Cloud workspace create returned no row", - }); - } - - try { - ctx.db - .insert(workspaces) - .values({ - id: cloudRow.id, - projectId: input.projectId, - worktreePath, - branch: branchName, - }) - .run(); - } catch (err) { - console.error( - "[workspaceCreation.create] local workspaces insert failed", - err, - ); - clearProgress(input.pendingId); - await rollbackWorktree(); - await ctx.api.v2Workspace.delete - .mutate({ id: cloudRow.id }) - .catch((cleanupErr) => { - console.warn( - "[workspaceCreation.create] failed to rollback cloud workspace", - { workspaceId: cloudRow.id, err: cleanupErr }, - ); - }); - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `Failed to persist workspace locally: ${err instanceof Error ? err.message : String(err)}`, - }); - } - - // Fire-and-forget AI rename from the composer prompt. A single - // structured-output call generates both a display title and a - // kebab-case branch name, and we apply each independently. - // Electric syncs updates to the renderer via v2_workspaces, so - // the pending/workspace page updates in place once the model - // responds. - // - // Name precedence (matches renderer `resolveNames`): - // 1. user-typed title → skip AI rename (flag = false) - // 2. friendly fallback + prompt → AI rename (this branch) - // 3. friendly fallback, no prompt → keep fallback - // - // `expectedCurrentName` covers the race where a user edits the - // title after create but before the AI response lands. - const composerPrompt = input.composer.prompt?.trim(); - const allowAiRename = input.names.workspaceNameWasAutoGenerated !== false; - if (composerPrompt && allowAiRename) { - void applyAiWorkspaceRename({ - ctx, - workspaceId: cloudRow.id, - repoPath: localProject.repoPath, - worktreePath, - oldBranchName: branchName, - oldWorkspaceName: input.names.workspaceName, - prompt: composerPrompt, - }).catch((err) => { - console.warn( - "[workspaceCreation.create] AI workspace rename failed", - err, - ); - }); - } - - const terminals: TerminalDescriptor[] = []; - const warnings: string[] = []; - - if (input.composer.runSetupScript) { - const { terminal, warning } = await startSetupTerminalIfPresent({ - ctx, - workspaceId: cloudRow.id, - worktreePath, - }); - if (warning) { - warnings.push(warning); - } - if (terminal) { - terminals.push(terminal); - } - } - - clearProgress(input.pendingId); - - return { - workspace: cloudRow, - terminals, - warnings, - }; - } finally { - clearProgress(input.pendingId); - } - }); diff --git a/packages/host-service/src/trpc/router/workspace-creation/procedures/generate-branch-name.ts b/packages/host-service/src/trpc/router/workspace-creation/procedures/generate-branch-name.ts deleted file mode 100644 index 7fe6b3a5fe6..00000000000 --- a/packages/host-service/src/trpc/router/workspace-creation/procedures/generate-branch-name.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { protectedProcedure } from "../../../index"; -import { generateBranchNameInputSchema } from "../schemas"; -import { findLocalProject } from "../shared/local-project"; -import { generateBranchNameFromPrompt } from "../utils/ai-branch-name"; -import { listBranchNames } from "../utils/list-branch-names"; - -export const generateBranchName = protectedProcedure - .input(generateBranchNameInputSchema) - .mutation(async ({ ctx, input }) => { - const trimmed = input.prompt.trim(); - if (!trimmed) return { branchName: null }; - - const localProject = findLocalProject(ctx, input.projectId); - if (!localProject) return { branchName: null }; - - const existingBranches = await listBranchNames(ctx, localProject.repoPath); - const branchName = await generateBranchNameFromPrompt( - trimmed, - existingBranches, - ); - return { branchName }; - }); diff --git a/packages/host-service/src/trpc/router/workspace-creation/procedures/get-context.ts b/packages/host-service/src/trpc/router/workspace-creation/procedures/get-context.ts deleted file mode 100644 index a12b2643542..00000000000 --- a/packages/host-service/src/trpc/router/workspace-creation/procedures/get-context.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { resolveDefaultBranchName } from "../../../../runtime/git/refs"; -import { protectedProcedure } from "../../../index"; -import { getContextInputSchema } from "../schemas"; -import { findLocalProject } from "../shared/local-project"; - -export const getContext = protectedProcedure - .input(getContextInputSchema) - .query(async ({ ctx, input }) => { - const localProject = findLocalProject(ctx, input.projectId); - - if (!localProject) { - return { - projectId: input.projectId, - hasLocalRepo: false, - defaultBranch: null as string | null, - }; - } - - const git = await ctx.git(localProject.repoPath); - const defaultBranch = await resolveDefaultBranchName(git); - - return { - projectId: input.projectId, - hasLocalRepo: true, - defaultBranch, - }; - }); diff --git a/packages/host-service/src/trpc/router/workspace-creation/procedures/get-github-issue-content.ts b/packages/host-service/src/trpc/router/workspace-creation/procedures/get-github-issue-content.ts deleted file mode 100644 index 99b2f163e57..00000000000 --- a/packages/host-service/src/trpc/router/workspace-creation/procedures/get-github-issue-content.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { TRPCError } from "@trpc/server"; -import { protectedProcedure } from "../../../index"; -import { githubIssueContentInputSchema, issueContentSchema } from "../schemas"; -import { resolveGithubRepo } from "../shared/project-helpers"; -import { execGh } from "../utils/exec-gh"; - -// Shell out to the user's `gh` CLI rather than host-service's -// octokit — `gh auth login` works out of the box while the -// credential-manager path requires setup most users don't have. -// Matches V1's projects.getIssueContent behavior. -export const getGitHubIssueContent = protectedProcedure - .input(githubIssueContentInputSchema) - .query(async ({ ctx, input }) => { - const repo = await resolveGithubRepo(ctx, input.projectId); - try { - const raw = await execGh([ - "issue", - "view", - String(input.issueNumber), - "--repo", - `${repo.owner}/${repo.name}`, - "--json", - "number,title,body,url,state,author,createdAt,updatedAt", - ]); - const data = issueContentSchema.parse(raw); - return { - number: data.number, - title: data.title, - body: data.body ?? "", - url: data.url, - state: data.state.toLowerCase(), - author: data.author?.login ?? null, - createdAt: data.createdAt, - updatedAt: data.updatedAt, - }; - } catch (err) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `Failed to fetch issue #${input.issueNumber}: ${err instanceof Error ? err.message : String(err)}`, - }); - } - }); diff --git a/packages/host-service/src/trpc/router/workspace-creation/procedures/get-github-pull-request-content.ts b/packages/host-service/src/trpc/router/workspace-creation/procedures/get-github-pull-request-content.ts deleted file mode 100644 index 30b16121933..00000000000 --- a/packages/host-service/src/trpc/router/workspace-creation/procedures/get-github-pull-request-content.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { TRPCError } from "@trpc/server"; -import { protectedProcedure } from "../../../index"; -import { - githubPullRequestContentInputSchema, - pullRequestContentSchema, -} from "../schemas"; -import { resolveGithubRepo } from "../shared/project-helpers"; -import { execGh } from "../utils/exec-gh"; - -type PullRequestContent = { - number: number; - title: string; - body: string; - url: string; - state: string; - branch: string; - baseBranch: string; - headRepositoryOwner: string | null; - isCrossRepository: boolean; - author: string | null; - isDraft: boolean; - createdAt: string | undefined; - updatedAt: string | undefined; -}; - -// Browsing the PR list re-opens the detail panel constantly; cache the -// `gh pr view` response so we don't burn the user's GitHub token bucket on -// repeat clicks. Concurrent callers share the same in-flight promise. -const PULL_REQUEST_CONTENT_CACHE_TTL_MS = 30_000; -const pullRequestContentCache = new Map< - string, - { promise: Promise; fetchedAt: number } ->(); - -export const getGitHubPullRequestContent = protectedProcedure - .input(githubPullRequestContentInputSchema) - .query(async ({ ctx, input }) => { - const repo = await resolveGithubRepo(ctx, input.projectId); - const cacheKey = `${repo.owner.toLowerCase()}/${repo.name.toLowerCase()}#${input.prNumber}`; - const cached = pullRequestContentCache.get(cacheKey); - if ( - cached && - Date.now() - cached.fetchedAt < PULL_REQUEST_CONTENT_CACHE_TTL_MS - ) { - return cached.promise; - } - - const fetchedAt = Date.now(); - const promise = (async (): Promise => { - try { - const raw = await execGh([ - "pr", - "view", - String(input.prNumber), - "--repo", - `${repo.owner}/${repo.name}`, - "--json", - "number,title,body,url,state,author,headRefName,baseRefName,headRepositoryOwner,isCrossRepository,isDraft,createdAt,updatedAt", - ]); - const data = pullRequestContentSchema.parse(raw); - return { - number: data.number, - title: data.title, - body: data.body ?? "", - url: data.url, - state: data.state.toLowerCase(), - branch: data.headRefName, - baseBranch: data.baseRefName, - headRepositoryOwner: data.headRepositoryOwner?.login ?? null, - isCrossRepository: data.isCrossRepository, - author: data.author?.login ?? null, - isDraft: data.isDraft, - createdAt: data.createdAt, - updatedAt: data.updatedAt, - }; - } catch (err) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `Failed to fetch PR #${input.prNumber}: ${err instanceof Error ? err.message : String(err)}`, - }); - } - })(); - // Evict on failure so the next caller retries instead of replaying the - // same error for the rest of the TTL. - promise.catch(() => { - if (pullRequestContentCache.get(cacheKey)?.promise === promise) { - pullRequestContentCache.delete(cacheKey); - } - }); - pullRequestContentCache.set(cacheKey, { promise, fetchedAt }); - return promise; - }); diff --git a/packages/host-service/src/trpc/router/workspace-creation/procedures/get-progress.ts b/packages/host-service/src/trpc/router/workspace-creation/procedures/get-progress.ts deleted file mode 100644 index 626c6b129a2..00000000000 --- a/packages/host-service/src/trpc/router/workspace-creation/procedures/get-progress.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { protectedProcedure } from "../../../index"; -import { getProgressInputSchema } from "../schemas"; -import { - getProgress as getCreateProgress, - sweepStaleProgress, -} from "../shared/progress-store"; - -export const getProgress = protectedProcedure - .input(getProgressInputSchema) - .query(({ input }) => { - sweepStaleProgress(); - const steps = getCreateProgress(input.pendingId); - return steps ? { steps } : null; - }); diff --git a/packages/host-service/src/trpc/router/workspace-creation/procedures/index.ts b/packages/host-service/src/trpc/router/workspace-creation/procedures/index.ts index e3d66d6d734..518e2bb9cb7 100644 --- a/packages/host-service/src/trpc/router/workspace-creation/procedures/index.ts +++ b/packages/host-service/src/trpc/router/workspace-creation/procedures/index.ts @@ -1,11 +1,4 @@ export { adopt } from "./adopt"; -export { checkout } from "./checkout"; -export { create } from "./create"; -export { generateBranchName } from "./generate-branch-name"; -export { getContext } from "./get-context"; -export { getGitHubIssueContent } from "./get-github-issue-content"; -export { getGitHubPullRequestContent } from "./get-github-pull-request-content"; -export { getProgress } from "./get-progress"; export { searchBranches } from "./search-branches"; export { searchGitHubIssues } from "./search-github-issues"; export { searchPullRequests } from "./search-pull-requests"; diff --git a/packages/host-service/src/trpc/router/workspace-creation/schemas.ts b/packages/host-service/src/trpc/router/workspace-creation/schemas.ts index 7c98376f7bf..9e9e852a173 100644 --- a/packages/host-service/src/trpc/router/workspace-creation/schemas.ts +++ b/packages/host-service/src/trpc/router/workspace-creation/schemas.ts @@ -1,22 +1,5 @@ import { z } from "zod"; -const attachmentSchema = z.object({ - data: z.string(), - mediaType: z.string(), - filename: z.string().optional(), -}); - -const linkedContextSchema = z - .object({ - internalIssueIds: z.array(z.string()).optional(), - githubIssueUrls: z.array(z.string()).optional(), - linkedPrUrl: z.string().optional(), - attachments: z.array(attachmentSchema).optional(), - }) - .optional(); - -export const getContextInputSchema = z.object({ projectId: z.string() }); - export const searchBranchesInputSchema = z.object({ projectId: z.string(), query: z.string().optional(), @@ -26,76 +9,6 @@ export const searchBranchesInputSchema = z.object({ filter: z.enum(["branch", "worktree"]).optional(), }); -export const generateBranchNameInputSchema = z.object({ - projectId: z.string(), - prompt: z.string(), -}); - -export const getProgressInputSchema = z.object({ pendingId: z.string() }); - -export const createInputSchema = z.object({ - pendingId: z.string(), - projectId: z.string(), - names: z.object({ - workspaceName: z.string(), - branchName: z.string(), - // Renderer signal: true when `workspaceName` came from the - // friendly-random fallback (no user-typed title). Gates the - // post-create AI rename so a user-typed title is never - // overwritten. Optional for backcompat — defaults to allowing - // the rename, matching pre-field behavior. - workspaceNameWasAutoGenerated: z.boolean().optional(), - }), - composer: z.object({ - prompt: z.string().optional(), - baseBranch: z.string().optional(), - // Hint from the picker about which form of the base branch - // was selected. When provided, the server uses it directly - // instead of probing — avoids racing against stale cached - // remote refs that could win in a re-resolve. See - // `resolve-start-point.ts` for the fallback semantics. - baseBranchSource: z.enum(["local", "remote-tracking"]).optional(), - runSetupScript: z.boolean().optional(), - }), - linkedContext: linkedContextSchema, -}); - -const checkoutPrSchema = z.object({ - number: z.number().int().positive(), - url: z.string().url(), - title: z.string(), - headRefName: z.string(), - baseRefName: z.string(), - headRepositoryOwner: z.string(), - isCrossRepository: z.boolean(), - state: z.enum(["open", "closed", "merged"]), -}); - -export const checkoutInputSchema = z - .object({ - pendingId: z.string(), - projectId: z.string(), - workspaceName: z.string(), - // Exactly one of `branch` or `pr` must be set (refine below). - // Branch mode: caller supplies a branch name; server resolves it. - // PR mode: caller supplies PR metadata + runs `gh pr checkout`. - branch: z.string().optional(), - pr: checkoutPrSchema.optional(), - composer: z.object({ - prompt: z.string().optional(), - // Written to `branch..base` for the Changes tab. Client - // fills from picker in branch mode, or `pr.baseRefName` in PR - // mode. Server reads uniformly — no intent branching for this - // write. - baseBranch: z.string().optional(), - runSetupScript: z.boolean().optional(), - }), - linkedContext: linkedContextSchema, - }) - .refine((value) => Boolean(value.branch) !== Boolean(value.pr), { - message: "exactly one of `branch` or `pr` must be set", - }); - export const adoptInputSchema = z.object({ projectId: z.string(), workspaceName: z.string(), diff --git a/packages/host-service/src/trpc/router/workspace-creation/shared/finish-checkout.ts b/packages/host-service/src/trpc/router/workspace-creation/shared/finish-checkout.ts deleted file mode 100644 index e982e2e479f..00000000000 --- a/packages/host-service/src/trpc/router/workspace-creation/shared/finish-checkout.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { getHostId, getHostName } from "@superset/shared/host-info"; -import { TRPCError } from "@trpc/server"; -import { workspaces } from "../../../../db/schema"; -import type { HostServiceContext } from "../../../../types"; -import { clearProgress, setProgress } from "./progress-store"; -import { startSetupTerminalIfPresent } from "./setup-terminal"; -import type { CheckoutResult, GitClient, TerminalDescriptor } from "./types"; - -/** - * Shared postlude for `checkout` (both branch and PR paths). - * - * - Writes `branch..base` from `composer.baseBranch` for the Changes tab. - * - `ensureV2Host` + `v2Workspace.create` with rollback on failure. - * - Inserts the local `workspaces` row. - * - Optionally spawns the setup terminal. - * - Clears progress. - */ -export async function finishCheckout( - ctx: HostServiceContext, - args: { - pendingId: string; - projectId: string; - workspaceName: string; - branch: string; - worktreePath: string; - baseBranch: string | undefined; - runSetupScript: boolean; - git: GitClient; - extraWarnings: string[]; - }, -): Promise { - setProgress(args.pendingId, "registering"); - - // Record the base branch for the Changes tab (skipped if unset — matches - // `create`'s head-start-point behavior). - if (args.baseBranch) { - await args.git - .raw([ - "-C", - args.worktreePath, - "config", - `branch.${args.branch}.base`, - args.baseBranch, - ]) - .catch((err) => { - console.warn( - `[workspaceCreation.checkout] failed to record base branch ${args.baseBranch}:`, - err, - ); - }); - } - - const rollbackWorktree = async () => { - try { - await args.git.raw(["worktree", "remove", args.worktreePath]); - } catch (err) { - console.warn("[workspaceCreation.checkout] failed to rollback worktree", { - worktreePath: args.worktreePath, - err, - }); - } - }; - - const machineId = getHostId(); - const hostName = getHostName(); - - let host: { machineId: string }; - try { - host = await ctx.api.host.ensure.mutate({ - organizationId: ctx.organizationId, - machineId, - name: hostName, - }); - } catch (err) { - console.error("[workspaceCreation.checkout] host.ensure failed", err); - clearProgress(args.pendingId); - await rollbackWorktree(); - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `Failed to register host: ${err instanceof Error ? err.message : String(err)}`, - }); - } - - const cloudRow = await ctx.api.v2Workspace.create - .mutate({ - organizationId: ctx.organizationId, - projectId: args.projectId, - name: args.workspaceName, - branch: args.branch, - hostId: host.machineId, - }) - .catch(async (err) => { - console.error( - "[workspaceCreation.checkout] v2Workspace.create failed", - err, - ); - clearProgress(args.pendingId); - await rollbackWorktree(); - throw err; - }); - - if (!cloudRow) { - clearProgress(args.pendingId); - await rollbackWorktree(); - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Cloud workspace create returned no row", - }); - } - - try { - ctx.db - .insert(workspaces) - .values({ - id: cloudRow.id, - projectId: args.projectId, - worktreePath: args.worktreePath, - branch: args.branch, - }) - .run(); - } catch (err) { - console.error( - "[workspaceCreation.checkout] local workspaces insert failed", - err, - ); - clearProgress(args.pendingId); - await rollbackWorktree(); - await ctx.api.v2Workspace.delete - .mutate({ id: cloudRow.id }) - .catch((cleanupErr) => { - console.warn( - "[workspaceCreation.checkout] failed to rollback cloud workspace", - { workspaceId: cloudRow.id, err: cleanupErr }, - ); - }); - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `Failed to persist workspace locally: ${err instanceof Error ? err.message : String(err)}`, - }); - } - - const terminals: TerminalDescriptor[] = []; - const warnings: string[] = [...args.extraWarnings]; - - if (args.runSetupScript) { - const { terminal, warning } = await startSetupTerminalIfPresent({ - ctx, - workspaceId: cloudRow.id, - worktreePath: args.worktreePath, - }); - if (warning) { - warnings.push(warning); - } - if (terminal) { - terminals.push(terminal); - } - } - - clearProgress(args.pendingId); - - return { workspace: cloudRow, terminals, warnings }; -} diff --git a/packages/host-service/src/trpc/router/workspace-creation/shared/progress-store.ts b/packages/host-service/src/trpc/router/workspace-creation/shared/progress-store.ts deleted file mode 100644 index 92713c25fa0..00000000000 --- a/packages/host-service/src/trpc/router/workspace-creation/shared/progress-store.ts +++ /dev/null @@ -1,54 +0,0 @@ -export interface ProgressStep { - id: string; - label: string; - status: "pending" | "active" | "done"; -} - -interface ProgressState { - steps: ProgressStep[]; - updatedAt: number; -} - -const STEP_DEFINITIONS = [ - { id: "ensuring_repo", label: "Ensuring local repository" }, - { id: "creating_worktree", label: "Creating worktree" }, - { id: "registering", label: "Registering workspace" }, -] as const; - -const createProgress = new Map(); - -export function setProgress(pendingId: string, activeStepId: string): void { - if (!STEP_DEFINITIONS.some((def) => def.id === activeStepId)) { - console.warn( - `[workspaceCreation.progress] unknown activeStepId "${activeStepId}" for pendingId "${pendingId}"`, - ); - return; - } - let reachedActive = false; - const steps: ProgressStep[] = STEP_DEFINITIONS.map((def) => { - if (def.id === activeStepId) { - reachedActive = true; - return { id: def.id, label: def.label, status: "active" }; - } - if (!reachedActive) { - return { id: def.id, label: def.label, status: "done" }; - } - return { id: def.id, label: def.label, status: "pending" }; - }); - createProgress.set(pendingId, { steps, updatedAt: Date.now() }); -} - -export function getProgress(pendingId: string): ProgressStep[] | null { - return createProgress.get(pendingId)?.steps ?? null; -} - -export function clearProgress(pendingId: string): void { - createProgress.delete(pendingId); -} - -export function sweepStaleProgress(): void { - const cutoff = Date.now() - 5 * 60 * 1000; - for (const [id, entry] of createProgress) { - if (entry.updatedAt < cutoff) createProgress.delete(id); - } -} diff --git a/packages/host-service/src/trpc/router/workspace-creation/workspace-creation.ts b/packages/host-service/src/trpc/router/workspace-creation/workspace-creation.ts index 9b5952fc1e4..75b4b6fda65 100644 --- a/packages/host-service/src/trpc/router/workspace-creation/workspace-creation.ts +++ b/packages/host-service/src/trpc/router/workspace-creation/workspace-creation.ts @@ -1,28 +1,14 @@ import { router } from "../../index"; import { adopt, - checkout, - create, - generateBranchName, - getContext, - getGitHubIssueContent, - getGitHubPullRequestContent, - getProgress, searchBranches, searchGitHubIssues, searchPullRequests, } from "./procedures"; export const workspaceCreationRouter = router({ - getContext, searchBranches, - generateBranchName, - getProgress, - create, - checkout, adopt, searchGitHubIssues, searchPullRequests, - getGitHubIssueContent, - getGitHubPullRequestContent, }); diff --git a/packages/host-service/src/trpc/router/workspace/workspace.ts b/packages/host-service/src/trpc/router/workspace/workspace.ts index 08f5b9cfcfa..25421fef23e 100644 --- a/packages/host-service/src/trpc/router/workspace/workspace.ts +++ b/packages/host-service/src/trpc/router/workspace/workspace.ts @@ -1,14 +1,9 @@ -import { existsSync, mkdirSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { getHostId, getHostName } from "@superset/shared/host-info"; import { TRPCError } from "@trpc/server"; import { eq } from "drizzle-orm"; -import simpleGit from "simple-git"; import { z } from "zod"; import { projects, workspaces } from "../../../db/schema"; import { invalidateLabelCache } from "../../../ports/static-ports"; import { protectedProcedure, router } from "../../index"; -import { ensureMainWorkspace } from "../project/utils/ensure-main-workspace"; export const workspaceRouter = router({ get: protectedProcedure @@ -28,114 +23,6 @@ export const workspaceRouter = router({ return localWorkspace; }), - create: protectedProcedure - .input( - z.object({ - projectId: z.string(), - name: z.string().min(1), - branch: z.string().min(1), - }), - ) - .mutation(async ({ ctx, input }) => { - if (!ctx.api) { - throw new TRPCError({ - code: "PRECONDITION_FAILED", - message: "Cloud API not configured", - }); - } - - let localProject = ctx.db.query.projects - .findFirst({ where: eq(projects.id, input.projectId) }) - .sync(); - - if (!localProject) { - const cloudProject = await ctx.api.v2Project.get.query({ - organizationId: ctx.organizationId, - id: input.projectId, - }); - - if (!cloudProject.repoCloneUrl) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Project has no linked GitHub repository — cannot clone", - }); - } - - const homeDir = process.env.HOME || process.env.USERPROFILE || "/tmp"; - const repoPath = join(homeDir, ".superset", "repos", input.projectId); - - if (!existsSync(repoPath)) { - mkdirSync(dirname(repoPath), { recursive: true }); - await simpleGit().clone(cloudProject.repoCloneUrl, repoPath); - } - - const inserted = ctx.db - .insert(projects) - .values({ id: input.projectId, repoPath }) - .returning() - .get(); - - localProject = inserted; - } - - await ensureMainWorkspace(ctx, input.projectId, localProject.repoPath); - - const worktreePath = join( - localProject.repoPath, - ".worktrees", - input.branch, - ); - const machineId = getHostId(); - const hostName = getHostName(); - - const git = await ctx.git(localProject.repoPath); - try { - await git.raw(["worktree", "add", worktreePath, input.branch]); - } catch { - await git.raw(["worktree", "add", "-b", input.branch, worktreePath]); - } - - const host = await ctx.api.host.ensure.mutate({ - organizationId: ctx.organizationId, - machineId, - name: hostName, - }); - - const cloudRow = await ctx.api.v2Workspace.create - .mutate({ - organizationId: ctx.organizationId, - projectId: input.projectId, - name: input.name, - branch: input.branch, - hostId: host.machineId, - }) - .catch(async (err) => { - try { - await git.raw(["worktree", "remove", worktreePath]); - } catch (cleanupErr) { - console.warn("[workspace.create] failed to rollback worktree", { - worktreePath, - cleanupErr, - }); - } - throw err; - }); - - if (cloudRow) { - ctx.db - .insert(workspaces) - .values({ - id: cloudRow.id, - projectId: input.projectId, - worktreePath, - branch: input.branch, - }) - .run(); - } - - return cloudRow; - }), - gitStatus: protectedProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { diff --git a/packages/host-service/src/trpc/router/workspaces/index.ts b/packages/host-service/src/trpc/router/workspaces/index.ts new file mode 100644 index 00000000000..9a31ab6c669 --- /dev/null +++ b/packages/host-service/src/trpc/router/workspaces/index.ts @@ -0,0 +1 @@ +export { workspacesRouter } from "./workspaces"; diff --git a/packages/host-service/src/trpc/router/workspaces/workspaces.ts b/packages/host-service/src/trpc/router/workspaces/workspaces.ts new file mode 100644 index 00000000000..626f2b126ac --- /dev/null +++ b/packages/host-service/src/trpc/router/workspaces/workspaces.ts @@ -0,0 +1,780 @@ +import { mkdirSync } from "node:fs"; +import { dirname } from "node:path"; +import { TRPCError } from "@trpc/server"; +import { and, eq } from "drizzle-orm"; +import { z } from "zod"; +import { workspaces } from "../../../db/schema"; +import { + asRemoteRef, + type ResolvedRef, + resolveDefaultBranchName, + resolveRef, + resolveUpstream, +} from "../../../runtime/git/refs"; +import type { HostServiceContext } from "../../../types"; +import { protectedProcedure, router } from "../../index"; +import { type AgentRunResult, runAgentInWorkspace } from "../agents"; +import { ensureMainWorkspace } from "../project/utils/ensure-main-workspace"; +import { getWorktreeBranchAtPath } from "../workspace-creation/shared/branch-search"; +import { enablePushAutoSetupRemote } from "../workspace-creation/shared/git-config"; +import { requireLocalProject } from "../workspace-creation/shared/local-project"; +import { startSetupTerminalIfPresent } from "../workspace-creation/shared/setup-terminal"; +import type { GitClient } from "../workspace-creation/shared/types"; +import { safeResolveWorktreePath } from "../workspace-creation/shared/worktree-paths"; +import { generateBranchNameFromPrompt } from "../workspace-creation/utils/ai-branch-name"; +import { + applyAiWorkspaceRename, + generateWorkspaceNamesFromPrompt, +} from "../workspace-creation/utils/ai-workspace-names"; +import { execGh } from "../workspace-creation/utils/exec-gh"; +import { listBranchNames } from "../workspace-creation/utils/list-branch-names"; +import { derivePrLocalBranchName } from "../workspace-creation/utils/pr-branch-name"; +import { resolveStartPoint } from "../workspace-creation/utils/resolve-start-point"; + +const agentLaunchSchema = z.object({ + agent: z.string().min(1), + prompt: z.string().min(1), + attachmentIds: z.array(z.string().uuid()).optional(), +}); + +const createInputSchema = z + .object({ + projectId: z.string(), + name: z.string().min(1), + branch: z.string().min(1).optional(), + pr: z.number().int().positive().optional(), + baseBranch: z.string().min(1).optional(), + taskIds: z.array(z.string().uuid()).optional(), + autogenerateName: z.boolean().optional(), + agents: z.array(agentLaunchSchema).optional(), + id: z.string().uuid().optional(), + }) + .refine((value) => Boolean(value.branch) !== Boolean(value.pr), { + message: "Exactly one of `branch` or `pr` must be set", + }); + +type AgentLaunchResult = + | ({ ok: true } & AgentRunResult) + | { ok: false; error: string }; + +interface ResolvedWorkspace { + id: string; + projectId: string; + name: string; + branch: string; +} + +async function findExistingWorkspaceByBranch( + ctx: HostServiceContext, + projectId: string, + branch: string, +): Promise { + const local = ctx.db.query.workspaces + .findFirst({ + where: and( + eq(workspaces.projectId, projectId), + eq(workspaces.branch, branch), + ), + }) + .sync(); + if (!local) return null; + + const cloud = await ctx.api.v2Workspace.getFromHost.query({ + organizationId: ctx.organizationId, + id: local.id, + }); + if (!cloud) return null; + return { + id: cloud.id, + projectId: cloud.projectId, + name: cloud.name, + branch: cloud.branch, + }; +} + +interface PrMetadata { + number: number; + url: string; + title: string; + headRefName: string; + baseRefName: string; + headRepositoryOwner: string; + isCrossRepository: boolean; + state: "open" | "closed" | "merged"; +} + +async function fetchPrMetadata(args: { + cwd: string; + prNumber: number; +}): Promise { + const result = await execGh( + [ + "pr", + "view", + String(args.prNumber), + "--json", + "number,url,title,headRefName,baseRefName,headRepositoryOwner,isCrossRepository,state", + ], + { cwd: args.cwd, timeout: 30_000 }, + ); + const parsed = result as { + number: number; + url: string; + title: string; + headRefName: string; + baseRefName: string; + headRepositoryOwner: { login: string } | null; + isCrossRepository: boolean; + state: string; + }; + const stateLower = parsed.state.toLowerCase(); + const state: PrMetadata["state"] = + stateLower === "open" + ? "open" + : stateLower === "merged" + ? "merged" + : "closed"; + return { + number: parsed.number, + url: parsed.url, + title: parsed.title, + headRefName: parsed.headRefName, + baseRefName: parsed.baseRefName, + headRepositoryOwner: parsed.headRepositoryOwner?.login ?? "", + isCrossRepository: parsed.isCrossRepository, + state, + }; +} + +async function localBranchExists( + git: GitClient, + branchName: string, +): Promise { + try { + await git.raw([ + "show-ref", + "--verify", + "--quiet", + `refs/heads/${branchName}`, + ]); + return true; + } catch { + return false; + } +} + +interface BranchSourcePlan { + branch: string; + startPoint: ResolvedRef; + usedExistingBranch: boolean; +} + +async function planBranchSource( + git: GitClient, + branch: string, + baseBranch: string | undefined, +): Promise { + const resolved = await resolveRef(git, branch); + + if ( + resolved && + (resolved.kind === "local" || resolved.kind === "remote-tracking") + ) { + return { branch, startPoint: resolved, usedExistingBranch: true }; + } + + if (resolved && resolved.kind === "tag") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `"${branch}" is a tag, not a branch — cannot check out into a workspace`, + }); + } + + let startPoint = await resolveStartPoint(git, baseBranch); + + // Fork from upstream of the default branch when the user didn't specify + // a base — locals are often stale. + if (startPoint.kind === "local") { + const defaultBranchName = await resolveDefaultBranchName(git); + if (startPoint.shortName === defaultBranchName) { + const upstream = await resolveUpstream(git, defaultBranchName); + if (upstream) { + const remoteRef = asRemoteRef(upstream.remote, upstream.remoteBranch); + const remoteExists = await git + .raw(["rev-parse", "--verify", "--quiet", `${remoteRef}^{commit}`]) + .then(() => true) + .catch(() => false); + if (remoteExists) { + startPoint = { + kind: "remote-tracking", + fullRef: remoteRef, + shortName: upstream.remoteBranch, + remote: upstream.remote, + remoteShortName: `${upstream.remote}/${upstream.remoteBranch}`, + }; + } + } + } + } + + if (startPoint.kind === "remote-tracking") { + try { + await git.fetch([ + startPoint.remote, + startPoint.shortName, + "--quiet", + "--no-tags", + ]); + } catch (err) { + console.warn( + `[workspaces.create] fetch ${startPoint.remoteShortName} failed:`, + err, + ); + } + } + + return { branch, startPoint, usedExistingBranch: false }; +} + +async function addBranchWorktree(args: { + git: GitClient; + plan: BranchSourcePlan; + worktreePath: string; +}): Promise { + const { git, plan, worktreePath } = args; + + if (plan.usedExistingBranch) { + // Existing branch — check it out into a fresh worktree. Remote-tracking + // refs need explicit --track + -b so the worktree gets a real local + // branch, not detached HEAD. + await git.raw( + plan.startPoint.kind === "remote-tracking" + ? [ + "worktree", + "add", + "--track", + "-b", + plan.branch, + worktreePath, + plan.startPoint.remoteShortName, + ] + : [ + "worktree", + "add", + worktreePath, + plan.startPoint.kind === "head" + ? "HEAD" + : plan.startPoint.shortName, + ], + ); + return; + } + + // New branch from start point. --no-track keeps `git pull` and + // ahead/behind counts pointing at the branch's own upstream once + // push.autoSetupRemote sets it on first push. + const startPointArg = + plan.startPoint.kind === "head" + ? "HEAD" + : plan.startPoint.kind === "remote-tracking" + ? plan.startPoint.remoteShortName + : plan.startPoint.shortName; + await git.raw([ + "worktree", + "add", + "--no-track", + "-b", + plan.branch, + worktreePath, + startPointArg, + ]); +} + +async function recordBaseBranchConfig(args: { + git: GitClient; + worktreePath: string; + branch: string; + baseBranch: string; +}): Promise { + await args.git + .raw([ + "-C", + args.worktreePath, + "config", + `branch.${args.branch}.base`, + args.baseBranch, + ]) + .catch((err) => { + console.warn( + `[workspaces.create] failed to record base branch ${args.baseBranch}:`, + err, + ); + }); +} + +async function registerCloudAndLocal(args: { + ctx: HostServiceContext; + id: string | undefined; + projectId: string; + name: string; + branch: string; + worktreePath: string; + taskIds: string[] | undefined; + rollbackWorktree: () => Promise; +}): Promise<{ id: string; projectId: string; name: string; branch: string }> { + const { ctx } = args; + const { getHostId, getHostName } = await import("@superset/shared/host-info"); + let host: { machineId: string }; + try { + host = await ctx.api.host.ensure.mutate({ + organizationId: ctx.organizationId, + machineId: getHostId(), + name: getHostName(), + }); + } catch (err) { + await args.rollbackWorktree(); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Failed to register host: ${err instanceof Error ? err.message : String(err)}`, + }); + } + + const cloudRow = await ctx.api.v2Workspace.create + .mutate({ + organizationId: ctx.organizationId, + projectId: args.projectId, + name: args.name, + branch: args.branch, + hostId: host.machineId, + taskIds: args.taskIds, + id: args.id, + }) + .catch(async (err) => { + await args.rollbackWorktree(); + throw err; + }); + + if (!cloudRow) { + await args.rollbackWorktree(); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Cloud workspace create returned no row", + }); + } + + try { + ctx.db + .insert(workspaces) + .values({ + id: cloudRow.id, + projectId: args.projectId, + worktreePath: args.worktreePath, + branch: args.branch, + }) + .run(); + } catch (err) { + await args.rollbackWorktree(); + await ctx.api.v2Workspace.delete + .mutate({ id: cloudRow.id }) + .catch((cleanupErr) => { + console.warn("[workspaces.create] failed to rollback cloud workspace", { + workspaceId: cloudRow.id, + err: cleanupErr, + }); + }); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Failed to persist workspace locally: ${err instanceof Error ? err.message : String(err)}`, + }); + } + + return { + id: cloudRow.id, + projectId: cloudRow.projectId, + name: cloudRow.name, + branch: cloudRow.branch, + }; +} + +async function dispatchSugarAgents( + ctx: HostServiceContext, + workspaceId: string, + launches: z.infer[], +): Promise { + if (launches.length === 0) return []; + return Promise.all( + launches.map(async (entry) => { + try { + const result = await runAgentInWorkspace(ctx, { + workspaceId, + agent: entry.agent, + prompt: entry.prompt, + attachmentIds: entry.attachmentIds, + }); + return { ok: true as const, ...result }; + } catch (err) { + return { + ok: false as const, + error: err instanceof Error ? err.message : String(err), + }; + } + }), + ); +} + +export const workspacesRouter = router({ + create: protectedProcedure + .input(createInputSchema) + .mutation(async ({ ctx, input }) => { + const localProject = requireLocalProject(ctx, input.projectId); + await ensureMainWorkspace(ctx, input.projectId, localProject.repoPath); + + const git = await ctx.git(localProject.repoPath); + + let resolvedBranch: string; + let worktreePath: string; + let alreadyExists = false; + let workspaceRow: { + id: string; + projectId: string; + name: string; + branch: string; + }; + let prMetadata: PrMetadata | null = null; + + if (input.pr !== undefined) { + prMetadata = await fetchPrMetadata({ + cwd: localProject.repoPath, + prNumber: input.pr, + }); + resolvedBranch = derivePrLocalBranchName(prMetadata); + + const existing = await findExistingWorkspaceByBranch( + ctx, + input.projectId, + resolvedBranch, + ); + if (existing) { + workspaceRow = existing; + alreadyExists = true; + } else { + if (await localBranchExists(git, resolvedBranch)) { + throw new TRPCError({ + code: "CONFLICT", + message: `Local branch "${resolvedBranch}" already exists outside Superset. Delete it (\`git branch -D ${resolvedBranch}\`) or rename it, then retry.`, + }); + } + + worktreePath = safeResolveWorktreePath( + localProject.id, + resolvedBranch, + ); + mkdirSync(dirname(worktreePath), { recursive: true }); + + const rollbackWorktree = async () => { + try { + await git.raw(["worktree", "remove", "--force", worktreePath]); + } catch (err) { + console.warn( + "[workspaces.create] failed to rollback PR worktree", + { worktreePath, err }, + ); + } + }; + + try { + await git.raw(["worktree", "add", "--detach", worktreePath]); + } catch (err) { + throw new TRPCError({ + code: "CONFLICT", + message: + err instanceof Error + ? err.message + : "Failed to add detached worktree", + }); + } + + try { + await execGh( + [ + "pr", + "checkout", + String(input.pr), + "--branch", + resolvedBranch, + "--force", + ], + { cwd: worktreePath, timeout: 120_000 }, + ); + } catch (err) { + await rollbackWorktree(); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `gh pr checkout failed: ${err instanceof Error ? err.message : String(err)}`, + }); + } + + await enablePushAutoSetupRemote( + git, + worktreePath, + "[workspaces.create]", + ); + + workspaceRow = await registerCloudAndLocal({ + ctx, + id: input.id, + projectId: input.projectId, + name: input.name, + branch: resolvedBranch, + worktreePath, + taskIds: input.taskIds, + rollbackWorktree, + }); + + if (prMetadata.baseRefName) { + await recordBaseBranchConfig({ + git, + worktreePath, + branch: resolvedBranch, + baseBranch: prMetadata.baseRefName, + }); + } + } + } else { + if (!input.branch) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "branch is required when pr is not set", + }); + } + resolvedBranch = input.branch.trim(); + + const existing = await findExistingWorkspaceByBranch( + ctx, + input.projectId, + resolvedBranch, + ); + if (existing) { + workspaceRow = existing; + alreadyExists = true; + } else { + worktreePath = safeResolveWorktreePath( + localProject.id, + resolvedBranch, + ); + + // Adopt: a worktree already exists at the standard path with the + // matching branch checked out (e.g. left behind by a prior session + // or registered outside Superset). Skip `git worktree add` and + // proceed straight to register. + const adopted = + (await getWorktreeBranchAtPath(git, worktreePath)) === + resolvedBranch; + + mkdirSync(dirname(worktreePath), { recursive: true }); + + const rollbackWorktree = async () => { + if (adopted) return; + try { + await git.raw(["worktree", "remove", "--force", worktreePath]); + } catch (err) { + console.warn("[workspaces.create] failed to rollback worktree", { + worktreePath, + err, + }); + } + }; + + const plan = adopted + ? null + : await planBranchSource(git, resolvedBranch, input.baseBranch); + + if (plan) { + try { + await addBranchWorktree({ git, plan, worktreePath }); + } catch (err) { + throw new TRPCError({ + code: "CONFLICT", + message: + err instanceof Error ? err.message : "Failed to add worktree", + }); + } + } + + await enablePushAutoSetupRemote( + git, + worktreePath, + "[workspaces.create]", + ); + + if ( + plan && + !plan.usedExistingBranch && + plan.startPoint.kind !== "head" + ) { + await git + .raw([ + "config", + `branch.${resolvedBranch}.base`, + plan.startPoint.shortName, + ]) + .catch((err) => { + console.warn( + `[workspaces.create] failed to record base branch ${plan.startPoint.kind === "head" ? "" : plan.startPoint.shortName}:`, + err, + ); + }); + } + + workspaceRow = await registerCloudAndLocal({ + ctx, + id: input.id, + projectId: input.projectId, + name: input.name, + branch: resolvedBranch, + worktreePath, + taskIds: input.taskIds, + rollbackWorktree, + }); + } + } + + const terminalsResult: Array<{ terminalId: string; label?: string }> = []; + + if (!alreadyExists) { + // worktreePath is set in the !alreadyExists branches above. + const setupWorktreePath = ctx.db.query.workspaces + .findFirst({ + where: eq(workspaces.id, workspaceRow.id), + }) + .sync()?.worktreePath; + if (setupWorktreePath) { + const { terminal, warning } = await startSetupTerminalIfPresent({ + ctx, + workspaceId: workspaceRow.id, + worktreePath: setupWorktreePath, + }); + if (warning) { + console.warn(`[workspaces.create] setup warning: ${warning}`); + } + if (terminal) { + terminalsResult.push({ + terminalId: terminal.id, + label: terminal.label, + }); + } + } + + if (input.autogenerateName) { + const composerPrompt = input.agents?.[0]?.prompt?.trim() ?? ""; + if (composerPrompt) { + const setupPath = setupWorktreePath ?? ""; + void applyAiWorkspaceRename({ + ctx, + workspaceId: workspaceRow.id, + repoPath: localProject.repoPath, + worktreePath: setupPath, + oldBranchName: workspaceRow.branch, + oldWorkspaceName: workspaceRow.name, + prompt: composerPrompt, + }).catch((err) => { + console.warn( + "[workspaces.create] AI workspace rename failed", + err, + ); + }); + } + } + } + + const agentsResult = await dispatchSugarAgents( + ctx, + workspaceRow.id, + input.agents ?? [], + ); + + return { + workspace: { + id: workspaceRow.id, + projectId: workspaceRow.projectId, + name: workspaceRow.name, + branch: workspaceRow.branch, + }, + terminals: terminalsResult, + agents: agentsResult, + alreadyExists, + }; + }), + + aiRename: protectedProcedure + .input( + z.object({ + workspaceId: z.string().uuid(), + prompt: z.string().min(1), + }), + ) + .mutation(async ({ ctx, input }) => { + const local = ctx.db.query.workspaces + .findFirst({ where: eq(workspaces.id, input.workspaceId) }) + .sync(); + if (!local) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `Workspace not found: ${input.workspaceId}`, + }); + } + const cloud = await ctx.api.v2Workspace.getFromHost.query({ + organizationId: ctx.organizationId, + id: input.workspaceId, + }); + if (!cloud) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `Cloud workspace not found: ${input.workspaceId}`, + }); + } + const project = ctx.db.query.projects + .findFirst({ where: eq(workspaces.projectId, local.projectId) }) + .sync(); + if (!project) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Local project not found for workspace", + }); + } + void applyAiWorkspaceRename({ + ctx, + workspaceId: input.workspaceId, + repoPath: project.repoPath ?? "", + worktreePath: local.worktreePath, + oldBranchName: cloud.branch, + oldWorkspaceName: cloud.name, + prompt: input.prompt, + }).catch((err) => { + console.warn("[workspaces.aiRename] failed", err); + }); + return { success: true as const }; + }), + + generateBranchName: protectedProcedure + .input( + z.object({ + projectId: z.string(), + prompt: z.string().min(1), + }), + ) + .mutation(async ({ ctx, input }) => { + const localProject = requireLocalProject(ctx, input.projectId); + const existingBranches = await listBranchNames( + ctx, + localProject.repoPath, + ); + const branchName = await generateBranchNameFromPrompt( + input.prompt, + existingBranches, + ); + return { branchName }; + }), +}); + +export { generateWorkspaceNamesFromPrompt as _aiNamesGenerator }; diff --git a/packages/mcp-v2/src/tools/workspaces/create.ts b/packages/mcp-v2/src/tools/workspaces/create.ts index 1e2a3bc9b4d..a62f1fe2fcf 100644 --- a/packages/mcp-v2/src/tools/workspaces/create.ts +++ b/packages/mcp-v2/src/tools/workspaces/create.ts @@ -7,20 +7,63 @@ export function register(server: McpServer): void { defineTool(server, { name: "workspaces_create", description: - "Create a workspace on a host. A workspace is a branch-scoped working copy of a project. The host service materializes the git worktree on disk before returning. Use projects_list and hosts_list first to get the projectId and hostId.", + "Create a workspace on a host. A workspace is a branch-scoped working copy of a project. The host service materializes the git worktree on disk before returning. Provide exactly one of `branch` or `pr`. Use projects_list and hosts_list first to get the projectId and hostId.", inputSchema: { projectId: z.string().uuid().describe("Project UUID."), name: z.string().min(1).describe("Workspace name (display)."), - branch: z.string().min(1).describe("Git branch the workspace tracks."), + branch: z + .string() + .min(1) + .optional() + .describe( + "Git branch the workspace tracks. Required unless `pr` is set.", + ), + pr: z + .number() + .int() + .positive() + .optional() + .describe( + "Pull request number — server runs `gh pr checkout` and derives the branch.", + ), + baseBranch: z + .string() + .optional() + .describe( + "Branch to fork from when `branch` does not exist (defaults to project default). Ignored when `pr` is set.", + ), hostId: z .string() .min(1) .describe("Host machineId to create the workspace on."), + taskIds: z + .array(z.string().uuid()) + .optional() + .describe( + "Optional Superset task ids to link to the new workspace via the workspace_tasks join.", + ), }, handler: async (input, ctx) => { return hostServiceMutation< - { projectId: string; name: string; branch: string }, - { id: string; projectId: string; branch: string; worktreePath: string } + { + projectId: string; + name: string; + branch?: string; + pr?: number; + baseBranch?: string; + taskIds?: string[]; + }, + { + workspace: { + id: string; + projectId: string; + name: string; + branch: string; + }; + terminals: Array<{ terminalId: string; label?: string }>; + agents: Array; + alreadyExists: boolean; + } >( { relayUrl: ctx.relayUrl, @@ -28,11 +71,14 @@ export function register(server: McpServer): void { hostId: input.hostId, jwt: ctx.bearerToken, }, - "workspace.create", + "workspaces.create", { projectId: input.projectId, name: input.name, branch: input.branch, + pr: input.pr, + baseBranch: input.baseBranch, + taskIds: input.taskIds, }, ); }, diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 98ddc13f10e..d681543689f 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -75,11 +75,12 @@ import { TaskUpdateParams, } from "./resources/tasks"; import { - CreatedWorkspace, HostWorkspace, Workspace, - WorkspaceAgentSpawn, + WorkspaceAgentLaunch, + WorkspaceCreateAgentResult, WorkspaceCreateParams, + WorkspaceCreateResult, WorkspaceDeleteResult, WorkspaceListParams, WorkspaceListResponse, @@ -1124,8 +1125,9 @@ export declare namespace Superset { Workspaces, Workspace, HostWorkspace, - CreatedWorkspace, - WorkspaceAgentSpawn, + WorkspaceAgentLaunch, + WorkspaceCreateAgentResult, + WorkspaceCreateResult, WorkspaceListResponse, WorkspaceListParams, WorkspaceCreateParams, diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 234a7ffc554..ec30140fc5e 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -48,10 +48,11 @@ export { type TaskListResponse, Tasks, type TaskUpdateParams, - type CreatedWorkspace, type Workspace, - type WorkspaceAgentSpawn, + type WorkspaceAgentLaunch, + type WorkspaceCreateAgentResult, type WorkspaceCreateParams, + type WorkspaceCreateResult, type WorkspaceDeleteResult, type WorkspaceListParams, type WorkspaceListResponse, diff --git a/packages/sdk/src/resources/index.ts b/packages/sdk/src/resources/index.ts index 562aaf9a0b4..b67e6ced589 100644 --- a/packages/sdk/src/resources/index.ts +++ b/packages/sdk/src/resources/index.ts @@ -23,11 +23,12 @@ export { type TaskUpdateParams, } from "./tasks"; export { - type CreatedWorkspace, type HostWorkspace, type Workspace, - type WorkspaceAgentSpawn, + type WorkspaceAgentLaunch, + type WorkspaceCreateAgentResult, type WorkspaceCreateParams, + type WorkspaceCreateResult, type WorkspaceDeleteResult, type WorkspaceListParams, type WorkspaceListResponse, diff --git a/packages/sdk/src/resources/workspaces.ts b/packages/sdk/src/resources/workspaces.ts index 9cb61dc7df6..4812ffd6f0c 100644 --- a/packages/sdk/src/resources/workspaces.ts +++ b/packages/sdk/src/resources/workspaces.ts @@ -2,11 +2,6 @@ import type { APIPromise } from "../core/api-promise"; import { SupersetError } from "../core/error"; import { APIResource } from "../core/resource"; import type { RequestOptions } from "../internal/request-options"; -import type { - AgentConfig, - Automation, - AutomationRunDispatched, -} from "./automations"; /** * Workspaces are physical artifacts (git worktrees / clones) on a developer's @@ -37,68 +32,30 @@ export class Workspaces extends APIResource { /** * Create a workspace on a specific host. Optionally spawn one or more - * agents inside it as soon as the worktree is ready. + * agents inside it as soon as the worktree is ready (the `agents` sugar + * runs `agents.run` once per entry against the freshly-created workspace). * * The host service must be running and reachable via the relay tunnel. - * When `agents` is provided, the SDK creates a one-shot automation per - * agent (pinned to the new workspace + host) and dispatches them — the - * dispatched runs are returned alongside the workspace. + * Provide exactly one of `branch` or `pr`. */ - async create( + create( params: WorkspaceCreateParams, options?: RequestOptions, - ): Promise { - const ws = await this._client.hostMutation( + ): APIPromise { + return this._client.hostMutation( params.hostId, - "workspace.create", + "workspaces.create", { projectId: params.projectId, name: params.name, branch: params.branch, + pr: params.pr, + baseBranch: params.baseBranch, + taskIds: params.taskIds, + agents: params.agents, }, options, ); - - const agents = params.agents ?? []; - if (agents.length === 0) { - return { ...ws, agentRuns: [] }; - } - - const agentRuns: AutomationRunDispatched[] = []; - for (let i = 0; i < agents.length; i++) { - const spec = agents[i]!; - const agentId = spec.agent ?? "claude"; - const agentConfig: AgentConfig = - typeof spec.agentConfig === "object" - ? spec.agentConfig - : { id: agentId, kind: "terminal", enabled: true }; - - const automation = await this._client.mutation( - "automation.create", - { - name: `${params.name} (${agentId}${agents.length > 1 ? ` #${i + 1}` : ""})`, - prompt: spec.prompt, - agentConfig, - targetHostId: params.hostId, - v2WorkspaceId: ws.id, - // Yearly schedule = effectively one-shot. The automation row - // stays in the DB after dispatch — clean it up out-of-band if - // it bothers you. - rrule: "FREQ=YEARLY;BYMONTH=12;BYMONTHDAY=31", - timezone: "UTC", - mcpScope: spec.mcpScope ?? [], - }, - options, - ); - const run = await this._client.mutation( - "automation.runNow", - { id: automation.id }, - options, - ); - agentRuns.push(run); - } - - return { ...ws, agentRuns }; } /** @@ -177,30 +134,44 @@ export interface WorkspaceCreateParams { projectId: string; /** Workspace name. */ name: string; - /** Git branch to check out / create. */ - branch: string; + /** Git branch the workspace tracks. Required unless `pr` is set. */ + branch?: string; + /** Pull request number — server runs `gh pr checkout` and derives the branch. */ + pr?: number; + /** Branch to fork from when `branch` does not exist. Ignored with `pr`. */ + baseBranch?: string; + /** Optional Superset task ids to link via the workspace_tasks join. */ + taskIds?: string[]; /** Spawn one or more agents in the workspace immediately after creation. */ - agents?: WorkspaceAgentSpawn[]; + agents?: WorkspaceAgentLaunch[]; } -export interface WorkspaceAgentSpawn { +export interface WorkspaceAgentLaunch { + /** Agent preset id (e.g. `"claude"`) or HostAgentConfig instance id. */ + agent: string; /** What to tell the agent. */ prompt: string; - /** Agent preset id. Defaults to `"claude"`. */ - agent?: string; - /** Full agent config; overrides `agent` if provided. */ - agentConfig?: AgentConfig; - /** MCP servers this dispatch is allowed to use. */ - mcpScope?: string[]; + /** Host-scoped attachment ids; host resolves to absolute paths in the prompt. */ + attachmentIds?: string[]; } -export interface CreatedWorkspace extends HostWorkspace { - /** Dispatched runs, one per `agents[]` entry. Empty if no agents were spawned. */ - agentRuns: AutomationRunDispatched[]; +export type WorkspaceCreateAgentResult = + | { ok: true; sessionId: string; label: string } + | { ok: false; error: string }; + +export interface WorkspaceCreateResult { + workspace: { + id: string; + projectId: string; + name: string; + branch: string; + }; + terminals: Array<{ terminalId: string; label?: string }>; + agents: WorkspaceCreateAgentResult[]; + alreadyExists: boolean; } export interface WorkspaceDeleteResult { - /** Host-service delete returns its own shape; surfaced here as-is. */ [key: string]: unknown; } @@ -211,8 +182,9 @@ export declare namespace Workspaces { WorkspaceListResponse, WorkspaceListParams, WorkspaceCreateParams, - WorkspaceAgentSpawn, - CreatedWorkspace, + WorkspaceAgentLaunch, + WorkspaceCreateAgentResult, + WorkspaceCreateResult, WorkspaceDeleteResult, }; } diff --git a/packages/trpc/src/router/automation/dispatch.ts b/packages/trpc/src/router/automation/dispatch.ts index cf97f2af9c6..dbf7c1ea8c9 100644 --- a/packages/trpc/src/router/automation/dispatch.ts +++ b/packages/trpc/src/router/automation/dispatch.ts @@ -342,15 +342,20 @@ async function createWorkspaceOnHost(args: { const result = await relayMutation< { - pendingId: string; projectId: string; - names: { workspaceName: string; branchName: string }; - composer: { prompt?: string; runSetupScript?: boolean }; + name: string; + branch: string; }, { - workspace: { id: string }; - terminals: unknown[]; - warnings: string[]; + workspace: { + id: string; + projectId: string; + name: string; + branch: string; + }; + terminals: Array<{ terminalId: string; label?: string }>; + agents: Array; + alreadyExists: boolean; } >( { @@ -361,12 +366,11 @@ async function createWorkspaceOnHost(args: { // can comfortably take >25s. Give it real room. timeoutMs: 90_000, }, - "workspaceCreation.create", + "workspaces.create", { - pendingId: args.runId, projectId: args.projectId, - names: { workspaceName, branchName }, - composer: { prompt: args.automation.prompt, runSetupScript: false }, + name: workspaceName, + branch: branchName, }, ); diff --git a/packages/trpc/src/router/v2-workspace/v2-workspace.ts b/packages/trpc/src/router/v2-workspace/v2-workspace.ts index 110ae0e7834..18201a1743b 100644 --- a/packages/trpc/src/router/v2-workspace/v2-workspace.ts +++ b/packages/trpc/src/router/v2-workspace/v2-workspace.ts @@ -1,10 +1,12 @@ import { db, dbWs } from "@superset/db/client"; import { v2WorkspaceTypeValues } from "@superset/db/enums"; import { + tasks, v2Hosts, v2Projects, v2UsersHosts, v2Workspaces, + workspaceTasks, } from "@superset/db/schema"; import { getCurrentTxid } from "@superset/db/utils"; import type { TRPCRouterRecord } from "@trpc/server"; @@ -167,6 +169,8 @@ export const v2WorkspaceRouter = { branch: z.string().min(1), hostId: z.string().min(1), type: z.enum(v2WorkspaceTypeValues).default("worktree"), + taskIds: z.array(z.string().uuid()).optional(), + id: z.string().uuid().optional(), }), ) .mutation(async ({ ctx, input }) => { @@ -183,56 +187,114 @@ export const v2WorkspaceRouter = { ); const host = await getScopedHost(input.organizationId, input.hostId); - // Relies on the partial unique index - // (project_id, host_id) WHERE type='main' for idempotency — race-safe - // even if two callers (e.g. the startup sweep and project.setup) both - // miss the existence check at the same instant. - const [inserted] = await dbWs - .insert(v2Workspaces) - .values({ - organizationId: project.organizationId, - projectId: project.id, - name: input.name, - branch: input.branch, - hostId: host.machineId, - type: input.type, - createdByUserId: ctx.userId, - }) - .onConflictDoNothing() - .returning(); + if (input.taskIds && input.taskIds.length > 0) { + const found = await dbWs.query.tasks.findMany({ + columns: { id: true, organizationId: true }, + where: inArray(tasks.id, input.taskIds), + }); + if (found.length !== input.taskIds.length) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "One or more taskIds not found", + }); + } + if (found.some((t) => t.organizationId !== input.organizationId)) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "All taskIds must belong to the workspace's organization", + }); + } + } - if (inserted) return inserted; + // Insert workspace + workspace_tasks rows in one transaction so the link + // is atomic with the create. Relies on the partial unique index + // (project_id, host_id) WHERE type='main' for main-workspace idempotency. + const result = await dbWs.transaction(async (tx) => { + const [inserted] = await tx + .insert(v2Workspaces) + .values({ + ...(input.id ? { id: input.id } : {}), + organizationId: project.organizationId, + projectId: project.id, + name: input.name, + branch: input.branch, + hostId: host.machineId, + type: input.type, + createdByUserId: ctx.userId, + }) + .onConflictDoNothing() + .returning(); - if (input.type === "main") { - const existing = await dbWs.query.v2Workspaces.findFirst({ - where: and( - eq(v2Workspaces.projectId, project.id), - eq(v2Workspaces.hostId, host.machineId), - eq(v2Workspaces.type, "main"), - ), - }); - if (existing) { - const patch: { - branch?: string; - name?: string; - } = {}; - if (existing.branch !== input.branch) { - patch.branch = input.branch; - if (existing.name === existing.branch) { - patch.name = input.name; - } + if (inserted) { + if (input.taskIds && input.taskIds.length > 0) { + await tx + .insert(workspaceTasks) + .values( + input.taskIds.map((taskId) => ({ + workspaceId: inserted.id, + taskId, + })), + ) + .onConflictDoNothing(); + } + return inserted; + } + + if (input.id) { + const existing = await tx.query.v2Workspaces.findFirst({ + where: and( + eq(v2Workspaces.id, input.id), + eq(v2Workspaces.organizationId, project.organizationId), + ), + }); + if (existing) return existing; + const collision = await tx.query.v2Workspaces.findFirst({ + columns: { id: true }, + where: eq(v2Workspaces.id, input.id), + }); + if (collision) { + throw new TRPCError({ + code: "CONFLICT", + message: "Workspace id already in use", + }); } - if (Object.keys(patch).length > 0) { - const [updated] = await dbWs - .update(v2Workspaces) - .set(patch) - .where(eq(v2Workspaces.id, existing.id)) - .returning(); - return updated ?? existing; + } + + if (input.type === "main") { + const existing = await tx.query.v2Workspaces.findFirst({ + where: and( + eq(v2Workspaces.projectId, project.id), + eq(v2Workspaces.hostId, host.machineId), + eq(v2Workspaces.type, "main"), + ), + }); + if (existing) { + const patch: { + branch?: string; + name?: string; + } = {}; + if (existing.branch !== input.branch) { + patch.branch = input.branch; + if (existing.name === existing.branch) { + patch.name = input.name; + } + } + if (Object.keys(patch).length > 0) { + const [updated] = await tx + .update(v2Workspaces) + .set(patch) + .where(eq(v2Workspaces.id, existing.id)) + .returning(); + return updated ?? existing; + } + return existing; } - return existing; } - } + + return null; + }); + + if (result) return result; throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", @@ -240,6 +302,66 @@ export const v2WorkspaceRouter = { }); }), + linkTask: protectedProcedure + .input( + z.object({ + workspaceId: z.string().uuid(), + taskId: z.string().uuid(), + }), + ) + .mutation(async ({ ctx, input }) => { + const organizationId = requireActiveOrgId(ctx, "No active organization"); + const workspace = await getWorkspaceAccess( + ctx.session.user.id, + input.workspaceId, + { organizationId }, + ); + const task = await dbWs.query.tasks.findFirst({ + columns: { id: true, organizationId: true }, + where: eq(tasks.id, input.taskId), + }); + if (!task) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Task not found", + }); + } + if (task.organizationId !== workspace.organizationId) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Task does not belong to the workspace's organization", + }); + } + await dbWs + .insert(workspaceTasks) + .values({ workspaceId: input.workspaceId, taskId: input.taskId }) + .onConflictDoNothing(); + return { success: true as const }; + }), + + unlinkTask: protectedProcedure + .input( + z.object({ + workspaceId: z.string().uuid(), + taskId: z.string().uuid(), + }), + ) + .mutation(async ({ ctx, input }) => { + const organizationId = requireActiveOrgId(ctx, "No active organization"); + await getWorkspaceAccess(ctx.session.user.id, input.workspaceId, { + organizationId, + }); + await dbWs + .delete(workspaceTasks) + .where( + and( + eq(workspaceTasks.workspaceId, input.workspaceId), + eq(workspaceTasks.taskId, input.taskId), + ), + ); + return { success: true as const }; + }), + getFromHost: jwtProcedure .input( z.object({ diff --git a/plans/20260425-canonical-workspace-create-flow.md b/plans/20260425-canonical-workspace-create-flow.md index 2e18b4c91aa..75899b9387e 100644 --- a/plans/20260425-canonical-workspace-create-flow.md +++ b/plans/20260425-canonical-workspace-create-flow.md @@ -4,7 +4,7 @@ Workspace creation should be a single host-service orchestration flow. Today the renderer drives too much of the lifecycle: it creates a pending row, creates or checks out the workspace, loads attachments, builds launch prompts, writes attachment files, stores transient launch intent, navigates, and then relies on the workspace route mounting to actually start or reveal work. -The target is to move workspace creation and runtime startup behind one `workspace.create()` API. The renderer uploads attachments separately, calls `workspace.create()`, writes returned launch panes into the workspace pane store, and navigates. The workspace route renders existing pane state; it does not start agents or populate panes as a side effect of mounting. +The target is to move workspace creation behind one `workspaces.create` API and split launches (`agents.run`, `terminals.run`) into their own resources. The renderer uploads attachments separately, calls `workspaces.create` (optionally with the `agents` sugar to spawn an agent in the same call), writes returned terminals + agent sessions into the workspace pane store, and navigates. The workspace route renders existing pane state; it does not start agents or populate panes as a side effect of mounting. ## Goals @@ -47,74 +47,229 @@ Attachments are stored on the selected host, not in Superset cloud: ### Workspace Create -Replace the current narrow `workspace.create({ projectId, name, branch })` with: +Replace the current narrow `workspace.create({ projectId, name, branch })` with one method that takes the *source* of the workspace (a branch name or a PR number) and returns a uniform shape. Other launch verbs (`agents.run`, `terminals.run`) stay separate. ```ts -workspace.create({ - mode: - | { - kind: "fork"; - branchName: string; - baseBranch?: string; - baseBranchSource?: "local" | "remote-tracking"; - } - | { kind: "checkout"; branchName: string } - | { kind: "pr-checkout"; prNumber: number } - | { kind: "adopt"; branchName: string; worktreePath?: string }, - - projectId: string, - name: string, - - launches?: Array< - | { kind: "terminal"; command: string; label?: string } - | { - kind: "agent"; - agentId: string; - prompt: string; - attachmentIds?: string[]; - } - >, +workspaces.create({ + projectId: string; + name: string; + + // Source — exactly one of `branch` or `pr` is required. Server picks the + // right machinery based on which is set; caller never picks a "mode". + branch?: string; + pr?: number; + + // Branch-source modifier: git ref to fork from when `branch` does not exist. + // Defaults to the project's default branch as a remote-tracking ref + // (e.g. "origin/main"). Pass "main" for fork-from-local. Ignored when `pr` + // is set — the PR head is the start point. + baseBranch?: string; + + // Optional metadata link to N Superset tasks. Cloud persists into the + // workspace_tasks join (see "Task linking" below). Composes with any source. + taskIds?: string[]; + + // Internal flag — when true, server fires a post-create AI rename of the + // workspace (and its branch) from the prompt as a background side-effect. + // Default false. Not surfaced in the SDK / CLI / MCP type definitions; the + // desktop renderer passes it directly via electronTrpc. See "AI naming". + autogenerateName?: boolean; + + // Sugar: spawn agents immediately after create. Equivalent to calling + // `agents.run` once per entry. See "Launching agents" below. + agents?: AgentLaunch[]; }) => { - workspace: { id, projectId, name, branch }, - launches: Array< - | { kind: "terminal"; terminalId: string; label?: string } - | { kind: "chat"; chatSessionId: string; label?: string } - >, - warnings: string[], -} + workspace: { id: string; projectId: string; name: string; branch: string }; + // Terminals started by *this* call. Empty when alreadyExists (server doesn't + // re-run setup script for an existing workspace). + terminals: Array<{ terminalId: string; label?: string }>; + // Per-entry results for the `agents` sugar. Spawned even when alreadyExists, + // so retries and "make sure this workspace has this agent running" intents + // work cleanly. Empty when sugar wasn't requested. + agents: AgentLaunchResult[]; + // True when the resolved source mapped to an existing workspace. The + // workspace itself is reused; sugar `agents` still spawn against it. + alreadyExists: boolean; +}; +``` + +Branch resolution: if `branch` exists locally or as a remote-tracking ref, it is checked out; otherwise it is created from `baseBranch`. That collapses the previous `fork` / `checkout` discriminated union. + +PR resolution: server runs `gh pr view --json` to fetch metadata, derives a unique local branch name (handles cross-fork PRs where the head ref alone isn't enough), runs `gh pr checkout` inside a detached worktree, and configures push/upstream. Caller passes only `pr: number`; the rest is server detail. + +Conflict handling: if the derived branch already has a Superset workspace, server returns it with `alreadyExists: true` and still runs any sugar `agents[]` against it (sugar reads as "make sure this workspace has these agents running" — calling twice spawns two sessions, which is fine for retry semantics). If the branch exists in git but no Superset workspace tracks it (e.g. user ran `gh pr checkout` outside Superset), server throws a `CONFLICT` error pointing at the orphaned branch — caller can show a confirm dialog or surface the message. No silent force-resets, no `force` flag. + +Why one method instead of two: PR vs branch is just *which source field is set*, not a different verb with different downstream fields. The return shape is identical. Splitting them into `workspaces.create` and `workspaces.fromPullRequest` would mean two SDK methods, two CLI subcommands, two sets of sugar handling — for a difference that's one optional field on the input. The cost isn't worth it. + +CLI shape: + +``` +superset workspace create --branch feat/auth +superset workspace create --pr 1234 +``` + +### Original four flows, mapped + +The previous draft had a `mode` discriminated union with four kinds. All four are still supported, just consolidated: + +| Original mode | New shape | Where it lives | +|---|---|---| +| `fork` (new branch from base) | `workspaces.create({ branch, baseBranch? })` — branch doesn't exist yet | Public | +| `checkout` (existing branch) | `workspaces.create({ branch })` — branch exists locally or as remote-tracking | Public | +| `pr-checkout` (PR number) | `workspaces.create({ pr })` | Public | +| `adopt` (worktree exists on disk, no Superset workspace) | host-service-internal `adopt` procedure | Internal | + +Adopt's only callers today are the desktop new-workspace modal's "existing worktree" picker and the v1→v2 migration. CLI / SDK / MCP users never touch it. Keeping it host-internal means the public surface doesn't carry a verb that nobody calls. + +Future sources slot in as additional optional fields under the same exactly-one-of rule. *Metadata* fields (like `taskIds`) compose with any source instead of replacing one. The decision per new field is "is this where the workspace comes from, or is this metadata about the workspace?" — the surface stays one method either way. + +### Task linking + +Workspaces and tasks are many-to-many: a workspace can be working on several tasks (common when batching small fixes), and a task can have several workspaces (multi-attempt automations, parallel agents). + +Schema (cloud DB): + +```ts +workspaceTasks = pgTable("workspace_tasks", { + workspaceId: uuid("workspace_id").notNull().references(() => v2Workspaces.id, { onDelete: "cascade" }), + taskId: uuid("task_id").notNull().references(() => tasks.id, { onDelete: "cascade" }), + createdAt: timestamp("created_at").notNull().defaultNow(), +}, (t) => [ + primaryKey({ columns: [t.workspaceId, t.taskId] }), + index("workspace_tasks_task_idx").on(t.taskId), +]); ``` +Composite PK enforces uniqueness; the leading column covers workspace→tasks lookups, the explicit index covers task→workspaces lookups. Cascade deletes on both sides. + +Operations: + +```ts +workspaces.linkTask({ workspaceId: string; taskId: string }) => { success: true }; +workspaces.unlinkTask({ workspaceId: string; taskId: string }) => { success: true }; +``` + +`workspaces.create({ taskIds: [...] })` writes the join rows atomically with the workspace row (single transaction in the cloud-layer create). + +### Launching agents + +Launches are a separate resource. They run against an existing workspace and are repeatable. Both verbs are idempotent at the workspace level — calling `agents.run` twice spawns two sessions. + +```ts +type AgentLaunch = { + // Either a preset id ("claude", "codex", "amp", …) or a HostAgentConfig + // instance id. Host-service resolves by trying instance id first, then preset + // id with the lowest `order` winning. + agent: string; + prompt: string; + attachmentIds?: string[]; +}; + +type Session = { sessionId: string; label?: string }; + +type AgentLaunchResult = + | ({ ok: true } & Session) + | { ok: false; error: string }; + +agents.run({ + workspaceId: string; +} & AgentLaunch) => Session; + +terminals.run({ + workspaceId: string; + command: string; + label?: string; +}) => { terminalId: string }; +``` + +The sugar `agents` field on `workspaces.create` is exactly equivalent to a `workspaces.create` call followed by N `agents.run` calls dispatched in parallel. Each entry succeeds or fails independently and is reported as an `AgentLaunchResult` in the response. Use the explicit two-step form when the caller wants to branch logic between create and launch (e.g. inspect the workspace before deciding what to run). + Notes: -- `prompt` is required, plain Markdown, on each requested agent launch. A promptless agent invocation has no place in this contract — use a raw `{ kind: "terminal", command }` launch instead. (See "Agent Configs" below for why agent profiles focus exclusively on prompt-mode startup.) -- `attachmentIds` belong to the agent launch that needs them. -- Setup is not a public input. If `.superset/setup.sh` exists, host-service starts it and returns it as a terminal launch. -- Attachment paths are not returned to the UI. +- `prompt` is required on every agent launch. A promptless run is `terminals.run({ command: "claude" })`. +- `agent` accepts either a preset id or a `HostAgentConfig` instance id, in one field. Host-service resolves by trying instance id first, then falling back to first match by `presetId` (ordered by `order`). The renderer agent picker sends the instance id since it knows exactly which row the user clicked; CLI/SDK/MCP callers send a preset string and don't think about the storage model. Preset ids are short slugs and instance ids are UUIDs, so they don't collide. +- `attachmentIds` are host-scoped IDs from the attachment store. Host-service resolves them to absolute paths and appends a deterministic block to the prompt at launch time. Renderer never sees paths. +- `terminals` covers what the server starts on its own (today: `.superset/setup.sh`). Caller renders these the same way it renders sugar `agents` results. +- `agents.run` returns terminal-backed sessions only for v1 (`HostAgentConfig` is terminal-only per PR1). Superset Chat is launched separately from the workspace's chat tab via `chat.createSession`, not through this surface. If chat-backed agents land later, the `Session` type can grow a `kind` discriminator additively. + +### What is not in the create input + +These were in the previous draft (or current code) and are now elsewhere or removed: + +- `mode` discriminated union → flat `branch` + `pr` source fields (exactly one). Adopt stays host-internal. +- `launches[]` with mixed terminal/agent kinds → `agents.run` and `terminals.run` are separate verbs. Multiple agents at create are still supported via the `agents` sugar; raw terminals at create are not — call `terminals.run` after. If a real use case shows up, sugar can be added later (additive, not breaking). +- `runSetupScript: boolean` toggle → removed. Server always runs `.superset/setup.sh` when present and returns the session in `terminals`. The current modal toggle is desktop-only and not wired in v2; we don't need a public flag for it. Users who want to skip setup can move the script aside. +- `warnings: string[]` → removed. Server throws on the only condition that needed it (orphan local branch). Per-launch failures surface as `{ ok: false, error }` in `agents`. +- `force` / `--force` → removed. The previous code used `gh pr checkout --force` and warned about clobbering; the new flow refuses up front, no flag needed. +- `workspaceNameWasAutoGenerated` flag → renamed to `autogenerateName` and kept as a desktop-only opt-in. Not surfaced in the SDK / CLI / MCP type definitions; desktop sets it via raw tRPC when the user didn't type a name. The post-create AI rename side-effect stays intact (see "AI naming" below) — opt-in by this flag, not implicit. +- `linkedContext` blob (`internalIssueIds`, `githubIssueUrls`, `linkedPrUrl`, `attachments`) → split into purpose-specific fields: tasks via `taskIds`, attachments via `attachmentIds` on agent launches, issue/PR *context* via prompt Markdown (see "Linked context" below). The original `linkedContext` field was dead in host-service anyway. + +### Linked context (issues / PRs that aren't sources) + +Today's modal lets users link N GitHub issues and an optional non-source PR for the agent to read. The renderer pre-fetches issue/PR bodies and synthesizes Markdown attachments (`github-issue-123.md`, etc.). + +The new shape doesn't add API fields for this. Linked context is *prompt content*, not workspace data. The renderer: + +- Fetches issue/PR content via existing helpers (`projects.getIssueContent`, `gh pr view`). +- Either inlines the rendered Markdown into the agent's `prompt`, or uploads it via `attachments.upload` and passes the resulting `attachmentId` on the agent launch. + +This stays consistent with the boundary "prompt is plain Markdown the caller authors." CLI / SDK / MCP callers don't need a special field — they include the context in the prompt themselves. The previous `linkedContext` blob (`githubIssueUrls`, `linkedPrUrl`, `internalIssueIds`) was dead in host-service anyway; this just makes the boundary explicit. + +Note: `pr` as a *source* (use this PR's branch as the workspace branch) is different — that's `workspaces.create({ pr })`. Linking a PR for *context* (workspace is on a different branch but agent should know about a related PR) goes in the prompt. + +### AI naming + +Today host-service auto-renames a freshly-created workspace from its prompt as a background side-effect of `workspaceCreation.create`, gated by a `workspaceNameWasAutoGenerated` flag. The new shape preserves this exact behavior with a renamed flag that's deliberately *not* part of the public SDK / CLI / MCP surface: + +- `workspaces.create({ ..., autogenerateName: true })` triggers a fire-and-forget AI rename in the same code path as today's `applyAiWorkspaceRename`. Server generates a workspace name + branch name from the prompt in the background, updates `v2Workspaces.name` / `v2Workspaces.branch`, renames the worktree directory, and lets Electric sync deliver the change to the renderer. +- The flag exists on the cloud-trpc input schema but is **omitted from the SDK / CLI / MCP type definitions** (Pick / Omit at the SDK boundary). The desktop renderer passes it directly via electronTrpc. CLI / SDK / MCP callers can't see it and don't accidentally trigger AI rename when they pass user-chosen names. +- Default `false` everywhere. Naming the flag `autogenerateName` (imperative "do this") rather than `nameWasAutoGenerated` (descriptive "this state was true") makes it read as a directive to the server, not a state report. + +This keeps the public API minimal and predictable for non-desktop callers, while preserving the single-call create UX for desktop without an extra round-trip. + +`workspaces.generateBranchName({ projectId, prompt }) => { branchName }` stays as a separate synchronous query for the modal's "✨ suggest a branch name" button before submit. Already exists today; just moves to the new router. + +### Workspace `type` is internal + +The `v2Workspaces.type: "main" | "worktree"` distinction is implementation, not public surface. Every (project, host) pair has one auto-managed `main` workspace pointing at the repo root, created by `ensureMainWorkspace` from project setup and the startup sweep. Users only ever create `type: "worktree"` workspaces via this API; they never see or set `type` directly. + +### Why split create from launch + +Same reasoning as OpenCode's `session.create` / `session.prompt` split. Each call has a small flat input that maps cleanly to CLI flags, SDK parameters, MCP tool inputs, and the renderer modal — instead of a four-arm discriminated union with nested launch arrays that every surface has to model. Composition is open: "create, run two agents, attach a terminal" is three calls, not new fields. Code share is real because the renderer's modal calls the same two methods the CLI does. ## Host-Service Flow -`workspace.create()` owns the full server-side flow: +`workspaces.create` owns workspace creation. Launches run through their own procedures. The split mirrors the public API. + +`workspaces.create`: 1. Resolve the local project/repo. -2. Execute the requested workspace mode: - - `fork`: create a new branch/worktree from base branch. - - `checkout`: check out an existing branch into a workspace. - - `pr-checkout`: check out a GitHub PR branch. - - `adopt`: register an existing worktree. -3. Register the host and cloud workspace row. -4. Persist the local host workspace row. -5. Build an internal launch list: - - setup terminal if `.superset/setup.sh` exists; - - all requested terminal launches; - - all requested agent launches. -6. For each agent launch: - - resolve the selected agent config; - - resolve `attachmentIds` to host-readable paths; - - append a deterministic attachment block to the Markdown prompt; - - start either a terminal-backed or chat-backed session. -7. Return the workspace row, all launched session IDs, and warnings. - -Attachment prompt block for terminal agents should reference absolute host paths, for example: +2. Resolve the source: + - **`branch` set**: if `branch` exists locally or as a remote-tracking ref, check it out; otherwise create it from `baseBranch` (default: project's default branch via remote-tracking). + - **`pr` set**: run `gh pr view --json` to fetch metadata; derive a unique local branch name (handles cross-fork PRs); run `gh pr checkout` inside a detached worktree; configure push/upstream. +3. Conflict checks: + - If a Superset workspace already tracks the resolved branch, set `alreadyExists = true`, skip new-workspace steps (4-6), and proceed to step 7 to run sugar `agents[]` against the existing workspace. + - If the resolved branch exists in git but no Superset workspace tracks it, throw `CONFLICT` with the orphan branch name. No silent overwrite. +4. Register the host and cloud workspace row; persist the local row. +5. Forward `taskIds` to cloud (if set) so the cloud-layer create can insert the `workspace_tasks` join rows atomically with the workspace row. +6. If `.superset/setup.sh` exists, start it as a terminal session and add it to `terminals`. +7. If the request included sugar `agents[]`, dispatch each entry through the same path as `agents.run` (against the new or existing workspace) and collect per-entry `AgentLaunchResult`s. +8. Return `{ workspace, terminals, agents, alreadyExists }`. + +Adopt of an existing worktree stays a host-service-internal procedure (used by the modal's existing-worktree picker and the v1→v2 migration). It is not exposed via the public SDK / CLI / MCP surface and is out of scope for this doc. + +`agents.run`: + +1. Resolve the workspace. +2. Resolve `agent` against host-local `HostAgentConfig` rows: try exact `id` match first, then fall back to first match by `presetId` ordered by `order`. Fail clearly if no match. +3. Resolve `attachmentIds` to absolute host paths. +4. Append a deterministic attachment block to the prompt when attachments exist. +5. Spawn the configured argv per the PR1 launch spec — `[command, ...args, ...promptArgs, prompt?]` for `argv` transport, or pipe the prompt to stdin for `stdin` transport. Apply the config's `env` overlay. +6. Return the session. + +`terminals.run`: spawn the requested command in the workspace's cwd. Return the terminal id. + +The attachment prompt block stays host-local, with absolute paths: ```md # Attached files @@ -126,18 +281,16 @@ The user attached these files. They are available on this host at: ## Renderer Flow -Interactive UI flows should work like this: +Interactive UI flows work like this: 1. User selects a target host. -2. User attaches files. -3. Renderer immediately calls `attachments.upload()` on the selected host. -4. Renderer stores `attachmentId` plus display metadata in local Zustand state. -5. If the selected host changes, clear or reupload attachments. -6. On submit, renderer calls `workspace.create()` with the requested mode and launches. -7. After create resolves, renderer writes returned launches into the workspace pane store. -8. Renderer navigates to `/v2-workspace/$workspaceId`. +2. User attaches files; renderer calls `attachments.upload()` on the selected host immediately and stores returned `attachmentId`s plus display metadata in local Zustand state. +3. If the selected host changes, clear or reupload attachments. +4. On submit, renderer calls `workspaces.create({...})` with the `agents` sugar populated when the user picked an agent + prompt. +5. After create resolves, renderer writes `terminals` and successful `agents` results into the workspace pane store via `addLaunchPanes` (PR3). +6. Renderer navigates to `/v2-workspace/$workspaceId`. -The workspace route should only render the existing pane store. It should not be required to start agents, consume pending launch intent, or populate panes as a side effect of mounting. +The workspace route only renders the existing pane store. It is never required to start agents, consume pending launch intent, or populate panes as a side effect of mounting. ## Pane Store Registry @@ -158,8 +311,7 @@ addLaunchPanes(workspaceId, launches) It should: - create or fetch the pane store for `workspaceId`; -- add terminal panes for returned `terminalId`s; -- add chat panes for returned `chatSessionId`s; +- add terminal panes for each returned session id (v1 returns terminal-backed only); - dedupe by session ID; - focus the created or existing pane. @@ -170,16 +322,16 @@ Prompt templates are separate from workspace creation. The create API accepts user-editable Markdown on each agent launch: ```ts -{ kind: "agent", agentId, prompt, attachmentIds } +{ agent, prompt, attachmentIds } ``` -Template systems can generate that Markdown before submit, and users can edit it freely. `workspace.create()` does not need to know whether the prompt came from a saved template, a task view button, an automation, CLI input, or manual typing. +Template systems can generate that Markdown before submit, and users can edit it freely. `workspaces.create` does not need to know whether the prompt came from a saved template, a task view button, an automation, CLI input, or manual typing. Host-service owns only runtime prompt finalization: - resolve attachment IDs to readable host paths; - append the attachment block; -- adapt the prompt for the selected terminal/chat agent config; +- adapt the prompt for the selected agent config; - start the session. This keeps semantic prompt authoring host-independent while keeping host-local paths host-owned. @@ -193,7 +345,7 @@ The prompt builder should be split into two responsibilities: ### Template Rendering -Templates produce Markdown. They are not part of the `workspace.create()` contract. +Templates produce Markdown. They are not part of the `workspaces.create` contract. Saved templates, task view actions, automations, CLI helpers, and manual input should all eventually produce the same simple value: @@ -221,12 +373,11 @@ The UI can use this to show unresolved variables before submit. Users should alw Host-service finalizes prompts only at launch time. -For each requested agent launch, host-service receives: +For each requested agent launch (via `agents.run` or the `agents` sugar on `workspaces.create`), host-service receives: ```ts { - kind: "agent", - agentId: string, + agent: string, // preset id or HostAgentConfig instance id prompt: string, attachmentIds?: string[], } @@ -234,12 +385,11 @@ For each requested agent launch, host-service receives: Host-service then: -- loads the selected agent config; -- resolves whether the agent is terminal-backed or chat-backed; +- resolves `agent` against host-local `HostAgentConfig` rows (instance id first, then preset id by `order`); - resolves each `attachmentId` from the selected host's attachment store; - adds an attachment section to the prompt when attachments exist; -- adapts the final prompt for the selected agent runtime; -- starts the terminal or chat session. +- spawns the configured argv per the PR1 launch spec, with the prompt as either an argv tail or piped to stdin (`promptTransport`); +- returns the session. The attachment section should be deterministic and host-local: @@ -268,124 +418,102 @@ The renderer can preview and edit human-authored Markdown, but host-service owns ### Agent Configs -Agent configs should be host-local launch profiles for v1. They encode real runtime and security preferences: CLI flags, approval mode, sandboxing behavior, model selection, and command templates. Those preferences can reasonably differ per machine. +Agent configs are host-local launch profiles. They encode real runtime and security preferences: CLI flags, approval mode, sandboxing behavior, model selection, and command spec. Those preferences can reasonably differ per machine. Responsibilities: - Product/settings UI owns editing agent profiles on the selected/local host. -- Host-local settings own persistence. +- `host.db` (PR1) owns persistence. - Host-service owns runtime validation and execution. -- Renderer should pass `agentId`, not reconstruct command flags. +- Public callers (renderer, CLI, SDK, MCP) pass `agent: string` — a preset id or instance id — never reconstruct argv themselves. -For v1, `agentId` means "the agent profile with this ID on the selected host." If another host does not have the same profile, that host cannot launch it. Cross-device synced agent profiles can be a later product decision. +For v1, an instance id is host-scoped; the same instance id only resolves on the host that wrote it. Preset ids (`"claude"`, `"codex"`, etc.) are stable across hosts. Cross-device synced agent profiles are a later product decision. -For `workspace.create()`, an agent launch should require a prompt. If the caller wants a promptless process, it should use a raw terminal launch: +For `workspaces.create` and `agents.run`, an agent launch always has a prompt. Promptless invocations go through `terminals.run` with the agent's executable as the command: ```ts -{ kind: "terminal", command: "claude", label: "Claude" } +terminals.run({ workspaceId, command: "claude", label: "Claude" }) ``` That lets agent profiles focus on one job: "given a Markdown prompt, how does this host start this agent?" -The host-local config model should be a list of configured preset instances. Hardcoded presets provide defaults and icons. Stored entries represent the agents this host actually exposes. - -```ts -type HostAgentSettings = { - version: 1; - agents: Array<{ - // Config instance id. Multiple entries may use the same presetId. - id: string; - // Hardcoded preset id, e.g. "claude", "codex", "custom-terminal". - presetId: string; - - // Optional overrides. Missing values resolve from the preset. - label?: string; - launchCommand?: string; - promptInput?: "argv" | "stdin"; - - order: number; - }>; -}; -``` - -Resolved runtime shape: +The host-local config model is a list of configured preset instances. Hardcoded presets provide defaults; stored entries represent the agents this host actually exposes. The full type is defined in PR1 (`plans/20260425-host-agent-configs-pr1.md`); summary: ```ts -type ResolvedHostAgentConfig = { - id: string; - presetId: string; - kind: "terminal"; +type HostAgentConfig = { + id: string; // instance id (UUID) + presetId: string; // "claude", "codex", "custom-terminal", … label: string; - description?: string; - launchCommand: string; - promptInput: "argv" | "stdin"; order: number; + command: string; // executable + args: string[]; // argv that's always present + promptTransport: "argv" | "stdin"; + promptArgs: string[]; // argv inserted only when launching with a prompt + env: Record; }; ``` -Configured entries are the available agents. Removing an entry removes it from the picker. Adding an entry creates a new instance from a hardcoded preset. Reordering edits `order`. - -Superset Chat should not be part of this host-local terminal agent config model for v1. It can still appear as a launch option, but its model/provider behavior should stay in chat/model settings. We can skip additional Superset Chat configuration in this refactor. - -Icons should not be stored in config for v1. The UI resolves icons from `presetId`. Builtins get branded icons; custom terminal entries get a generic terminal/custom icon. - -This removes the need for both `command` and `promptCommand` in the create flow. The old distinction exists because some surfaces can open an agent with no prompt, while other surfaces launch with a prompt. In the new workspace create contract: +Launch resolution at runtime is mechanical: -- `agent` launches are prompted and use `launchCommand`. -- promptless/manual commands are represented as `{ kind: "terminal", command }`. -- `launchCommand` is everything before the prompt. For `argv`, host-service appends the prompt argument. For `stdin`, host-service pipes the prompt through stdin. +```ts +const argv = prompt + ? [command, ...args, ...promptArgs, ...(promptTransport === "argv" ? [prompt] : [])] + : [command, ...args]; +// promptTransport === "stdin" with a prompt: pipe `prompt` to stdin. +``` -Based on current builtins: +Configured entries are the available agents. Removing an entry removes it from the picker. Adding an entry copies a hardcoded preset's fields into a new instance with a fresh `id`. Reordering edits `order`. -- Claude, Gemini, Mastracode, Pi, and Cursor can use their normal prompt-aware command with argv input. -- Amp needs stdin prompt input. -- Codex, OpenCode, and Copilot need prompt-specific CLI flags, which become their `launchCommand`. -- None of the current builtins need trailing arguments after the prompt, so no `launchCommandSuffix` is needed. +Superset Chat is not part of this host-local terminal agent config model for v1. It can appear as a launch option, but its model/provider behavior stays in chat/model settings. -Do not include a file-based prompt input mode in v1. It may be useful later for CLIs with native `--prompt-file` support or for avoiding shell argument limits, but none of the current builtins require it and the existing prompt transport enum only supports `argv` and `stdin`. +Icons are not stored in config for v1. The UI resolves icons from `presetId`. Builtins get branded icons; custom-terminal entries get a generic icon. -The current code does have `buildPromptFileCommandString(filePath, ...)`, but that is not a file transport mode. It reads an existing prompt file and still passes the resulting prompt through `argv` or `stdin`. +Do not include a file-based prompt input mode in v1. It may be useful later for CLIs with native `--prompt-file` support or to dodge shell argument limits, but none of the current builtins require it and the prompt transport enum is `argv | stdin` only. -Host-service should validate: +Host-service validates at launch: -- the agent config exists and is enabled; -- the requested agent kind is supported on that host; -- required commands/providers are available; -- command templates and CLI flags are well-formed; -- prompt input mode is supported. +- the agent config exists; +- `command` is resolvable on `PATH`; +- `promptTransport` is one of `argv | stdin`; +- `args` / `promptArgs` are well-formed. -If an agent command/provider is unavailable on that host, `workspace.create()` should fail that specific launch clearly or return a warning when other launches can continue. +If the configured executable is unavailable on the host, `agents.run` (or the sugar entry on `workspaces.create`) fails that specific launch with a clear error. The workspace create itself still succeeds; the failure is reported per-entry in `agents[]`. Security boundary: - User-owned configs may run user-configured commands on that user's host. - Host-local capabilities, paths, tokens, and installed tools remain host-owned and are never assumed from synced config alone. -The invariant should be: +The invariant: -> The same `agentId` is only stable within a host. The renderer selects from the target host's available profiles; host-service resolves that profile and launches it. +> Instance ids are stable only within a host. Preset ids are stable across hosts. The renderer selects from the target host's available profiles; host-service resolves whichever was sent and launches it. ## Router Migration -`workspaceCreation` should be deprecated, not extended. +`workspaceCreation` is deprecated, not extended. Behavior moves to the new methods: -Move current create behavior into `workspace.create()`: +| Old procedure | New home | +|---|---| +| `workspaceCreation.create` (fork) | `workspaces.create` (branch-source path) | +| `workspaceCreation.checkout` (branch path) | `workspaces.create` (branch-source path) | +| `workspaceCreation.checkout` (PR path) | `workspaces.create` (PR-source path) | +| `workspaceCreation.adopt` | host-internal `adopt` procedure (kept; not exposed publicly) | -- `workspaceCreation.create` fork behavior; -- `workspaceCreation.checkout` branch checkout behavior; -- `workspaceCreation.checkout` PR checkout behavior; -- `workspaceCreation.adopt` adopt behavior. +New public methods on the workspace router: `workspaces.create`, `workspaces.linkTask`, `workspaces.unlinkTask`. Existing `workspace.get`, `workspace.gitStatus`, `workspace.delete` stay. -Keep existing `workspace.get`, `workspace.gitStatus`, and `workspace.delete`. +New launch routers: `agents.run`, `terminals.run`. New attachment router: `attachments.upload`, `attachments.delete`. Move or delete remaining `workspaceCreation` helpers: - `getProgress`: delete; create is promise-based for v1. -- `searchBranches`: move to `project` or `workspace`. -- `generateBranchName`: move to `workspace`. -- GitHub issue/PR search and content helpers: move to `github` or a context-oriented router. +- `searchBranches`: move to `workspaces.searchBranches` (still needs to return state per branch — local / remote-tracking / has-existing-worktree — so the modal can pick the right intent). +- `generateBranchName`: move to `workspaces.generateBranchName`. +- AI rename side-effect on `workspaceCreation.create`: stays as a side-effect of `workspaces.create`, gated by the new `nameWasAutoGenerated` flag (default false). Same `applyAiWorkspaceRename` code path as today. +- GitHub issue/PR search and content helpers (`searchGitHubIssues`, `searchPullRequests`, `getGitHubIssueContent`, `getGitHubPullRequestContent`): move to a `github` router. Renderer keeps using them to render linked-context Markdown before submit. - `getContext`: delete if no new caller needs it. +The host-internal `adopt` procedure stays where it is, just no longer exposed publicly. v1→v2 migration and the modal's "existing worktree" picker keep using it directly. + After callers migrate, remove `workspaceCreation` from `appRouter`. ## Logic To Remove @@ -411,9 +539,9 @@ Replace the current pending-row flow with direct mutation state: - upload attachments to the selected host as they are added; - store uploaded attachment metadata in modal-local Zustand state; -- call `workspace.create()` on submit; +- call `workspaces.create({...})` on submit (with `agents` sugar populated when the user picked an agent); - show loading while the create promise is in flight; -- write returned launches to the workspace pane store; +- write returned `terminals` and successful `agents` results into the workspace pane store via `addLaunchPanes`; - navigate to the created workspace. Remove modal plumbing that only exists for pending orchestration: @@ -465,19 +593,19 @@ This lets callers populate panes before route mount. The route should read the s ### Automations And CLI -Automations should call the same `workspace.create()` endpoint with requested launches instead of doing: +Automations call `workspaces.create({ ..., agents: [...] })` instead of doing: 1. workspace create; 2. separate chat or terminal dispatch; 3. separate run-row session wiring. -The automation run row should persist the returned workspace ID and launch IDs. +The automation run row persists the returned workspace ID and the per-entry session IDs from the response's `agents[]`. -CLI should call the same endpoint and print the returned workspace and session IDs. It does not need pane store logic. +CLI calls the same endpoint and prints the returned workspace and session IDs. It does not need pane store logic. ## PR Boundaries And Implementation Order -This should be split into several PRs. The safest order is to move ownership one boundary at a time and keep old flows working until the replacement path is complete. +The work splits into 5 PRs total. PRs 1–3 are foundational (already in flight or merged) and PRs 4–5 are the canonical create rewrite. The split between PR4 and PR5 is "additive cutover" vs "deletes" — PR4 ships the new flow end-to-end (API + UI), PR5 rips out the old machinery now that nothing reaches it. ### PR 1: Host-Local Agent Config Model @@ -485,11 +613,11 @@ Goal: introduce the new configured-agent-instance model without changing workspa Changes: -- Add hardcoded terminal agent presets with `presetId`, label, description, default `launchCommand`, default `promptInput`, and UI icon mapping. -- Add host-local storage for `HostAgentSettings { version, agents }`. -- Add host-service/settings APIs to list, add, update, remove, and reorder configured agents. -- Migrate existing builtin overrides/custom agents into configured entries, preserving current enabled agents and command edits. -- Keep existing renderer consumers working by exposing resolved configs in the current `ResolvedAgentConfig`-compatible shape where needed. +- Add hardcoded terminal agent presets with `presetId`, label, default `command` + `args`, default `promptArgs`, default `promptTransport`, default `env`, and UI icon mapping (per the argv-array spec in `plans/20260425-host-agent-configs-pr1.md`). +- Add host-local storage for `HostAgentConfig` rows in `host.db`. +- Add host-service settings APIs (`settings.agentConfigs.list/add/update/remove/reorder/resetToDefaults`). +- v1→v2 migration of existing desktop preset/custom-agent overrides is a separate follow-up, not part of PR1. +- Keep existing renderer consumers working: PR1 only adds the V2 settings UI under `FEATURE_FLAGS.V2_CLOUD`. Non-V2 keeps the legacy `settings.getAgentPresets()` UI unchanged. Tests: @@ -536,103 +664,115 @@ Tests: - route renders pre-populated panes; - duplicate terminal/chat IDs do not create duplicate panes. -### PR 4: New `workspace.create()` API +### PR 4: New API + new UI (additive cutover) -Goal: add canonical host-service orchestration while leaving `workspaceCreation` in place. +Goal: ship the new flow end-to-end — new public API, new modal, all callers (modal, task view, automation dispatch, CLI, MCP) flipped onto it. The old `workspaceCreation.create/checkout/adopt` procedures stay in the host-service router but are no longer reachable from any caller. Build stays green throughout: nothing's deleted yet. -Changes: +API + host-service work: -- Add the new `workspace.create()` input/output shape. -- Port fork, checkout, PR checkout, and adopt internals from `workspaceCreation`. -- Start setup terminal automatically when `.superset/setup.sh` exists. -- Start requested raw terminal launches. -- Start requested agent launches by resolving host-local agent config, finalizing prompt with host attachment paths, and creating terminal sessions. -- Return `workspace`, `launches`, and `warnings`. +- Add `workspaces.create` with the input/output shape from "Workspace Create" above (`branch | pr` source, optional `baseBranch`, `taskIds`, `agents` sugar, `autogenerateName` desktop-only flag). Returns `{ workspace, terminals, agents, alreadyExists }`. Throws `CONFLICT` on orphan local branches. +- Port branch/checkout internals from `workspaceCreation.create` and `workspaceCreation.checkout` into the branch-source path. +- Port PR checkout internals from `workspaceCreation.checkout` PR path into the PR-source path. Server fetches PR metadata via `gh pr view`; caller passes only `pr: number`. +- `workspaceCreation.adopt` stays as a host-internal procedure for the modal "existing worktree" picker and the v1→v2 migration. Not exposed publicly. +- Server always runs `.superset/setup.sh` when present; returns the session in `terminals[]`. +- Add `agents.run` and `terminals.run` as separate procedures. +- `agents.run` resolves `agent` via host-local `HostAgentConfig` (instance id then preset id), finalizes prompt with attachment paths, spawns argv per the PR1 launch spec. +- Add `workspaces.aiRename({ workspaceId, prompt })` and `workspaces.generateBranchName({ projectId, prompt })`. Triggered by `autogenerateName: true` on `workspaces.create` (renderer-only flag, omitted from SDK/CLI/MCP types). +- Add `workspace_tasks` join table migration (cloud DB). Cloud-layer `workspaces.create` writes `taskIds` rows atomically. Add `workspaces.linkTask` and `workspaces.unlinkTask`. -Tests: +Renderer cutover (rebuild the modal flow on the new API): -- all modes create/adopt the expected workspace; -- multiple launches start for one workspace; -- setup launch is included when setup script exists; -- invalid agent IDs fail clearly; -- attachment IDs are resolved into prompt text. +- New workspace modal uploads attachments to host on attach (via `attachments.upload`); stores `attachmentId`s in modal-local Zustand state. +- Submit calls `workspaces.create({...})` directly with the `agents` sugar populated when the user picked an agent. No pending row created. +- After success, renderer writes returned `terminals` + successful `agents` results into the workspace pane store via `addLaunchPanes(workspaceId, [...terminals, ...agents.filter(ok)])`, then navigates to `/v2-workspace/$workspaceId`. +- Task view's "open in workspace" / "run in workspace" flows build prompt Markdown in the UI (fetching task description, linked GitHub issue/PR content as needed), pass `taskIds` to link the workspace. +- Existing-worktree picker path in the modal calls the host-internal `adopt` procedure directly, same as today. -### PR 5: Migrate Interactive Create UI +Drop the orchestration gunk in the same PR (the new flow doesn't need any of it): -Goal: move the new workspace modal/task entrypoints onto the new create flow. +- Pending route as create orchestrator: rebuild as a thin loading view around the create mutation, OR drop it entirely if the modal can show its own loading state. The state machine (`pending.intent` switch, `consumePendingLaunch` mount-effect, `dispatchForkLaunch`, `useConsumePendingLaunch`) is gone. +- `getProgress` polling and the progress-store machinery — gone. Create is a single promise; UI shows a spinner. +- IndexedDB attachment-blob storage for the modal — gone. Attachments upload directly to host now. +- `buildIntentPayload` / `buildForkAgentLaunch` / `buildCheckoutPayload` / `buildPrCheckoutPayload` / `buildAdoptPayload` — gone. The new modal builds one `workspaces.create` payload directly. +- `useCreateDashboardWorkspace` / `useCheckoutDashboardWorkspace` / `useAdoptWorktree` — collapsed into a single `useCreateWorkspace` hook (the existing-worktree adopt path stays as a separate hook against the host-internal procedure). +- Renderer-side launch building (turning agent configs + prompts into terminal command strings) — gone. Host-service owns argv now. +- Renderer-side attachment file writing into worktrees — gone. Attachments live in the host attachment store. -Changes: - -- New workspace modal uploads attachments to host on attach. -- Submit calls `workspace.create()` directly. -- After success, call `addLaunchPanes()` and navigate. -- Remove create-flow use of pending rows, pending route, IndexedDB attachment blobs, and renderer-side launch building for migrated entrypoints. -- Task view/open-in-workspace flows build semantic Markdown in the UI and call the same endpoint. - -Tests: - -- modal creates workspace and opens returned agent pane; -- attachments appear in terminal prompt as host-local paths; -- task launch creates prompt Markdown in UI and launches via host-service; -- route mount is not required to start the agent. +Other callers also flip in this PR: -### PR 6: Migrate Automations And CLI +- Automations call `workspaces.create({ ..., agents: [{ agent, prompt }] })` instead of `workspaceCreation.create` + separate dispatch. Automation run rows persist the returned workspace ID and per-entry session IDs. +- CLI calls `workspaces.create` and prints workspace/session IDs. +- MCP `create_workspace` tool routes to `workspaces.create`. The legacy `sourceWorkspaceId` convenience field is dropped — tool only supports `baseBranch`. -Goal: make non-renderer callers use the same create API. - -Changes: - -- Automations call `workspace.create()` with requested launches instead of create plus separate dispatch. -- Automation run rows persist returned workspace ID and launch IDs. -- CLI calls `workspace.create()` and prints workspace/session IDs. +After PR4: every callsite uses the new API. The old `workspaceCreation.create/checkout/getProgress` procedures still exist in the router but nothing reaches them. `workspaceCreation.adopt` is still actively used (host-internal) and stays. Tests: -- automation run creates workspace and session through one host call; -- CLI create works without renderer pane state. - -### PR 7: Remove Legacy Creation Machinery - -Goal: delete the old orchestration path after all callers migrate. +- All four source flows produce the expected workspace (branch fork, branch checkout, PR checkout, internal adopt). +- Branch resolution prefers existing local/remote-tracking refs over forking. +- PR resolution uses `gh pr checkout` and handles cross-fork PRs. +- Orphan local branch throws `CONFLICT`. +- `agents` sugar runs independently per entry and surfaces per-entry results. +- Sugar agents still spawn against an `alreadyExists: true` workspace. +- `autogenerateName: true` triggers the AI rename background task. +- Setup terminal appears in `terminals[]` when present. +- Invalid `agent` (no matching config) fails the entry without failing the workspace. +- Attachment IDs resolve to prompt-block paths. +- `taskIds` writes the `workspace_tasks` join atomically. +- `linkTask` / `unlinkTask` are idempotent. +- New modal creates a workspace and opens the returned agent pane without going through `/pending/$pendingId`. +- Automation run creates workspace + session in one host call. +- CLI create prints IDs without renderer pane state. + +### PR 5: Remove legacy create machinery + +Goal: delete code that PR4 made unreachable. No behavior change. Changes: -- Remove `workspaceCreation.create`, `checkout`, `adopt`, and `getProgress`. -- Move remaining picker/search helpers to their final routers. -- Remove `dispatchForkLaunch`. -- Remove pending row `terminalLaunch` / `chatLaunch`. -- Remove pending route create orchestration. -- Remove renderer-side terminal command construction and attachment writing for create flows. +- Remove `workspaceCreation.create`, `workspaceCreation.checkout`, `workspaceCreation.getProgress` from the host-service router. +- Move remaining helpers (`searchBranches`, `generateBranchName`, GitHub issue/PR search/content) to their final routers (`workspaces`, `github`). +- Delete `dispatchForkLaunch` and the launch-building utilities under `pending/$pendingId/`. +- Remove pending-row columns: `terminalLaunch`, `chatLaunch`, `intent`, `runSetupScript`, anything else load-bearing only for the old flow. (Drizzle migration to drop the columns / table.) +- If the pending route was kept as a thin loading shim in PR4, decide whether to remove it entirely now. If nothing else depends on it, delete the route. +- Remove `useConsumePendingLaunch` if PR4 left a compatibility-only stub. +- Remove `linkedContext` and `composer` schemas from any leftover input types. +- Drop `applyAiWorkspaceRename` from `workspaceCreation.create` (it's now called from `workspaces.aiRename` instead). +- Remove `workspaceCreation` from `appRouter` once the file has nothing left. Tests: -- no references remain to removed procedures; -- full create flows still pass across modal, task, automation, and CLI. +- No references remain to removed procedures or pending-row launch fields. +- All call sites still use only the new `workspaces.*` / `agents.*` / `terminals.*` / `attachments.*` surface. +- `workspaceCreation.adopt` stays callable (still used by modal + v1 migration). ## Testing Host-service tests: -- `workspace.create` works for `fork`, `checkout`, `pr-checkout`, and `adopt`. -- multiple requested launches start for one workspace. -- setup script, when present, returns as a terminal launch. -- agent sessions start without renderer navigation. -- attachment IDs resolve to host-readable paths used in prompts. -- invalid attachment IDs fail the relevant agent launch clearly. -- raw terminal launch starts the requested command. +- `workspaces.create` covers all three public flows: branch fork, branch checkout, and PR checkout. +- The host-internal `adopt` procedure still registers existing worktrees correctly (modal + v1 migration paths). +- Branch resolution prefers existing local/remote-tracking refs over forking. +- PR resolution uses `gh pr checkout` and handles cross-fork PRs without manual remote setup. +- Orphan local branch (no Superset workspace) throws `CONFLICT` instead of silently overwriting. +- Multiple `agents` sugar entries start independently and surface per-entry results. +- Setup script, when present, appears in the response's `terminals[]`. +- `agents.run` and `terminals.run` work as standalone calls against existing workspaces. +- Attachment IDs resolve to host-readable paths used in prompts; invalid IDs fail the specific launch. +- `taskIds` on create writes `workspace_tasks` rows atomically with the workspace insert. Renderer tests: -- attachment upload stores only IDs and display metadata in local UI state. -- host changes clear or reupload attachments. -- create result launches are added to the workspace pane store before route mount. -- duplicate launch IDs focus existing panes instead of creating duplicates. -- workspace route renders pre-populated pane state without consuming pending launch intent. +- Attachment upload stores only IDs and display metadata in local UI state. +- Host changes clear or reupload attachments. +- `terminals` and successful `agents` results are added to the workspace pane store before route mount. +- Duplicate session IDs focus existing panes instead of creating duplicates. +- Workspace route renders pre-populated pane state without consuming pending launch intent. Integration tests: -- new workspace modal, task view, automations, and CLI can call the same create API. -- no create path depends on pending rows, query params, or workspace route effects. +- New workspace modal, task view, automations, MCP, and CLI all call the same create API. +- No create path depends on pending rows, query params, or workspace route effects. ## Assumptions From 124f8b7a833d6b1d007450fc5318974465ac3724 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Sun, 3 May 2026 15:54:20 -0700 Subject: [PATCH 05/16] fix: drop simple-git --quiet on rev-parse and ship membership claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - refExists/localBranchExists/remoteExists: simple-git's `raw` resolves successfully with empty stdout when --quiet is on, so every "new branch" was being treated as already existing. Drop the flag and validate a 40+ hex sha was actually printed. - auth: include the user's full membership list in OAuth access-token claims so cross-org JWT checks pass downstream. - org-resource-access: split the not-found / wrong-org error paths so org-mismatch reports the actual ids in the diagnostic. - pull-requests: skip refresh for workspaces whose worktree was deleted on disk; simple-git would otherwise throw a confusing directory-does-not-exist error. - git/utils: stop logging on missing remote/origin — both null cases are expected and callers handle them. --- packages/auth/src/server.ts | 16 +++++++++++++++- packages/host-service/src/runtime/git/refs.ts | 8 ++++++-- packages/host-service/src/runtime/git/utils.ts | 5 +++-- .../src/runtime/pull-requests/pull-requests.ts | 5 +++++ .../utils/resolve-start-point.ts | 7 +++++-- .../src/trpc/router/workspaces/workspaces.ts | 14 +++++++++----- .../trpc/src/router/utils/org-resource-access.ts | 14 ++++++++++---- 7 files changed, 53 insertions(+), 16 deletions(-) diff --git a/packages/auth/src/server.ts b/packages/auth/src/server.ts index 518b26e1408..d6f3f276489 100644 --- a/packages/auth/src/server.ts +++ b/packages/auth/src/server.ts @@ -225,13 +225,27 @@ export const auth = betterAuth({ return activeOrganizationId ?? undefined; }, }, - customAccessTokenClaims: ({ referenceId, metadata }) => { + customAccessTokenClaims: async ({ user, referenceId, metadata }) => { const clientName = metadata && typeof metadata === "object" && "client_name" in metadata ? metadata.client_name : undefined; + // Mirror the JWT plugin's `definePayload` so OAuth access tokens + // carry the user's full membership list. Without this, every + // `ctx.organizationIds.includes(...)` check downstream rejects + // the token because the claim defaults to `[]`. + const memberRows = user?.id + ? await db.query.members.findMany({ + where: eq(members.userId, user.id), + columns: { organizationId: true }, + }) + : []; + const organizationIds = [ + ...new Set(memberRows.map((m) => m.organizationId)), + ]; return { organizationId: referenceId ?? undefined, + organizationIds, client_name: typeof clientName === "string" ? clientName : undefined, }; }, diff --git a/packages/host-service/src/runtime/git/refs.ts b/packages/host-service/src/runtime/git/refs.ts index 015e7e593d5..2cbedc80380 100644 --- a/packages/host-service/src/runtime/git/refs.ts +++ b/packages/host-service/src/runtime/git/refs.ts @@ -41,8 +41,12 @@ export function asRemoteRef( async function refExists(git: SimpleGit, fullRef: string): Promise { try { - await git.raw(["rev-parse", "--verify", "--quiet", `${fullRef}^{commit}`]); - return true; + // Don't use `--quiet` — simple-git's `raw` mis-resolves on empty + // stderr and reports the missing ref as a success with empty stdout. + // Without `--quiet`, git writes the error to stderr and simple-git + // rejects as expected. We then verify a sha was actually printed. + const out = await git.raw(["rev-parse", "--verify", `${fullRef}^{commit}`]); + return /^[0-9a-f]{40,}/.test(out.trim()); } catch { return false; } diff --git a/packages/host-service/src/runtime/git/utils.ts b/packages/host-service/src/runtime/git/utils.ts index 20c25dd62ac..8d56bd905d6 100644 --- a/packages/host-service/src/runtime/git/utils.ts +++ b/packages/host-service/src/runtime/git/utils.ts @@ -4,8 +4,9 @@ export async function getRemoteUrl(git: SimpleGit): Promise { try { const url = await git.remote(["get-url", "origin"]); return url?.trim() || null; - } catch (error) { - console.warn("[host-service] Failed to get remote URL:", error); + } catch { + // Common (and expected) failure modes: not a git repo, no `origin` + // remote configured. Callers handle null and don't need a log. return null; } } diff --git a/packages/host-service/src/runtime/pull-requests/pull-requests.ts b/packages/host-service/src/runtime/pull-requests/pull-requests.ts index cb279e6b371..c35aa4db629 100644 --- a/packages/host-service/src/runtime/pull-requests/pull-requests.ts +++ b/packages/host-service/src/runtime/pull-requests/pull-requests.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import { existsSync } from "node:fs"; import type { Octokit } from "@octokit/rest"; import { parseGitHubRemote } from "@superset/shared/github-remote"; import { and, eq, inArray } from "drizzle-orm"; @@ -308,6 +309,10 @@ export class PullRequestRuntimeManager { const changedProjectIds = new Set(); for (const workspace of allWorkspaces) { + // Skip workspaces whose worktree was deleted on disk but whose row + // is still in the host db. simple-git would throw a confusing + // "directory does not exist" error otherwise. + if (!existsSync(workspace.worktreePath)) continue; try { const git = await this.git(workspace.worktreePath); const branch = await getCurrentBranchName(git); diff --git a/packages/host-service/src/trpc/router/workspace-creation/utils/resolve-start-point.ts b/packages/host-service/src/trpc/router/workspace-creation/utils/resolve-start-point.ts index 64f4a853a38..dfaccb6ad63 100644 --- a/packages/host-service/src/trpc/router/workspace-creation/utils/resolve-start-point.ts +++ b/packages/host-service/src/trpc/router/workspace-creation/utils/resolve-start-point.ts @@ -8,8 +8,11 @@ import { async function refExists(git: SimpleGit, fullRef: string): Promise { try { - await git.raw(["rev-parse", "--verify", "--quiet", `${fullRef}^{commit}`]); - return true; + // See refs.ts — `--quiet` makes simple-git's `raw` mis-resolve a + // missing ref as success with empty stdout. Drop it; verify a sha + // was actually printed. + const out = await git.raw(["rev-parse", "--verify", `${fullRef}^{commit}`]); + return /^[0-9a-f]{40,}/.test(out.trim()); } catch { return false; } diff --git a/packages/host-service/src/trpc/router/workspaces/workspaces.ts b/packages/host-service/src/trpc/router/workspaces/workspaces.ts index 626f2b126ac..6a0008bd1e2 100644 --- a/packages/host-service/src/trpc/router/workspaces/workspaces.ts +++ b/packages/host-service/src/trpc/router/workspaces/workspaces.ts @@ -151,13 +151,15 @@ async function localBranchExists( branchName: string, ): Promise { try { - await git.raw([ + // Same trap as refs.ts: `--quiet` causes simple-git's `raw` to + // mis-resolve missing refs as success with empty stdout. Verify a + // sha was printed to confirm the ref actually exists. + const out = await git.raw([ "show-ref", "--verify", - "--quiet", `refs/heads/${branchName}`, ]); - return true; + return /^[0-9a-f]{40,}/.test(out.trim()); } catch { return false; } @@ -200,9 +202,11 @@ async function planBranchSource( const upstream = await resolveUpstream(git, defaultBranchName); if (upstream) { const remoteRef = asRemoteRef(upstream.remote, upstream.remoteBranch); + // `--quiet` confuses simple-git's `raw` (resolves on missing + // refs with empty stdout). Drop it; verify a sha was printed. const remoteExists = await git - .raw(["rev-parse", "--verify", "--quiet", `${remoteRef}^{commit}`]) - .then(() => true) + .raw(["rev-parse", "--verify", `${remoteRef}^{commit}`]) + .then((out) => /^[0-9a-f]{40,}/.test(out.trim())) .catch(() => false); if (remoteExists) { startPoint = { diff --git a/packages/trpc/src/router/utils/org-resource-access.ts b/packages/trpc/src/router/utils/org-resource-access.ts index 86a165d0990..cf2e6ef6f8c 100644 --- a/packages/trpc/src/router/utils/org-resource-access.ts +++ b/packages/trpc/src/router/utils/org-resource-access.ts @@ -20,14 +20,20 @@ export async function requireOrgScopedResource( ): Promise { const resource = await resolveResource(); + if (!resource) { + throw new TRPCError({ + code: options.code ?? "NOT_FOUND", + message: options.message, + }); + } + if ( - !resource || - (options.organizationId && - resource.organizationId !== options.organizationId) + options.organizationId && + resource.organizationId !== options.organizationId ) { throw new TRPCError({ code: options.code ?? "NOT_FOUND", - message: options.message, + message: `${options.message} (resource org ${resource.organizationId} ≠ requested org ${options.organizationId})`, }); } From 1e4ef4e6837614490d06b91ab957816a20fe6d0e Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Sun, 3 May 2026 16:07:05 -0700 Subject: [PATCH 06/16] chore(cli): override env vars from workspace .env in dev script Without -o, dotenv-cli leaves any pre-existing process.env vars alone, so a shell-level `SUPERSET_HOME_DIR=~/.superset` (set globally for the production CLI) wins over the workspace's `.env` value and dev CLI ops land in the prod data dir instead of `superset-dev-data/`. --- packages/cli/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index a78d74ce597..0f4015db55e 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "dev": "dotenv -e ../../.env -- sh -c 'exec env SUPERSET_API_URL=$NEXT_PUBLIC_API_URL cli-framework dev \"$@\"' --", + "dev": "dotenv -e ../../.env -o -- sh -c 'exec env SUPERSET_API_URL=$NEXT_PUBLIC_API_URL cli-framework dev \"$@\"' --", "build": "cli-framework build", "build:darwin-arm64": "cli-framework build --target=bun-darwin-arm64 --outfile=./dist/superset-darwin-arm64", "build:linux-x64": "cli-framework build --target=bun-linux-x64 --outfile=./dist/superset-linux-x64", From d3aa75fd79238ee24a997310155692707a68f1c1 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Sun, 3 May 2026 17:03:20 -0700 Subject: [PATCH 07/16] feat(cli): add agents run command + variadic flag support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cli-framework: extend `.variadic()` to string flags so repeated `--flag value` invocations accumulate into an array. Drop the auto-`isRequired: true` from `.variadic()` — both existing positional callsites already chain `.required()` explicitly, no behavior change. - cli: new `superset agents run --workspace --agent --prompt [--attachment-id ]...` for spawning an agent inside an existing workspace. Wraps the host service `agents.run` mutation; resolves the workspace's host via the cloud lookup. --- packages/cli-framework/src/option.ts | 25 ++++++---- packages/cli-framework/src/parser.ts | 24 +++++++-- packages/cli/src/commands/agents/meta.ts | 3 ++ .../cli/src/commands/agents/run/command.ts | 49 +++++++++++++++++++ 4 files changed, 89 insertions(+), 12 deletions(-) create mode 100644 packages/cli/src/commands/agents/meta.ts create mode 100644 packages/cli/src/commands/agents/run/command.ts diff --git a/packages/cli-framework/src/option.ts b/packages/cli-framework/src/option.ts index 248ae315c6f..98b92a1b879 100644 --- a/packages/cli-framework/src/option.ts +++ b/packages/cli-framework/src/option.ts @@ -55,9 +55,9 @@ export class OptionBuilderBase< OptionBuilderBase< BuilderConfig<"string">, string | undefined, - TOmit | OptionType | "min" | "max" | "int" | "variadic" + TOmit | OptionType | "min" | "max" | "int" >, - TOmit | OptionType | "min" | "max" | "int" | "variadic" + TOmit | OptionType | "min" | "max" | "int" > { return new OptionBuilderBase({ ...this._.config, @@ -288,15 +288,22 @@ export class OptionBuilderBase< OptionBuilderBase< TBuilderConfig, string[], - TOmit | "variadic" | "required" | "default", + TOmit | "variadic" | "default", TEnums >, - TOmit | "variadic" | "required" | "default" + TOmit | "variadic" | "default" > { + if ( + this._.config.type !== "positional" && + this._.config.type !== "string" + ) { + throw new CLIError( + "`.variadic()` is only valid on string or positional options", + ); + } return new OptionBuilderBase({ ...this._.config, isVariadic: true, - isRequired: true, }) as any; } } @@ -325,9 +332,9 @@ export function string(): Omit< OptionBuilderBase< BuilderConfig<"string">, string | undefined, - OptionType | "min" | "max" | "int" | "variadic" + OptionType | "min" | "max" | "int" >, - OptionType | "min" | "max" | "int" | "variadic" + OptionType | "min" | "max" | "int" >; export function string( name: TName, @@ -335,9 +342,9 @@ export function string( OptionBuilderBase< BuilderConfig<"string">, string | undefined, - OptionType | "min" | "max" | "int" | "variadic" + OptionType | "min" | "max" | "int" >, - OptionType | "min" | "max" | "int" | "variadic" + OptionType | "min" | "max" | "int" >; export function string(name?: string) { return name !== undefined diff --git a/packages/cli-framework/src/parser.ts b/packages/cli-framework/src/parser.ts index 80bcd865ec2..1ec95faa89b 100644 --- a/packages/cli-framework/src/parser.ts +++ b/packages/cli-framework/src/parser.ts @@ -111,7 +111,14 @@ export function parseArgv( throw new CLIError(`Unknown option: ${flagPart}`); } - options[entry[0]] = coerce(entry[1], valuePart, flagPart); + const coerced = coerce(entry[1], valuePart, flagPart); + if (entry[1].isVariadic) { + const existing = (options[entry[0]] as string[] | undefined) ?? []; + existing.push(coerced as string); + options[entry[0]] = existing; + } else { + options[entry[0]] = coerced; + } continue; } @@ -148,7 +155,14 @@ export function parseArgv( ); } - options[entry[0]] = coerce(entry[1], nextArg, arg); + const coerced = coerce(entry[1], nextArg, arg); + if (entry[1].isVariadic) { + const existing = (options[entry[0]] as string[] | undefined) ?? []; + existing.push(coerced as string); + options[entry[0]] = existing; + } else { + options[entry[0]] = coerced; + } i++; continue; } @@ -179,7 +193,11 @@ export function parseArgv( // Validate required options for (const [key, config] of Object.entries(allConfigs)) { if (config.type === "positional") continue; - if (config.isRequired && options[key] === undefined) { + const value = options[key]; + const missing = + value === undefined || + (config.isVariadic && Array.isArray(value) && value.length === 0); + if (config.isRequired && missing) { const flag = config.name.startsWith("-") ? config.name : `--${config.name}`; diff --git a/packages/cli/src/commands/agents/meta.ts b/packages/cli/src/commands/agents/meta.ts new file mode 100644 index 00000000000..94b706332f4 --- /dev/null +++ b/packages/cli/src/commands/agents/meta.ts @@ -0,0 +1,3 @@ +export default { + description: "Run agents inside workspaces", +}; diff --git a/packages/cli/src/commands/agents/run/command.ts b/packages/cli/src/commands/agents/run/command.ts new file mode 100644 index 00000000000..7cc37ce80b6 --- /dev/null +++ b/packages/cli/src/commands/agents/run/command.ts @@ -0,0 +1,49 @@ +import { CLIError, string } from "@superset/cli-framework"; +import { command } from "../../../lib/command"; +import { resolveHostTarget } from "../../../lib/host-target"; + +export default command({ + description: "Launch an agent inside an existing workspace", + options: { + workspace: string().required().desc("Workspace ID"), + agent: string() + .required() + .desc("Agent preset id (e.g. claude) or instance id"), + prompt: string().required().desc("Prompt sent to the agent"), + attachmentId: string() + .variadic() + .desc("Attachment UUID; pass --attachment-id repeatedly"), + }, + run: async ({ ctx, options }) => { + const organizationId = ctx.config.organizationId; + if (!organizationId) { + throw new CLIError("No active organization", "Run: superset auth login"); + } + + const cloudWorkspace = await ctx.api.v2Workspace.getFromHost.query({ + organizationId, + id: options.workspace, + }); + if (!cloudWorkspace) { + throw new CLIError(`Workspace not found: ${options.workspace}`); + } + + const target = resolveHostTarget({ + requestedHostId: cloudWorkspace.hostId, + organizationId, + userJwt: ctx.bearer, + }); + + const result = await target.client.agents.run.mutate({ + workspaceId: options.workspace, + agent: options.agent, + prompt: options.prompt, + attachmentIds: options.attachmentId, + }); + + return { + data: result, + message: `Launched ${result.label} (terminal ${result.sessionId}) in workspace ${options.workspace}`, + }; + }, +}); From 2f5d820e085ebe1cddf1efde1968d770de9f8bbe Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Sun, 3 May 2026 17:03:37 -0700 Subject: [PATCH 08/16] refactor: server-generate workspace name + per-side AI rename gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The renderer no longer generates a friendly-random fallback. Both `name` and `branch` are now optional on the create input — the server picks a friendly random for whichever side is missing, and the AI rename only replaces the side(s) the user didn't supply. - `workspaces.create` schema: drop `autogenerateName`, make `name` optional, allow both `branch` and `pr` to be absent (refine relaxed to "not both set"). Server generates+dedupes a friendly branch name when `branch` is undefined; falls back to PR title or branch for workspace name when `name` is undefined. - `applyAiWorkspaceRename`: take `renameTitle` / `renameBranch` flags; only apply the side the caller asked for. Both `true` from the manual `aiRename` mutation; computed per-create-input on the branch path; skipped entirely on the PR path. - Renderer: drop `friendlyFallback` from the draft store + context + `PromptGroup` placeholder; `resolveNames` returns `string | null`; `useSubmitWorkspace` sends only what the user typed (no synthetic name). In-flight states show "Creating workspace" with no subtitle when the name is absent. - plans: add the v2-workspaces-create-test-plan covering this PR's divergences from v1. --- .../useDashboardSidebarData.ts | 5 +- .../WorkspaceCreateErrorState.tsx | 4 +- .../WorkspaceCreatingState.tsx | 4 +- .../DashboardNewWorkspaceDraftContext.tsx | 1 - .../PromptGroup/PromptGroup.tsx | 3 +- .../hooks/useSubmitWorkspace/resolveNames.ts | 35 +-- .../useSubmitWorkspace/useSubmitWorkspace.ts | 20 +- .../renderer/stores/new-workspace-draft.ts | 3 - .../utils/ai-workspace-names.ts | 25 +- .../src/trpc/router/workspaces/workspaces.ts | 48 ++- plans/v2-workspaces-create-test-plan.md | 289 ++++++++++++++++++ 11 files changed, 370 insertions(+), 67 deletions(-) create mode 100644 plans/v2-workspaces-create-test-plan.md diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts index 3e03edaf52d..e491a1da4c1 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts @@ -143,8 +143,9 @@ export function useDashboardSidebarData() { .map((entry) => ({ id: entry.snapshot.id as string, projectId: entry.snapshot.projectId, - name: entry.snapshot.name, - branchName: entry.snapshot.branch ?? entry.snapshot.name, + name: entry.snapshot.name ?? "New workspace", + branchName: + entry.snapshot.branch ?? entry.snapshot.name ?? "New workspace", status: entry.state === "creating" ? ("creating" as const) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceCreateErrorState/WorkspaceCreateErrorState.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceCreateErrorState/WorkspaceCreateErrorState.tsx index 63708d6c9d9..21c0cd2122c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceCreateErrorState/WorkspaceCreateErrorState.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceCreateErrorState/WorkspaceCreateErrorState.tsx @@ -5,7 +5,7 @@ import { useWorkspaceCreates } from "renderer/stores/workspace-creates"; interface WorkspaceCreateErrorStateProps { workspaceId: string; - name: string; + name?: string; error: string; } @@ -31,7 +31,7 @@ export function WorkspaceCreateErrorState({

Failed to create workspace

-

{name}

+ {name &&

{name}

}

{error}