diff --git a/apps/desktop/plans/done/20260428-pty-handoff-survival-architectures.md b/apps/desktop/plans/done/20260428-pty-handoff-survival-architectures.md deleted file mode 100644 index fa35ab11a48..00000000000 --- a/apps/desktop/plans/done/20260428-pty-handoff-survival-architectures.md +++ /dev/null @@ -1,1040 +0,0 @@ -# PTY Survival Across Host-Service Upgrades — Architecture Survey - -**Status:** Decision doc. -**Owner:** Kiet -**Date:** 2026-04-28 -**Branch:** `pty-manifest-detach-reatt` - -Survey of architectures for keeping `node-pty` shells alive across -host-service upgrades. PoC of SCM_RIGHTS fd-passing in -`~/workplace/pty-handoff-poc/`. - -## Table of Contents - -- [Problem Statement](#problem-statement) -- [Today's Behavior](#todays-behavior) -- [Survival Bars](#survival-bars) -- [Architectures](#architectures) - - [A. Status Quo: Kill + Respawn](#a-status-quo-kill--respawn) - - [B. Serialize + Replay (VS Code-style)](#b-serialize--replay-vs-code-style) - - [C1. SCM_RIGHTS fd-passing with node-pty](#c1-scm_rights-fd-passing-with-node-pty) - - [C2. SCM_RIGHTS fd-passing with direct forkpty (FFI)](#c2-scm_rights-fd-passing-with-direct-forkpty-ffi) - - [D. Long-Lived `pty-daemon`](#d-long-lived-pty-daemon) - - [E. Hybrid: Daemon + fd-passing on daemon upgrade](#e-hybrid-daemon--fd-passing-on-daemon-upgrade) - - [F. tmux / screen / abduco as the backend](#f-tmux--screen--abduco-as-the-backend) - - [G. exec() in-place upgrade](#g-exec-in-place-upgrade) - - [H. OS Supervisor (launchd / systemd socket activation)](#h-os-supervisor-launchd--systemd-socket-activation) - - [I. CRIU (Linux-only checkpoint/restore)](#i-criu-linux-only-checkpointrestore) - - [J. Mosh-style state sync](#j-mosh-style-state-sync) -- [Windows (ConPTY)](#windows-conpty) -- [Comparison Table](#comparison-table) -- [PoC Findings](#poc-findings) -- [Recommendation](#recommendation) -- [Phased Plan](#phased-plan) -- [Phase 0: C2 Reliability Test Plan (gating)](#phase-0-c2-reliability-test-plan-gating) -- [Phase 0 Results (macOS arm64, 2026-04-29)](#phase-0-results-macos-arm64-2026-04-29) -- [Phase 0 Follow-up: node-pty handoff (macOS arm64, 2026-04-29)](#phase-0-follow-up-node-pty-handoff-macos-arm64-2026-04-29) -- [Appendix: PTY Library Evaluation](#appendix-pty-library-evaluation) -- [Open Questions](#open-questions) -- [References](#references) - ---- - -## Problem Statement - -Host-service (Bun process spawned by Electron main, `packages/host-service/src/index.ts`, -manifest in `apps/desktop/src/main/lib/host-service-manifest.ts`) owns -`node-pty` master fds for every v2 terminal session. Restarting host-service -kills all PTYs. - -The manifest already handles Electron-main-restart (adopt detached -host-service via PID + endpoint on disk). Unsolved: host-service binary -version bumps. - -**Hard constraints:** - -1. **Process continuity required.** Shells survive upgrades. Kill+respawn - (A) and serialize+replay (B) are not acceptable, even as fallbacks. -2. **Host-service upgrades happen frequently.** Architecture must treat - upgrades as a hot path. - -Non-trivial because Node's `process.send` only passes net/dgram handle -wrappers, not arbitrary fds (`node/lib/internal/child_process.js:91`). - -## Today's Behavior - -- `host-service-coordinator.ts:157-163` SIGTERMs old, spawns fresh; PTYs die. -- Renderer reconnects via `tRPC.terminal.createOrAttach` to a new shell. -- ~64KB ring buffer per session in-memory only - (`packages/host-service/src/terminal/terminal.ts:64`) — lost. -- `terminalSessions` row exists (`packages/host-service/src/db/schema.ts:9-30`) - with metadata only, no PTY state. - -## Survival Bars - -1. **Visual continuity.** Renderer redraws prior screen. New shell PID, - running commands killed. (VS Code's bar.) **Excluded by constraint.** -2. **Process continuity.** Same shell PID, commands keep running. - **Our minimum bar.** -3. **Bit-for-bit continuity.** No data loss between handoff. Polish on top of (2). - ---- - -## Architectures - -### A. Status Quo: Kill + Respawn - -Current behavior. `host-service-coordinator.ts:157-163`. Every shell -dies on every upgrade. - -**Status: excluded by constraint.** - ---- - -### B. Serialize + Replay (VS Code-style) - -**Bar:** visual only. - -**Mechanism:** mirror PTY output into headless xterm; serialize buffer to -DB on shutdown; respawn shell + replay buffer through WS on startup. -Working dir restored manually (`echo $PWD`/`cd`); env mutations and -running commands lost. - -**Code sketch:** - -```ts -import { Terminal as XtermHeadless } from "@xterm/headless"; -import { SerializeAddon } from "@xterm/addon-serialize"; - -session.onData((d) => headless.get(session.id)!.term.write(d)); - -// Shutdown: -for (const [id, { ser }] of headless) { - await db.update(terminalSessions).set({ - serializedBuffer: ser.serialize({ scrollback: 1000 }), - }).where(eq(terminalSessions.id, id)); -} -// Startup: ws.send({ type: "replay", data: row.serializedBuffer }); -``` - -**OSS prior art:** VS Code `ptyService.ts:687-960` (`PersistentTerminalProcess`), -`:1032-1108` (`XtermSerializer`); `LocalReconnectConstants.GraceTime = 60000` -(`common/terminal.ts:846-861`). Uses `xterm-headless` + `@xterm/addon-serialize`. - -**Pros:** pure JS, all platforms, ~1 week. -**Cons:** running commands die. Only visual continuity. - -**Status: excluded by constraint.** - ---- - -### C1. SCM_RIGHTS fd-passing with node-pty - -**Bar:** process continuity (same PID). - -**Mechanism:** old host-service spawns new, hands PTY master fd via -`sendmsg(SCM_RIGHTS)` over Unix socket, waits for ack, exits. Kernel -dups the fd; refcount stays > 0; slave (shell) sees no change. - -Three ways to call `sendmsg` from JS: - -1. Native N-API addon (~100 LOC C++, node-gyp). -2. `usocket` npm package — broken on Node 24 (PoC confirmed). -3. **Bun's `bun:ffi cc`: inline C, zero install. PoC uses this.** - -**Code (excerpt from `~/workplace/pty-handoff-poc/scm.c`):** - -```c -int send_fd(int sockfd, int fd_to_send, const uint8_t *data, int data_len) { - struct iovec iov = { .iov_base = (void *)data, .iov_len = data_len }; - union { char buf[CMSG_SPACE(sizeof(int))]; struct cmsghdr align; } u; - struct msghdr msg = { - .msg_iov = &iov, .msg_iovlen = 1, - .msg_control = u.buf, .msg_controllen = sizeof(u.buf), - }; - struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg); - cmsg->cmsg_level = SOL_SOCKET; - cmsg->cmsg_type = SCM_RIGHTS; - cmsg->cmsg_len = CMSG_LEN(sizeof(int)); - memcpy(CMSG_DATA(cmsg), &fd_to_send, sizeof(int)); - return sendmsg(sockfd, &msg, 0); -} -``` - -```ts -import { cc } from "bun:ffi"; -const lib = cc({ source: "./scm.c", symbols: { /* ... */ } }).symbols; -const masterFd = lib.dup_fd((term as any)._fd); // dup early — see PoC gotcha -// send via sendmsg; receiver: new tty.ReadStream(receivedFd) -``` - -**PoC results:** -- ✅ SCM_RIGHTS works in Bun via `cc`. -- ✅ Received fd is a real `/dev/ptmx` master. -- ✅ Read/write through dup'd fd works. -- ❌ Shell dies on macOS when original host-service exits. - -**Original (now corrected) interpretation:** "node-pty on macOS uses a -`spawn-helper` subprocess; helper dies when parent exits, takes bash with -it." A subsequent Node + node-pty 1.1 experiment (2026-04-29) showed this -framing is incorrect: `term.pid` *equals* the shell PID (`spawn-helper` -exec's into the shell rather than persisting), and shells survive parent -exit fine **as long as another process holds a reference to the master -fd.** The C1 PoC's failure was actually the same kernel-level mechanism -that affects every PTY architecture: when the master's last fd -reference closes, the kernel sends SIGHUP to the shell session, and -bash exits on SIGHUP by default. - -**Real gotcha:** the master fd reference must not drop to zero across -the handoff. The Go harness handles this by `dup`ing before sending and -keeping the dup alive in the receiver; the Node experiment handles it -via `child_process.spawn` with the master fd inherited through the -`stdio` array. Either works, on either runtime. - -**Status: with proper fd discipline, C1 (node-pty + handoff) works on -macOS too.** The architectural distinction between C1 and C2 collapses -into "which library spawns the shell" — both are viable for D *and* E -on macOS arm64 (verified). C2 (creack/pty + Go) is still preferred -on the "small static binary, no Node runtime" axis if we want a Go -daemon; otherwise node-pty in a Node/Bun daemon is equally correct. - ---- - -### C2. SCM_RIGHTS fd-passing with direct `forkpty` - -**Bar:** process continuity, all Unix. - -**Mechanism:** bypass node-pty. Call `forkpty(3)` directly. forkpty does -`open(/dev/ptmx)` + `grantpt` + `unlockpt` + `fork` + `setsid` + makes -slave the controlling tty. Bash is a direct child in its own session, -reparents to launchd/init when its parent process exits. - -Recommended runtime: **Go**, using `creack/pty` for spawn and -`golang.org/x/sys/unix.UnixRights` for SCM_RIGHTS. Both call `forkpty` -and `sendmsg` directly, ship as part of a static binary, and avoid -node-pty's macOS spawn-helper interposition. See the -[PTY library appendix](#appendix-pty-library-evaluation) for the full -comparison. - -**Sketch (Go):** - -```go -// spawn -ptmx, err := pty.Start(exec.Command("/bin/bash", "-l")) -// ptmx is *os.File over /dev/ptmx master; child has setsid + ctty. - -// hand off -oob := unix.UnixRights(int(ptmx.Fd())) -unix.Sendmsg(connFd, sessionMetaJSON, oob, nil, 0) - -// receive in new process -n, oobn, _, _, _ := unix.Recvmsg(connFd, buf, oobBuf, 0) -cmsgs, _ := unix.ParseSocketControlMessage(oobBuf[:oobn]) -fds, _ := unix.ParseUnixRights(&cmsgs[0]) -ptmx := os.NewFile(uintptr(fds[0]), "ptmx") -``` - -**OSS prior art:** tmux `spawn.c:386`; `creack/pty` on macOS -(`creack/pty/pty_darwin.go`) and Linux (`creack/pty/pty_linux.go`); -node-pty uses `forkpty` on Linux (`node-pty/src/unix/pty.cc:438`) but -diverges to spawn-helper on macOS — `creack/pty` does not. - -**Pros:** removes spawn-helper dependency; same `setsid` guarantee -everywhere; Go's `golang.org/x/sys` is stable, no experimental tooling; -static binary distribution; trivial cross-compile. - -**Cons:** introduces Go to the repo; we re-implement small pieces around -node-pty's surface (resize, exit handling) — but `creack/pty` covers most -of this. Windows path entirely separate (ConPTY). - -**Status: untested for handoff-with-shell-survival. Mandatory empirical -validation before architecture commit — see [Phase 0](#phase-0-c2-reliability-test-plan-gating).** - ---- - -### D. Long-Lived `pty-daemon` - -**Bar:** process continuity. Shell PID never changes across host-service -upgrades. - -**Mechanism:** split host-service in two: - -``` -WS / tRPC ──► host-service (upgrades freely) - │ Unix socket - ▼ - pty-daemon (rarely upgrades, owns PTY fds) - │ - ▼ - bash, ssh, vim, ... -``` - -Daemon exposes Unix-socket protocol: open, input, output, resize, close. -host-service is a byte relay between daemon and WS. - -Same architecture as `dtach`, `abduco`, `tmux server`. - -**Code sketch:** - -```ts -// pty-daemon -const sessions = new Map(); -Bun.listen({ - unix: "/var/superset/pty-daemon.sock", - socket: { data(socket, data) { - const msg = decode(data); - if (msg.type === "open") { - const t = pty.spawn(msg.shell, msg.argv, msg.opts); - sessions.set(msg.id, t); - t.onData((d) => socket.write(encode({ type: "data", id: msg.id, d }))); - } else if (msg.type === "input") sessions.get(msg.id)?.write(msg.data); - else if (msg.type === "resize") sessions.get(msg.id)?.resize(msg.cols, msg.rows); - }}, -}); -``` - -**OSS prior art:** -- dtach `master.c:450-565` (~100 LOC select loop, minimal reference). -- abduco `server.c:139-260` (multi-client, ~300 LOC C). -- tmux `server.c:176, 264`, `spawn.c:386`. -- VS Code's PtyHost (`ptyHostService.ts`) — same shape; VS Code does - kill its PtyHost on restart, daemon here would not. - -**Pros:** host-service stateless re PTYs, upgrades freely; protocol is -small and ours; no fd-passing for routine upgrades; daemon is small (KB); -easy to observe. - -**Cons:** one extra process per workspace (or one global — design Q); -daemon has its own upgrade story; daemon crash = all PTYs lost; protocol -is now versioned public surface; one extra socket hop (~tens µs); -manifest grows to track two endpoints. - ---- - -### E. Hybrid: Daemon (D) + fd-passing (C2) on daemon upgrade - -D is steady-state. When the *daemon* upgrades (rare), C2 hands sessions -over to a new daemon binary. Only configuration with zero-downtime on -all upgrade paths. - -**Cons:** combined complexity; two upgrade paths to test; macOS -fd-passing depends on C2 working (see Open Question #1). - ---- - -### F. tmux / screen / abduco as the backend - -**Bar:** process continuity, battle-tested. - -**Mechanism:** run shells inside tmux. host-service connects via -`tmux -CC` control mode. - -```ts -const id = `superset-${sessionId}`; -const tmuxSock = `/tmp/superset-tmux.sock`; -pty.spawn("tmux", ["-S", tmuxSock, "new-session", "-A", "-s", id, "-d", shell, ...args]); -const attach = pty.spawn("tmux", ["-S", tmuxSock, "-CC", "attach", "-t", id]); -``` - -**OSS prior art:** tmux (30 years); `tmux -CC` (iTerm2 uses this); abduco. - -**Pros:** battle-tested; scrollback/resize/detach free; remote-attach free. - -**Cons:** must bundle tmux (cross-platform binary distribution); tmux -escape sequences may not match xterm.js perfectly; nested-tmux UX for -users already in tmux; control-mode protocol to wrap; less control over -semantics. - ---- - -### G. `exec()` in-place upgrade - -**Bar:** process continuity, single process. - -**Mechanism:** running host-service `execve`s the new binary. Same PID, -fd table preserved (modulo `O_CLOEXEC`). New binary picks up fds via -serialized table (env var / fd 3+4 / memfd). HAProxy/nginx pattern. - -**Code sketch:** - -```ts -for (const [id, term] of sessions) { - fcntl(term._fd, F_SETFD, fcntl(term._fd, F_GETFD) & ~FD_CLOEXEC); -} -process.env.SUPERSET_FD_TABLE = JSON.stringify(/* ... */); -execv("/path/to/new/host-service", [...]); -``` - -**OSS prior art:** HAProxy `-x`, nginx `USR2`, Erlang hot reload (spirit). - -**Pros:** no daemon; same PID throughout; fd preservation free via -execve. - -**Cons:** Bun/Node don't expose `execve` from JS — needs FFI; -re-attaching stream wrappers to inherited fds is unusual (same work as -C2); macOS sets `O_CLOEXEC` by default; in-process state (WS connections) -resets on exec; Electron parent's child-watcher must tolerate the binary -swap. - ---- - -### H. OS Supervisor (launchd / systemd socket activation) - -**Bar:** infra-level supervision; doesn't itself solve survival. - -**Mechanism:** register host-service as launchd LaunchAgent / systemd -user unit with socket activation. Combine with C2 or D for actual PTY -survival. - -**OSS prior art:** systemd `ListenStream=`, launchd `Sockets`, -`launch_activate_socket(3)`. - -**Pros:** OS handles supervision/restart/log rotation; pairs with D. - -**Cons:** desktop apps don't usually install background services; -code-signing/notarization implications on macOS; doesn't itself solve -PTY survival; install/uninstall complexity. - ---- - -### I. CRIU (Linux-only checkpoint/restore) - -Snapshot full process tree, restore into new binary. - -**Cons:** Linux only; needs `CONFIG_CHECKPOINT_RESTORE` and often root; -designed to restore *identical* processes, not new binaries; heavy. - -**Verdict:** not viable. - ---- - -### J. Mosh-style state sync - -Per-shell mosh-server keeps a framebuffer; client diffs over UDP; -host-service reconnects after upgrade. - -**Cons:** designed for unreliable network links — ours is local IPC; -massive overkill; full SSP re-implementation; client is C++. - -**Verdict:** wrong tool. - ---- - -## Windows (ConPTY) - -ConPTY: `CreatePseudoConsole`, `ResizePseudoConsole`, `ClosePseudoConsole`. -**No SCM_RIGHTS equivalent.** `DuplicateHandle` exists but requires the -target process's HANDLE — the target must already exist when you call -it. There is no "exec self and inherit fds" primitive comparable to -SCM_RIGHTS. - -Workable on Windows: -- **D (daemon owns ConPTY):** ✅ works. Daemon stays alive while - host-service upgrades; routine host-service upgrades touch nothing. -- **B (serialize+replay):** would work mechanically but is excluded by - the project constraint. -- **F (tmux/abduco backend):** out — tmux doesn't run on Windows - without WSL. -- **C/E fd-passing:** does not apply. - -With B excluded by constraint, **D is the only Windows option for -host-service upgrades.** *Daemon* upgrades on Windows are an unsolved -sub-problem — there's no clean equivalent to C2's exec-and-handoff -dance. Plausible future paths: `DuplicateHandle` between two daemon -processes coordinated via named pipe (more bespoke), or accept shell -loss on daemon upgrade only. Defer until we have Windows users. - ---- - -## Comparison Table - -| | Bar | macOS | Linux | Windows | LOC | Procs | Risk | -|---|---|---|---|---|---|---|---| -| A. Status quo | none | y | y | y | 0 | 1 | none | -| B. Serialize+replay | visual | y | y | y | ~500 | 1 | low | -| C1. fd-handoff + node-pty | process | **y (re-tested 2026-04-29)** | likely y | n | ~300 | 1 | medium | -| C2. SCM_RIGHTS + forkpty | process | **y (Phase 0 ✅ macOS arm64)** | gated by Phase 0 | n | ~600 | 1 | medium-high | -| D. pty-daemon | process | y | y | y | ~1500 | 2 | medium | -| E. D + C2 hybrid | process | y | y | partial (D only) | ~2000 | 2 | high | -| F. tmux backend | process | y (bundled) | y (bundled) | n | ~400 | 2+ | medium | -| G. exec() in-place | process | y | y | n | ~700 | 1 | high | -| H. OS supervisor | infra | y | y | maybe | ~300 | 1 | install-UX | -| I. CRIU | full | n | y | n | ~1000+ | 1 | very high | -| J. Mosh-style | net roam | y | y | y | ~3000+ | 2 | very high | - -LOC = order of magnitude including refactor + tests. - ---- - -## PoC Findings - -`~/workplace/pty-handoff-poc/` (Bun + node-pty + inline-C SCM_RIGHTS via -`bun:ffi cc`): - -1. SCM_RIGHTS works in JS. ~80 LOC C; no node-gyp. -2. Received fd is a real `/dev/ptmx` master. -3. Read/write through dup'd fd works. -4. **Gotcha 1:** `term._fd` can be recycled between `pty.spawn` and - `sendmsg` (kernel reuses fd number for unrelated `accept()`). Fix: - `dup_fd(term._fd)` immediately after spawn. -5. **Gotcha 2 (C1 killer):** node-pty's macOS spawn-helper dies with - parent, takes bash with it. Specific to node-pty on macOS, not a - fundamental fd-passing limit. - -Files: `scm.c`, `scm.ts`, `process-a.ts`, `process-b.ts`, `run.sh`, -`run-idle.sh`, `README.md`. - ---- - -## Recommendation - -**Architecture E: long-lived `pty-daemon` (D) with fd-handoff for daemon -upgrades. Phase 0 (macOS arm64) empirically validates the handoff -primitive on both Go (`creack/pty` + SCM_RIGHTS) and Node (`node-pty` + -`stdio` fd inheritance); ship it.** - -Phase 0 results (macOS arm64, 2026-04-29) close Open Question #1 -positively in both languages: shells survive parent exit when master fd -refcount is preserved across the handoff. The choice between Go and -Node for the daemon is now an engineering preference, not a correctness -one. **Default recommendation: Node daemon using node-pty, since it -matches the rest of the codebase.** Promote to Go only if a static -binary or runtime-decoupling becomes a hard requirement. - -That result *strengthens* E rather than promoting C2-only: - -- **The fd-passing primitive works.** We can confidently put it in the - daemon-upgrade path. -- **The architectural argument against C2-only stands.** host-service - holds more than PTY fds (WebSocket connections, tRPC subscriptions, - ring buffers, DB pool, EventBus). Re-execing it on every upgrade - forces all of that to re-establish on every release — visible to - the user and complex to engineer correctly. D keeps host-service - stateless re PTYs and lets it die/be replaced freely; C2 is - reserved for the rare daemon upgrade. -- **Daemon is the right boundary for the risk.** Phase 0's 100% - survival is on macOS arm64 only, with idle/counter workloads only. - Real-world failure modes (SIGKILL mid-handoff, fd table near limit, - curses apps mid-redraw, x86_64) aren't yet covered. Localizing C2 - to rare daemon upgrades is the right risk posture even with a - positive smoke-test result. - -### Decision table (post Phase 0) - -| Outcome | Architecture | -|---|---| -| Phase 0 ✅ macOS arm64 (current state) | **E** — proceed with D as foundation, C2 layer for daemon upgrades. | -| Future: Phase 0 ✅ on Linux + macOS x86_64 | Stay on E. C2-only remains rejected on architectural grounds (state model), not reliability grounds. | -| Future: Phase 0 ❌ on any platform | **D-only** on that platform. Daemon upgrades on that platform lose shells until we have a working primitive. | - -### Cross-platform portability of C2 - -| Platform | Status | Why | -|---|---|---| -| macOS arm64 | ✅ proven (Phase 0) | `creack/pty` calls `forkpty(3)` directly; `setsid` reparents shell to launchd cleanly. | -| macOS x86_64 | High confidence, untested | Same Darwin kernel + libc; same `creack/pty/pty_darwin.go` code path. | -| Linux x86_64 / arm64 | High confidence, untested | The *easier* case. `forkpty` is canonical Unix; SCM_RIGHTS is the original Linux primitive (well-documented kernel surface). macOS was the harder target. | -| Windows | **Not portable** | ConPTY has no SCM_RIGHTS analog. `DuplicateHandle` requires the target's HANDLE — i.e. the target process must already exist when transferring. Different mechanism entirely. | - -**For Unix in general, C2 is portable in theory and almost certainly -in practice. The only platform where C2 fails as a primitive is -Windows.** On Windows, D still works (daemon owns ConPTY, host-service -is a client) — what doesn't work on Windows is the *daemon-upgrade* -fd-handoff. That's a smaller problem; defer until Windows users -justify the work. - -**Skipped (reasoning unchanged):** -- A, B: violate process-continuity constraint. -- C1: broken on macOS (node-pty spawn-helper). -- F: bundling cost + nested-tmux UX + cedes PTY semantics to tmux. -- G: comparable cost to C2 with no daemon separation; doesn't address - frequent-upgrade requirement. -- H: install-UX cost; doesn't itself solve survival. -- I, J: wrong tools. - -**Crash-recovery tradeoff:** without B as fallback, daemon *crashes* -lose shells. No architecture recovers a dead PTY's process. Mitigation: -small audited daemon, supervised respawn, loud crash telemetry, daemon -treated as part of trust boundary. Explicit choice: zero shell loss on -the 99% upgrade path > graceful degradation on the 1% crash path. - ---- - -## Phased Plan - -### Phase 0: C2 reliability test (gating) - -✅ **Done on macOS arm64 (2026-04-29).** Results in -[Phase 0 Results](#phase-0-results-macos-arm64-2026-04-29). C2 is -reliable enough to use as the daemon-upgrade primitive. Linux + -macOS x86_64 still need a run before we ship across all platforms; -they're high-confidence based on the cross-platform analysis above -but should be verified with the same harness. - -### Phase 1: Architecture D (pty-daemon) - -Default runtime: **Node + `node-pty`**, given the Phase 0 follow-up. -Switch to Go + creack/pty only if static-binary distribution becomes a -hard requirement. - -1. New package `packages/pty-daemon`. Node process owning all PTY - sessions via `node-pty`. Single entry point, no bundler. -2. Versioned Unix-socket protocol: `open`, `input`, `resize`, `close`, - `subscribe-output`. Long-lived contract. -3. Daemon manifest at `~/.superset/host/{orgId}/pty-daemon-manifest.json`. - Tracks PID + socket path. -4. `host-service-coordinator`: spawn daemon if not running, adopt if - running. Mirror `host-service-coordinator.ts:290-331` adoption logic. -5. Refactor `packages/host-service/src/terminal/terminal.ts` to be a - daemon client (byte relay). -6. Supervise daemon: respawn on unexpected exit. Crashed-daemon - sessions are lost (acknowledged). -7. Telemetry: `pty_daemon_spawn`, `pty_daemon_adopt`, `pty_daemon_crash` - (latter is a bug signal, not a metric). - -After Phase 1, host-service upgrades freely without touching shells. -Primary requirement delivered. - -### Phase 2: Architecture E layer (fd-handoff on daemon upgrade) - -The simplest implementation depends on the daemon runtime: - -**If daemon is Node** (default after Phase 0 follow-up): - -1. On daemon shutdown, spawn the new daemon binary via - `child_process.spawn(newDaemonExe, [...args], { stdio: ['ignore', - 'inherit', 'inherit', ...masterFds] })`. Kernel handles the fd dup; - no SCM_RIGHTS needed. -2. New daemon receives master fds at fds `3, 4, ...`. Wrap each with - `fs.createReadStream(null, { fd })` for output and `fs.write(fd, ...)` - for input. Re-attach session metadata from a side-channel JSON - (env var or argv). -3. Drain user-space buffers in old daemon before spawn (forward - pending bytes to host-service / WS subscribers); pass any remaining - partial buffer through the side-channel JSON to avoid the byte loss - observed in the Phase 0 follow-up. - -**If daemon is Go:** - -1. `forkpty`-based PTY spawn in daemon via `creack/pty`. -2. SCM_RIGHTS handoff via `golang.org/x/sys/unix.UnixRights`. -3. New daemon: receive fds with `unix.ParseUnixRights`, wrap as - `*os.File`, resume serving. host-service reconnects via manifest - re-read. - -**Either way:** - -4. Verify on macOS *and* Linux — bash survives daemon exit when new - daemon holds the inherited / dup'd master. -5. Windows: ConPTY has no SCM_RIGHTS equivalent. `stdio` fd inheritance - on Windows works for HANDLE inheritance via `bInheritHandles=TRUE`, - but ConPTY's per-pseudoconsole HANDLE has its own lifetime rules — - needs separate investigation. Defer Windows daemon-upgrade survival - until Windows users justify it. Don't paper over with - serialize+replay. - ---- - -## Phase 0: C2 Reliability Test Plan (gating) - -Mandatory before any production work. Goal: prove or disprove that -direct-`forkpty` + SCM_RIGHTS handoff is reliable enough to be the -*only* upgrade primitive (i.e. C2-only, no daemon). Bar: -**100% session survival, zero byte loss, across realistic workloads -and handoff frequencies.** Anything less kicks us back to D or E. - -### Harness - -Standalone Go binary using `creack/pty` + `golang.org/x/sys/unix`: - -1. Spawn N PTY sessions, each running a workload that emits - sequence-numbered output. -2. Loop K times: spawn replacement self via `exec.Command`, hand all - master fds + session metadata via `unix.Sendmsg(..., UnixRights, ...)`, - wait for ack, exit. Replacement reads fds with - `unix.ParseUnixRights` and continues serving. -3. After each handoff, verify per session: - - Process alive (`kill -0 pid`). - - Output stream has no gaps in sequence numbers (or only quantified, - bounded loss at the swap moment). - - Resize still works (`unix.IoctlSetWinsize`). - - Input still reaches the shell. -4. After all K handoffs: count survivors, total bytes lost, fd-table - size (`lsof`), RSS. - -### Test matrix - -| Axis | Values | -|---|---| -| Sessions (N) | 1, 5, 20, 100 | -| Handoffs (K) | 1, 10, 100, 1000 | -| Platform | macOS arm64, macOS x86_64, Linux x86_64 | -| Workload | idle prompt; `yes` (max throughput); SSH client; `vim` insert mode; nested `tmux`; `node` REPL; background `&` jobs; `tail -f` on growing file | -| Stress | normal exit; parent SIGKILL'd mid-handoff; slow ack (1s sleep); concurrent stdin during handoff; rapid back-to-back handoffs (no settle time) | - -### Pass criteria - -- 100% session survival across 1000-handoff runs. -- Zero byte loss in sequence-numbered streams (or quantified, bounded - loss only at the swap moment, recoverable via buffer replay). -- No fd leaks (`lsof` count stable across runs). -- No memory leak across 1000 handoffs. -- Per-session handoff latency under ~50ms. - -### Outcome → architecture - -Per the [recommendation table](#recommendation): - -- **All pass on macOS + Linux:** ship C2-only. Re-evaluate cons (WS - reset on every upgrade, in-process state) — if the reliability is - there, simplicity wins. -- **Linux clean, macOS flaky:** ship E (daemon required to localize - risk). -- **macOS broken:** investigate why setsid doesn't keep bash alive - (signal disposition? controlling-tty re-acquisition?). Fall back to - D-only or F (tmux) on macOS. - -### Effort - -- Harness: ~1 day in Go. -- Run + analysis: ~½ day per platform. -- Total: ~3 days for a defensible empirical answer. Cheaper than - picking the wrong architecture. - ---- - -## Phase 0 Results (macOS arm64, 2026-04-29) - -**Outcome: C2 passes the reliability bar on macOS arm64.** Direct -`forkpty` + `setsid` keeps shells alive across SCM_RIGHTS handoff to a -new exec'd self, with zero byte loss across all tested workloads at -all tested scales. Open Question #1 is **resolved positively** for -this platform. - -Harness lives at `apps/desktop/plans/pty-handoff-experiment/` (Go, -`creack/pty` + `golang.org/x/sys/unix.UnixRights`). Per-handoff -sequence: dup all master fds, marshal session metadata + sequence -checkpoints into a length-prefixed JSON frame, `Sendmsg` with -`UnixRights` over a SOCK_STREAM AF_UNIX socketpair, ack, parent exits. -Child re-execed via `os.Executable()` with the socketpair end as -`ExtraFiles[0]`. - -### Run results - -| N (sessions) | K (handoffs) | Workload | All alive | Seq gaps | Handoff latency (ms) | -|---|---|---|---|---|---| -| 1 | 1 | counter | ✅ | 0 | 2.62 | -| 5 | 10 | counter | ✅ | 0 | avg 2.27 / max 2.59 | -| 20 | 100 | counter | ✅ | 0 | avg 2.71 / max 4.47 | -| 100 | 10 | counter | ✅ | 0 | avg 18.65 / max 29.14 | -| 5 | 100 | counter-slow (10ms cadence) | ✅ | 0 | avg 11.06 / max 14.96 | -| 10 | 100 | idle (`sleep 3600`) | ✅ | 0 | avg 49.27 / max 51.46 | -| **20** | **1000** | counter | ✅ | 0 | avg 2.78 / max 8.97 | - -Largest run: **20 sessions × 1000 handoffs = 20,000 fd-handoff -operations**, all clean. - -### Observations - -- **Latency scales with N (sessions), not K (handoff number).** N=20 - sustains avg 2.7ms across 1000 handoffs without drift. N=100 spikes - to ~18ms because every reader-stop + dup is per-session. -- **Idle workload latency includes the 50ms reader-poll deadline.** - Once we use a proper poll/epoll-based wakeup in the production - daemon, idle handoffs will be fast too. -- **Sequence gap = 0 across every tested run.** PTY data buffered in - the kernel during the parent → child handoff window arrives intact - on the first child read. The handoff is not just process-preserving, - it's byte-preserving in practice for our test workloads. -- **`creack/pty` confirmed clean on macOS arm64.** Direct `forkpty`, - no spawn-helper interposition; shells reparented to launchd and - stayed alive after parent `exit(0)`. - -### Bugs found and fixed - -- **Serial reader-stop blew up latency at N≥100** (50ms × N). Fixed by - signalling all stops first, then waiting on all done channels — - O(max) instead of O(sum). -- **SOCK_STREAM Sendmsg can split the JSON frame** at large session - counts (~12KB exceeded a single Recvmsg's effective batch). Fixed by - reading the rest of the length-prefixed frame after the initial - Recvmsg (SCM_RIGHTS arrives with that first chunk regardless). -- **`SetReadDeadline` no-ops on creack/pty's master fd by default** - (idle workloads hung). Fixed with `unix.SetNonblock(fd, true)` on - spawn and after every reattach. SCM_RIGHTS does **not** preserve - `O_NONBLOCK` across the handoff — must re-set on the receiving side. - -### Limitations of this run - -- **macOS arm64 only.** Linux x86_64 untested in this session. -- **macOS x86_64 untested.** -- **Workloads were all `/bin/sh`-driven.** Real bash login shells, vim, - tmux nested, ssh — all skipped (the harness has stubs for vim/tmux - but they weren't run). For the C1 spawn-helper failure mode to apply - here the workload needs to be node-pty-spawned, which we deliberately - bypass. -- **No SIGKILL stress.** The harness exits cleanly each generation. - Production failure modes (crashed parent mid-handoff, slow ack, fd - table near limit) untested. -- **Bytes verified by sequence number, not byte-for-byte.** Some - intra-line bytes could be dropped without our checker noticing, - though that would imply lost characters from `echo "SEQ:N"` output. - -### What this changes in the recommendation - -C2 is now **proven viable** as a primitive on macOS arm64 — at minimum -for the daemon-upgrade fd-handoff in Architecture E. The narrower -question of whether to ship **C2-only** (no daemon, host-service -re-execs itself with fd-handoff on every upgrade) remains open and -depends on: - -1. Linux + macOS x86_64 results (not yet run). -2. The non-PTY state in host-service (WebSocket connections, ring - buffer, tRPC subscriptions, DB pool) that would also need to - survive a re-exec — none of which Phase 0 tested. -3. Whether we want the renderer to see a WS reconnect on every - host-service upgrade (C2-only) vs. only on rare daemon upgrades (E). - -The recommendation table above remains correct — C2 reliable + E -preferred for the architectural reasons in *Why not C2 alone?* The -empirical floor is now: **C2 works**, so E is buildable. - ---- - -## Phase 0 Follow-up: node-pty handoff (macOS arm64, 2026-04-29) - -Triggered by the question "can the daemon be Node-only?" The original -C1 PoC concluded that node-pty's macOS spawn-helper architecture made -shell-survival across handoff impossible. A direct test with Node 24 + -node-pty 1.1 disproves that. - -Harness: `apps/desktop/plans/pty-handoff-experiment/nodepty-test/`. -Three small Node scripts: - -- `test1-survival.js`: spawn N shells via node-pty, exit immediately, - check shells externally. -- `test2-handoff.js`: spawn N shells, then spawn a child Node process - with each master fd passed via `child_process.spawn`'s `stdio` array - (kernel-level fd inheritance — fd refcount stays > 0 across parent - exit), parent exits. -- `test3-counter-handoff.js`: same as test2 but with a continuous-output - counter workload; child verifies it can read SEQ:N lines from each - inherited master. - -### Findings - -1. **`term.pid === shellPid` on Node 24 + node-pty 1.1 + macOS arm64.** - The "spawn-helper" exec's into the actual shell — there is no - long-lived helper process at runtime. The original C1 framing - ("helper dies, takes bash with it") doesn't match what's there. - That framing was likely a Bun-specific artifact, a node-pty version - difference, or simply a mis-attribution of the real failure mode. -2. **Shells die on parent exit if no other process holds the master.** - test1: parent exits, no handoff, all 5 shells die. Cause is the - kernel sending SIGHUP to the shell's session when the master's last - fd reference closes — exactly the same failure mode any PTY library - would have. Default `bash` exits on SIGHUP. -3. **Shells survive parent exit cleanly when fd inheritance preserves - master refcount.** test2 N=20: all 20 shells alive while the child - holder is alive. When the child eventually exits and closes the fds, - shells die. Predictable: master refcount is the real invariant. -4. **The child process can read live PTY output from inherited - masters.** test3 N=10: child read ~4.6 MB per session of `SEQ:N` - output across 4s after parent exit. ~440 sequence gaps per session, - all clustered at the handoff moment (seq ~6900) — these are the - bytes node-pty had buffered in user-space and never forwarded - before the parent exited. Same mitigation as the Go harness: - include the partial buffer in the handoff payload. **Not a node-pty - limitation; an artifact of any pre-buffering reader.** - -### Implications - -- **node-pty + macOS arm64 supports both D and E.** No spawn-helper - problem in practice. -- **The daemon can be Node-only.** SCM_RIGHTS isn't even needed for - daemon-upgrade handoff if the new daemon is spawned at upgrade time: - inherit master fds via `child_process.spawn`'s `stdio` array (kernel - does the dup). SCM_RIGHTS is only needed if the daemon hands off to - an *already-running* peer. -- **The choice between Go (creack/pty) and Node (node-pty) for the - daemon is now an engineering preference, not a correctness one.** - Go gives a static binary and lighter distribution; Node gives same - language as host-service and zero new tooling. Either works for both - D and E. -- **Open Question #1 is fully resolved.** The original C1 conclusion - was incorrect. Phase 0 (C2 with creack/pty) and this follow-up (with - node-pty) both pass on macOS arm64. The mechanism is identical: - preserve master fd refcount across the handoff. - -### Limitations of this follow-up - -- macOS arm64 only (same as the main Phase 0 run). -- Node 24 + node-pty 1.1 only. Older versions might behave differently; - the original C1 PoC was on Bun + node-pty (version not pinned in the - PoC notes). -- fd inheritance via `stdio` was tested; SCM_RIGHTS-from-Node was not. - Conceptually equivalent at the kernel level (same fd dup, same - refcount semantics) but uses different APIs (would need `koffi` / - N-API addon to call `sendmsg` from Node). -- `node_modules/node-pty/prebuilds/darwin-arm64/spawn-helper` had its - executable bit stripped on `npm install` — needed manual `chmod +x`. - This is a packaging quirk on this machine; would be handled by - node-pty's installer in a fresh setup, but worth noting. - ---- - -## Appendix: PTY Library Evaluation - -xterm.js doesn't care about the PTY library — it consumes any byte -stream containing standard VT/xterm escape sequences. The PTY's job is -just to give us a master fd and a child process. "Best" here means: -correct `forkpty` + `setsid` semantics, exposes the raw master fd, no -spawn-helper interposition, mature, suitable for SCM_RIGHTS handoff. - -### Ranked - -1. **`node-pty` (Microsoft).** Used by VS Code. On Node 24 + node-pty - 1.1 + macOS arm64, `term.pid === shellPid` (the spawn-helper exec's - into the shell), and shells survive parent exit cleanly when master - fd refcount is preserved (Phase 0 follow-up, 2026-04-29). **Default - choice for a Node daemon.** ConPTY on Windows. -2. **`creack/pty` (Go).** Calls `forkpty(3)` directly on macOS and - Linux (`pty_darwin.go`, `pty_linux.go`). Used by k9s, devcontainers, - dozens of TUI tools. **Choose if the daemon is Go** — best static - binary story. -3. **`portable-pty` (Rust).** Same shape as creack/pty but Rust. Used - by wezterm. Equally correct semantics. Pick only if the team is - already on Rust. -4. **Direct `forkpty(3)` FFI.** ~30 LOC C wrapper. Most control, no - library at all. Useful as a fallback if creack/pty / node-pty - behavior diverges from raw `forkpty`. Overkill otherwise. -5. **Wrapping `dtach`/`abduco`.** Skipped — their protocols aren't - ours; we'd be re-parsing instead of building. - -### Why the original "avoid node-pty" framing was wrong - -The C1 PoC concluded that node-pty's macOS spawn-helper architecture -made handoff impossible, and the doc previously rated node-pty as -unusable for E. The Phase 0 follow-up disproved this — see -[Phase 0 Follow-up: node-pty handoff](#phase-0-follow-up-node-pty-handoff-macos-arm64-2026-04-29). -The actual failure mode is master fd refcount → SIGHUP, which affects -*every* PTY library equally if you don't preserve the refcount across -handoff. Both creack/pty and node-pty are correctly handled by either -SCM_RIGHTS (`unix.UnixRights` / `koffi` / N-API) or `stdio` fd -inheritance. - -### Cross-cutting consequences - -#### If daemon is Node - -- **Same language as host-service.** Shared TypeScript types via - `packages/pty-daemon/protocol/` exported through the package index. - Shared lint, build, test tooling. -- **Distribution:** daemon ships as a Node script + `node-pty` native - module inside the desktop app bundle. Node runtime is reused from - Bun/Electron's Node-compatible runtime, or bundled separately. -- **Daemon-upgrade handoff:** simplest implementation is - `child_process.spawn(newDaemonPath, ..., { stdio: [..., - ...masterFds] })` — kernel handles fd inheritance, no SCM_RIGHTS - required. -- **Risk to manage:** the `node-pty` native binary is the long-lived - contract; node version churn touches the daemon more often than a - Go static binary would. - -#### If daemon is Go - -- **Two languages in the repo** (TS for host-service + DaemonClient, - Go for daemon). Mitigated by treating the daemon protocol as a - *spec* both sides implement, not generated/shared code. -- **Build system:** add a Go build step (or commit prebuilt binaries - per platform). Trivial to cross-compile. -- **Distribution:** daemon ships as a static binary inside the desktop - app bundle (`/pty-daemon--`). No runtime - dependency. -- **Daemon-upgrade handoff:** SCM_RIGHTS via - `golang.org/x/sys/unix.UnixRights`, validated by the Phase 0 harness. - ---- - -## Open Questions - -1. ~~**Does C2 forkpty + setsid keep bash alive after parent exit on - macOS, *under stress*?**~~ **Resolved 2026-04-29 (macOS arm64): yes** - for both creack/pty (Go) and node-pty (Node). The earlier C1 PoC - conclusion was misframed; the actual mechanism is master fd - refcount → SIGHUP, which any architecture handles by preserving the - refcount across handoff. See - [Phase 0 Results](#phase-0-results-macos-arm64-2026-04-29) and - [Phase 0 Follow-up: node-pty handoff](#phase-0-follow-up-node-pty-handoff-macos-arm64-2026-04-29). - Linux + macOS x86_64 still need a run. -2. **Manifest design for D:** single manifest with two endpoints, or - two manifests? Affects `host-service-manifest.ts` refactor scope. -3. **Per-workspace daemon vs global daemon?** Global is simpler ops but - blurs workspace isolation; per-workspace mirrors current - host-service-per-workspace pattern. -4. **Daemon crash policy.** No fallback exists (B excluded). Respawn - daemon, surface crashes as bug signal, treat daemon stability as - first-class (audited, minimal surface, no business logic). - Per-session "restart command on daemon-loss" is *not* acceptable — - it's serialize+replay by another name. -5. **Upgrade-success telemetry:** did upgrade preserve all sessions? - Crucial for measuring whether this work pays off. -6. **Renderer reattach:** v2 already has detach/reattach - (`terminal.ts:832-836`); should "just work" but verify under new flow. - ---- - -## References - -### OSS Code Read - -- VS Code — `vscode/src/vs/platform/terminal/node/`: - `ptyHostService.ts:145-198, 365-374`; - `ptyService.ts:687-960` (`PersistentTerminalProcess`), - `:1032-1108` (`XtermSerializer`); - `common/terminal.ts:846-861` (`IReconnectConstants`). -- dtach `master.c:450-565` (~100 LOC core loop). -- abduco `server.c:139-260` (multi-client). -- tmux `server.c:176, 264`, `spawn.c:386`. -- node-pty `src/unix/pty.cc:438` (forkpty), `:494` (fd export), - `:566` (resize ioctl); `lib/unixTerminal.js:108-112`. -- usocket `src/uwrap.cc:444-459, 592-599` (broken on Node 24). -- node-unix-dgram `src/unix_dgram.cc:97-104, 270-309` (no SCM_RIGHTS). -- HAProxy seamless reload, nginx hot-binary upgrade — refs for G. -- mosh SSP — ref for J. - -### PTY / handoff libraries - -- `creack/pty` (Go): — `forkpty(3)` - directly on macOS + Linux; ConPTY on Windows. -- `portable-pty` (Rust): . -- `golang.org/x/sys/unix`: `Sendmsg`, `Recvmsg`, `UnixRights`, - `ParseUnixRights`, `IoctlSetWinsize`. - -### Background - -- "Know your SCM_RIGHTS" — Cloudflare blog. -- POSIX `forkpty(3)`, `sendmsg(2)`. - -### Internal - -- `apps/desktop/HOST_SERVICE_LIFECYCLE.md`. -- `packages/host-service/src/terminal/terminal.ts`. -- `apps/desktop/src/main/lib/host-service-coordinator.ts:290-331`. -- `packages/host-service/src/db/schema.ts:9-30`. -- C1 PoC: `~/workplace/pty-handoff-poc/` (Bun + node-pty + bun:ffi cc). -- **Phase 0 harness:** `apps/desktop/plans/pty-handoff-experiment/` - (Go + creack/pty + UnixRights). See its README for design details - and reproduction steps. - -### Local clones (for offline reading) - -- `~/workplace/creack-pty/` — `pty_darwin.go`, `pty_linux.go` show the - forkpty path we depend on. -- `~/workplace/node-pty/` — `src/unix/pty.cc` for the spawn-helper - divergence on macOS that broke C1. -- `~/workplace/tmux/` — full multiplexer reference. -- `~/workplace/wezterm/` — contains portable-pty (Rust), the C2 - alternative if we ever switch off Go. -- `~/workplace/dtach/`, `~/workplace/abduco/` — minimal daemon - references for D's design ethos. diff --git a/apps/desktop/plans/done/20260430-pty-daemon-implementation-report.md b/apps/desktop/plans/done/20260430-pty-daemon-implementation-report.md deleted file mode 100644 index 1149d81f5f9..00000000000 --- a/apps/desktop/plans/done/20260430-pty-daemon-implementation-report.md +++ /dev/null @@ -1,223 +0,0 @@ -# pty-daemon Implementation Report - -**Status:** Phase 1 implemented; awaiting review. -**Date:** 2026-04-30 -**Branch:** `pty-daemon-host-integration` -**PR:** #3896 -**Plan:** `20260429-pty-daemon-implementation.md` - -Concise audit of every change against the plan. Each deviation has a -**DECISION** marker with the choice you need to make: accept the -deviation (and I'll update the plan), revert to plan (and I'll update -the code), or defer. - -## TL;DR - -- **Architecture:** as planned — daemon outlives host-service via - manifest-based adoption, identical lifetime model to host-service. -- **Tests:** 65 across 4 layers (24 daemon unit + 30 daemon - control-plane + 5 host-side DaemonClient + 6 host-service E2E). -- **Plan-compliance:** 18 decisions correctly implemented as specified; - 6 deviations from the plan (most are improvements or pragmatic - trade-offs); 7 explicit plan items not done; 1 wrong assertion of - mine that this report corrects. -- **Operationally ready:** the architecture is correct; the - observability and failure-mode hooks the plan called out (telemetry, - crash supervision) are not yet wired. - -## What shipped - -``` -packages/pty-daemon/ -├── src/ -│ ├── main.ts # Node entry: argv → Server.listen -│ ├── index.ts # public exports -│ ├── protocol/ -│ │ ├── version.ts # CURRENT_PROTOCOL_VERSION + supported list -│ │ ├── messages.ts # ClientMessage / ServerMessage unions -│ │ ├── framing.ts # encodeFrame / FrameDecoder -│ │ └── index.ts -│ ├── Pty/Pty.ts # node-pty wrapper + dim validation -│ ├── SessionStore/SessionStore.ts # in-memory map + ring buffer per session -│ ├── handlers/handlers.ts # open/input/resize/close/list/subscribe -│ └── Server/Server.ts # AF_UNIX accept loop, handshake, dispatch -├── test/ -│ ├── helpers/client.ts # reusable DaemonClient -│ ├── integration.test.ts # smoke (3 tests) -│ └── control-plane.test.ts # exhaustive (30 tests, 11 suites) -└── build.ts # Bun.build target=node → dist/pty-daemon.js - -packages/host-service/src/terminal/ -├── DaemonClient/ -│ ├── DaemonClient.ts # Unix-socket client w/ multi-subscriber fan-out -│ └── DaemonClient.node-test.ts # 5 integration tests under node:test -├── daemon-client-singleton.ts # lazy DaemonClient singleton -├── terminal.ts # refactored to use DaemonClient (was node-pty.spawn) -└── terminal.adoption.node-test.ts # 6 E2E tests under Electron-as-Node - -apps/desktop/src/main/ -├── lib/ -│ ├── pty-daemon-coordinator.ts # spawn/adopt; sibling of HostServiceCoordinator -│ └── pty-daemon-manifest.ts # manifest read/write helpers -└── pty-daemon/index.ts # main entry that registers in electron.vite.config.ts -``` - -Source: ~870 LOC daemon, ~270 LOC DaemonClient, ~250 LOC coordinator. -Tests: ~1100 LOC across 4 layers. - -## Plan-compliance audit - -### ✅ Correctly implemented as specified (18) - -| # | Plan decision | Verified by | -|---|---|---| -| 1 | Architecture E (daemon now, fd-handoff Phase 2 deferred) | Code structure | -| 2 | Daemon runtime: Node + node-pty | `build.ts`, `bin` field, `engines.node` | -| 3 | Daemon scope: pure PTY runtime, stateless from client perspective | No HTTP/auth/DB/business logic anywhere in `packages/pty-daemon/src` | -| 4 | Transport: AF_UNIX SOCK_STREAM + length-prefixed binary frames | `protocol/framing.ts`, `Server/Server.ts` | -| 5 | Auth: Unix socket file mode 0600 | `Server.listen()` chmod | -| 6 | In-memory ring buffer per session, ~64 KB | `SessionStore.ts` | -| 7 | All v1 anti-patterns omitted (HistoryWriter, cold restore, tombstones, EventEmitter, dedup, priority semaphore, ANSI parsing, sticky state, deferred-cleanup setTimeout) | Grep | -| 8 | Per-session snapshot on attach (pid, cols, rows, alive) | `open-ok` + `list-reply` messages | -| 9 | Resize bounds validation | `Pty.ts:validateDims` | -| 10 | Signal abstraction as strings | Protocol message types | -| 11 | Graceful shutdown ordering | `Server.close()` | -| 12 | Versioned handshake | `protocol/version.ts` + Server dispatch | -| 13 | Renderer code zero changes | No diffs in `apps/desktop/src/renderer` | -| 14 | PSK auth boundary unchanged at host-service | Hono WS upgrade unchanged | -| 15 | terminalSessions DB table unchanged; daemon never touches DB | Daemon has no `better-sqlite3` import | -| 16 | Daemon binary bundled via electron-vite alongside host-service | `electron.vite.config.ts:115` adds entry; outputs `dist/main/pty-daemon.js` | -| 17 | node-pty version pinned (1.1.0) | `package.json` | -| 18 | **Daemon outlives host-service restart and app quit; killed only on explicit restart (and dev-mode reload by HostServiceCoordinator's enableDevReload)** | `tryAdopt()` finds detached daemon at next launch; no `before-quit` hook | - -### ⚠️ Deviations from the plan (6) — DECISIONS NEEDED - -#### Deviation #1: Host-side ring buffer kept - -- **Plan:** "Move the ring buffer entirely to the daemon. host-service no longer holds replay state; it asks the daemon for replay-on-attach via `subscribe { replay: true }`." -- **What I did:** Kept the 64 KB host-side buffer (`terminal.ts:64,101-102,206-225`) for in-process fan-out to multiple WS subscribers. The daemon also has its own 64 KB buffer; that one is the cross-restart source of truth. -- **Why:** Removing the host buffer would require either (a) a separate daemon subscription per WS connection, or (b) buffer-aware replay logic that re-asks the daemon on each WS attach. Keeping the host buffer is the smallest, most behaviour-preserving change. -- **Trade-off:** Two layers of 64 KB buffers per session. Memory cost is negligible. The deviation removes one of the v1-bloat rationales (host should be stateless re PTY data plane), but only partially. -- **DECISION:** - - [ ] **A: Accept deviation** — update the plan to reflect "host-side fan-out buffer + daemon source-of-truth buffer." - - [ ] B: Revert to plan — remove host buffer, add per-WS daemon subscriptions in a follow-up. - - [ ] C: Defer to a cleanup PR; ship as-is. - -#### Deviation #2: Per-organization daemon (not per-workspace) - -- **Plan:** "Per-workspace daemon (mirrors current host-service-per-workspace)." -- **What I did:** Per-organization daemon, exactly mirroring `HostServiceCoordinator` which is keyed by `organizationId`. -- **Why:** The plan's parenthetical claim is wrong: host-service is per-organization, not per-workspace. I matched real host-service. -- **DECISION:** - - [ ] **A: Accept deviation** — fix the plan to say "Per-organization, mirroring host-service-per-organization." - - [ ] B: Revert to plan — refactor to per-workspace (no production reason to do this; would create N daemons per org). - -#### Deviation #3: Manifest `startedAt` is epoch ms, not ISO 8601 string - -- **Plan:** `startedAt: string` ISO 8601. -- **What I did:** `startedAt: number` epoch ms, matching `HostServiceManifest`. -- **DECISION:** - - [ ] **A: Accept deviation** — keep epoch ms, fix the plan. - - [ ] B: Revert to plan — switch to ISO string. (Trivial change; no real impact either way.) - -#### Deviation #4: Protocol module split into 3 files - -- **Plan:** single `protocol/protocol.ts`. -- **What I did:** `protocol/version.ts`, `protocol/messages.ts`, `protocol/framing.ts`. -- **Why:** Cleaner separation of concerns; tests only need to import what they use. -- **DECISION:** - - [ ] **A: Accept deviation** — update the plan to show three files. - - [ ] B: Revert — collapse into one file. (No real benefit; current shape is more readable.) - -#### Deviation #5: Adoption check skips protocol-version verification - -- **Plan:** "If PID alive **and socket connectable and protocol version compatible** → adopt." -- **What I did:** Adoption checks PID alive + socket connectable; **does not** connect-and-handshake to verify protocol compatibility before adopting. -- **Why:** v1 is the only protocol; pure overhead today. The check matters when Phase 2 introduces a v2 binary alongside v1 daemons. -- **DECISION:** - - [ ] **A: Accept until Phase 2 lands** — flag this in the plan as deferred. - - [ ] B: Implement now — costs ~30 LOC; trivial. Adds a connect/handshake/disconnect cycle to every adoption. - -#### Deviation #6: `subscribe` / `unsubscribe` as explicit protocol ops - -- **Plan:** ops list mentioned `subscribe-output` (one op). -- **What I did:** `subscribe` (with `replay: bool`) and `unsubscribe` as separate ops; daemon supports multi-subscriber fan-out per session. -- **DECISION:** - - [ ] **A: Accept deviation** — update the plan to show both ops. - - [ ] B: Revert — collapse to one op. (No real benefit; current shape is the minimum needed for renderer reattach + observer mode.) - -### ❗ Plan items NOT done (7) - -| # | Plan item | Status | Risk if shipped without | -|---|---|---|---| -| 1 | **Telemetry: 6 events** (`pty_daemon_spawn/adopt/session_open/session_exit/crash`, `host_service_restart_sessions_preserved`) | None wired | **Can't measure success or detect crashes.** The headline metric of the entire project is unobservable. | -| 2 | Daemon crash supervision: "3 crashes in 60s → stop respawning, surface to user" (Open Decision #3 in plan) | Not implemented; coordinator doesn't even auto-respawn after exit | Daemon crashes mid-session = silent terminal death until host-service restart | -| 3 | host-service crash integration test (real `kill -9` + verify renderer reattaches) | Adoption tested via `__resetSessionsForTesting`, not real `kill -9` | Real-world signal handling (no graceful close events) untested | -| 4 | Daemon crash integration test | Not explicitly tested | Same gap | -| 5 | Linux + macOS x86_64 Phase 0 / Phase 1 verification | Not done; macOS arm64 only | Architecture is portable but unverified — defer until shipping to those platforms | -| 6 | Daemon-disconnect → close terminal WS streams | `daemon-client-singleton.ts` clears its cache but doesn't close ws sockets to the renderer | Renderer thinks the terminal is alive; input silently fails | -| 7 | `/tmp/superset-ptyd-*.sock` sweep on coordinator init | Not done | Cosmetic; `/tmp` accumulates over time | - -**DECISION:** for each, mark **before ship** / **after ship** / **never** to set scope: - - [ ] #1 telemetry — recommended **before ship** - - [ ] #2 crash supervision — recommended **before ship** - - [ ] #3, #4 crash tests — recommended **before ship** - - [ ] #5 Linux verification — recommended **before shipping to Linux** - - [ ] #6 disconnect → close WS — recommended **before ship** - - [ ] #7 `/tmp` sweep — recommended **after ship** or **never** (cosmetic) - -### ❗ Decisions I made that weren't in the plan (5) - -| # | What I decided | Why | Plan should mention? | -|---|---|---|---| -| 1 | Socket path in `os.tmpdir()/superset-ptyd-<12hex>.sock`, not `$SUPERSET_HOME_DIR/host//pty-daemon.sock` | Darwin's 104-byte `sun_path` limit; original path was 159+ chars in dev | **Yes** — add to plan as the reason for this path | -| 2 | Adoption-on-EEXIST path in `createTerminalSessionInternal` | Race: host-service restart finds daemon already has the session id; bare `daemon.open` errors with EEXIST → tight loop until adopted | **Yes** — add as a critical post-restart code path | -| 3 | `__resetSessionsForTesting()` test escape hatch exported from production `terminal.ts` | Needed for in-process e2e testing of the adoption path | **Maybe** — note the test-only contract | -| 4 | Daemon's `handleOpen` recycles already-`exited` session entries (drops dead entry, spawns fresh); live entries still EEXIST | Without this, dispose-then-recreate-with-same-id loops forever — late-subscriber replay needs the entry to stick around after exit, but a fresh `open` should not see it as a collision | **Yes** — small protocol semantic to document | -| 5 | Initial-command suppression on adoption (`initialCommandQueued: isAdopted`) | Without this, setup.sh would re-run on every host-service restart for setup terminals | **Yes** — document | - -**DECISION:** for each `Yes`, I'll update the plan if you accept. - -### ❌ Wrong assertion I made earlier - -In the prior "shippability" assessment I said: - -> "App-quit lifecycle for the daemon ⚠️ — should fix before ship: daemon should be killed when user quits app." - -This is **wrong**. The plan and `HOST_SERVICE_LIFECYCLE.md` specify the daemon **outlives app quit** (manifest-based adoption picks it up next launch — same model as host-service). Only `enableDevReload` in `HostServiceCoordinator` tears down running services for hot-reload during dev. - -Action: do **not** add a `before-quit` hook; the current behavior is correct. - -## Five-question summary you can answer in one pass - -| # | Question | Recommendation | -|---|---|---| -| 1 | Accept all 6 plan deviations? (host-side buffer, per-org daemon, manifest format, protocol split, adoption proto-version skip, subscribe/unsubscribe ops) | **Yes**, update plan | -| 2 | Wire telemetry before ship? | **Yes** (~50 LOC, the project's headline metric is currently unobservable) | -| 3 | Wire daemon crash supervision before ship? | **Yes** (~80 LOC, agreed crash policy isn't actually implemented) | -| 4 | Wire daemon-disconnect → close WS streams before ship? | **Yes** (~30 LOC, otherwise silent terminal failure) | -| 5 | Add real `kill -9` integration tests? | **Yes** (~40 LOC of test code) | - -If you say yes to all five: ~200 LOC of additional production code + ~100 LOC of tests. Roughly half a day. Then this is genuinely shippable to users. - -## What's currently in PR #3896 - -7 commits on `pty-daemon-host-integration`: - -1. `9bdbf7b85` feat(host-service): DaemonClient — Unix-socket client for pty-daemon -2. `b1eb105f0` feat(desktop): pty-daemon coordinator + manifest + main entry -3. `401e203fe` feat(host-service): route terminal sessions through pty-daemon -4. `b387324e1` fix(desktop): make pty-daemon spawn failure non-fatal for host-service -5. `df81d8b15` fix(desktop): allow .env / shell to provide SUPERSET_PTY_DAEMON_SOCKET -6. `2e8d2167e` debug(desktop): surface daemon spawn failures with log tail + child exit code -7. `05ae50c20` fix(desktop): use short /tmp path for pty-daemon socket (Darwin sun_path) -8. `aae131eb3` fix(host-service): adopt existing daemon sessions on host-service restart -9. `2bbb0846c` test(pty-daemon): replay-on-exited-session edge case -10. `525d3ec94` test(host-service): full E2E adoption test under Electron-as-Node -11. `a6f09d36a` fix(pty-daemon) + test(host-service): three more edge cases - -## Status flag - -Once you've made the five decisions above, this report becomes -**signed-off** and I update the implementation plan to match the -final accepted state. diff --git a/apps/desktop/plans/done/20260501-pty-daemon-phase2-implementation.md b/apps/desktop/plans/done/20260501-pty-daemon-phase2-implementation.md deleted file mode 100644 index 44f0d9367e3..00000000000 --- a/apps/desktop/plans/done/20260501-pty-daemon-phase2-implementation.md +++ /dev/null @@ -1,319 +0,0 @@ -# Phase 2 Implementation Plan — Daemon-Upgrade FD-Handoff - -**Date:** 2026-05-01 -**Status:** ready to build -**Companion docs:** -- `20260501-pty-daemon-phase2-audit.md` (current-state audit) -- `20260430-pty-daemon-host-service-migration.md` (Phase 1, shipped) -- `pty-handoff-experiment/` (Phase 0 harness — primitives validated) - -## Goal - -Preserve PTY sessions across daemon-binary upgrades. Today, "Restart and update" SIGTERMs the daemon and all shells die in the gap. After Phase 2, the new daemon binary takes over via fd inheritance — sessions stay alive, the user sees no flicker. - -## Decisions (from /decide walkthrough on 2026-05-01) - -| # | Decision | Choice | Implication | -|---|---|---|---| -| 1 | What new daemon inherits | **PTY master fds only** (listener inheritance dropped after spike) | Old daemon unlinks + exits, new daemon binds fresh. host-service's daemon-client sees a brief disconnect and reconnects via existing retry logic. **Sessions stay alive — that's what fd inheritance is actually for.** | -| 2 | Old daemon exit timing | Ack-then-exit | Spawn successor → wait for `upgrade-ack` over control fd → exit. If ack fails, old daemon stays alive | -| 3 | Session metadata transport | Snapshot file + manifest pointer | Old daemon writes `pty-daemon-handoff-snapshot.json`, manifest points at it, new daemon reads + clears | -| 4 | Restart UX | Handoff is default ("Update"); "Force restart" stays as opt-in | New mutation `terminal.daemon.update()`; existing `restart()` keeps kill+respawn semantics | -| 5 | Handoff failure mode | Surface to user with dialog | On failure, renderer offers "Force update" (= old `restart()`) or "Cancel" | -| 6 | Handoff protocol | Separate control-fd protocol; client wire stays at v1 | New file `protocol/handoff.ts`; `messages.ts` and `version.ts` untouched | - -### D1 was revised after the spike - -Originally D1 said "PTY fds + listener fd". The spike at -`pty-handoff-experiment/listener-handoff/` proved listener inheritance works -but exposed a foot-gun (must skip `server.close()` to keep the socket path -linked). The user pushed back: host-service is the *only* client of the -daemon socket and already has reconnect logic for the host-service-restart -adoption path. Adding listener inheritance just to avoid a ~100ms reconnect -blip wasn't worth the moving parts. **The PTY master fds are the only thing -that genuinely cannot blink — those ARE the live shells.** - -## Architecture sketch - -The supervisor does NOT participate in the fd transfer. The daemon spawns its -own successor, hands over PTY fds via stdio inheritance, exits. Supervisor's -existing adopted-liveness path discovers and adopts the new daemon via the -manifest — same code path as Phase 1's "host-service restart" adoption. - -``` -┌─ supervisor (host-service) ───────────────────────────────────────┐ -│ │ -│ update(orgId) │ -│ │ ① wire-message "prepareUpgrade" to running daemon │ -│ ▼ │ -│ ┌─ daemon A (running) ──────────────────────────────────────┐ │ -│ │ ② write snapshot.json │ │ -│ │ ③ update manifest: { handoffInProgress: true, │ │ -│ │ handoffSnapshotPath, ... } │ │ -│ │ ④ spawn daemon B with stdio: │ │ -│ │ fd 0: ignore │ │ -│ │ fd 1,2: log fds │ │ -│ │ fd 3..N: PTY master[0..N-3] ← inherited │ │ -│ │ fd N+1: control fd (socketpair) │ │ -│ │ plus env: SUPERSET_PTY_DAEMON_HANDOFF=1 │ │ -│ │ SUPERSET_PTY_DAEMON_SNAPSHOT= │ │ -│ │ SUPERSET_PTY_DAEMON_SOCKET= │ │ -│ └────────────────────┬──────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌─ daemon B (successor) ────────────────────────────────────┐ │ -│ │ ⑤ read snapshot, adopt PTY master fds │ │ -│ │ ⑥ write upgrade-ack on control fd │ │ -│ │ ⑦ wait for socket path to be unbindable-then-bindable │ │ -│ │ (poll bind() with retry — succeeds once A unlinked) │ │ -│ │ ⑧ update manifest: { pid: B.pid, handoffInProgress: false }│ │ -│ └────────────────────┬──────────────────────────────────────┘ │ -│ │ │ -│ ┌─ daemon A ─────────▼──────────────────────────────────────┐ │ -│ │ ⑨ ack received → server.close() (unlinks socket path) │ │ -│ │ ⑩ exit(0) │ │ -│ └────────────────────┬──────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ⑪ supervisor's adopted-liveness check sees A's pid dead. │ -│ Re-reads manifest, finds B's pid, updates instances map. │ -│ ⑫ host-service daemon-client reconnects (existing retry logic). │ -└────────────────────────────────────────────────────────────────────┘ - -Failure: if step ⑥ ack times out (default 5s), supervisor SIGKILLs B, -restores manifest (clear handoffInProgress, snapshot stays for cleanup), -and leaves A running. Returns { ok: false, reason }. -``` - -## Sequencing — concrete code changes - -### Step 1: expose `_fd` from Pty.ts (foundation) - -**Files:** `packages/pty-daemon/src/Pty/Pty.ts` - -- Add `getMasterFd(): number` to the `Pty` interface. -- Implement on `NodePtyAdapter`: `return (this.term as unknown as { _fd: number })._fd`. -- Add startup assert in `main.ts`: confirm `_fd` is a number; fail loudly with a clear node-pty version message if not. -- Pin `node-pty` to `1.1.x` in `packages/pty-daemon/package.json` (no caret). -- Test: `Pty.test.ts` — spawn, assert `getMasterFd()` returns a positive integer, confirm fcntl reports it open. - -### Step 2: build the handoff control-fd protocol - -**Files:** `packages/pty-daemon/src/protocol/handoff.ts` (new) - -Tiny dedicated wire format, length-prefixed JSON frames over fd 7 (matches existing `framing.ts` format so we can reuse `encodeFrame`/`FrameDecoder`). - -```ts -export type HandoffMessage = - | { type: "upgrade-init"; snapshotPath: string; sessionFds: SessionFdMapping[] } - | { type: "upgrade-ack"; ok: true } - | { type: "upgrade-nak"; ok: false; reason: string }; - -export interface SessionFdMapping { - sessionId: string; - pid: number; - fdIndex: number; // index into the inherited stdio fds -} -``` - -- Reuse `encodeFrame`/`FrameDecoder` from `framing.ts`. -- No changes to `protocol/messages.ts` or `protocol/version.ts`. -- Test: `handoff.test.ts` — round-trip encode/decode each message variant. - -### Step 3: snapshot writer + reader - -**Files:** `packages/pty-daemon/src/SessionStore/snapshot.ts` (new) - -```ts -interface HandoffSnapshot { - version: 1; - writtenAt: number; - sessions: SerializedSession[]; -} - -interface SerializedSession { - sessionId: string; - pid: number; - meta: SessionMeta; // existing protocol type - lastSeq: number; - ringBuffer: string; // base64 - exited: false; // exited sessions are filtered out - // Note: subscribers are NOT serialized — clients reconnect after handoff. -} - -export function writeSnapshot(path: string, sessions: Session[]): void; -export function readSnapshot(path: string): HandoffSnapshot; -export function clearSnapshot(path: string): void; -``` - -- Atomic write: write to `path + ".tmp"` then `rename`. -- `clearSnapshot` is `unlink` swallowing ENOENT. -- Test: `snapshot.test.ts` — round-trip a populated SessionStore through write+read. - -### Step 4: extend manifest schema - -**Files:** `packages/host-service/src/daemon/manifest.ts` - -Add optional fields (forward-compatible — old code ignores extras): - -```ts -export interface PtyDaemonManifest { - pid: number; - socketPath: string; - protocolVersions: number[]; - startedAt: number; - organizationId: string; - // Phase 2 additions (all optional, present only during handoff) - handoffInProgress?: boolean; - handoffSnapshotPath?: string; - handoffSuccessorPid?: number; -} -``` - -- Update `readPtyDaemonManifest` to type-check the new fields leniently. -- Test: `manifest.test.ts` — read-back of manifest with handoff fields, parse-tolerance to extra fields. - -### Step 5: daemon-side handoff (sender) - -**Files:** `packages/pty-daemon/src/Server/Server.ts`, `packages/pty-daemon/src/handlers/handlers.ts` - -Add a wire handler for the new `prepareUpgrade` message (received from supervisor over the existing daemon-client socket). - -In `Server`: -- Add `prepareUpgrade(newBinaryPath: string)`: - 1. Suspend `onData` event handlers; buffer bytes to ring. - 2. Gather session state into a `HandoffSnapshot` (Step 3) and write to disk. - 3. Update manifest: `{ handoffInProgress: true, handoffSnapshotPath }`. - 4. Build the stdio array: `["ignore", logFd, logFd, ...ptyMasterFds, controlSockOurEnd]`. - - Use `node:net` `socketpair` shim or `node:dgram`'s socket pair... actually, Node has no public socketpair API. Workaround: open a temporary AF_UNIX listener on a side-path (like the spike did), child connects to it. Or use `child_process.fork`'s built-in IPC channel (`stdio: 'ipc'`) — that gives us a duplex JSON channel for free. - 5. Spawn successor with `process.execPath` + `[newBinaryPath]` and env `SUPERSET_PTY_DAEMON_HANDOFF=1`, `SUPERSET_PTY_DAEMON_SNAPSHOT=`, `SUPERSET_PTY_DAEMON_SOCKET=`. - 6. Wait for `upgrade-ack` from successor (5s timeout). - 7. On ack: reply to supervisor with `upgrade-ok { successorPid }`. Then `server.close()` (unlinks socket path) and `process.exit(0)`. Successor is waiting to bind. - 8. On nak/timeout: SIGKILL successor, restore manifest, resume `onData` handlers, reply `upgrade-failed { reason }`. - -### Step 5b: daemon-side handoff (receiver) - -**Files:** `packages/pty-daemon/src/main.ts` - -When env `SUPERSET_PTY_DAEMON_HANDOFF=1`: -1. Don't call `server.listen()` yet. The old daemon still owns the socket path. -2. Read snapshot from `SUPERSET_PTY_DAEMON_SNAPSHOT`. -3. For each session in the snapshot, call `Pty.adoptFromFd(...)` with the inherited fd index. -4. Open the IPC control channel (Node's built-in if we used `stdio: 'ipc'`, otherwise our temp side-socket). -5. Send `upgrade-ack { pid: process.pid }` on the control channel. -6. Now bind the socket. Loop with retry: `try server.listen(socketPath); catch EADDRINUSE: wait 50ms, retry. timeout 5s.` -7. Once bound: update manifest to point at our pid, clear `handoffInProgress`. Begin normal operation. -8. From here, behaves exactly like a normal pty-daemon: any old wire connections (host-service was disconnected during the unbind window) reconnect. - -### Step 6: PTY adoption from inherited fd - -**Files:** `packages/pty-daemon/src/Pty/Pty.ts` - -This is the gnarly part. node-pty's normal `spawn()` creates a new PTY pair; we need a constructor that takes an *existing* master fd and rebuilds the IPty-like surface. - -Implementation: -- Add `adoptFromFd(fd: number, pid: number, meta: SessionMeta): Pty`. -- It can't reuse `nodePty.spawn` — instead, build a minimal adapter directly on the fd: - - Use `fs.createReadStream(null, { fd })` for `onData`. - - Use `fs.createWriteStream(null, { fd })` for `write`. - - For `resize`: use `koffi` or a tiny native helper to call `ioctl(fd, TIOCSWINSZ, ...)`. **Or** skip resize support during the handoff window and accept that — most users don't resize within the millisecond handoff. (Defer this; document as a known gap.) - - For `kill`: `process.kill(pid, signal)` — same as today's NodePtyAdapter delegates to. - - For `onExit`: SIGCHLD handling? Or just rely on read-stream-end (the kernel closes the master when the child exits)? **Phase 0 harness used the latter — the readable stream emits "end" when the slave side closes.** -- Test: `Pty.test.ts` adds an adoption case — spawn → get fd → simulate handoff by calling `adoptFromFd` with the same fd → confirm bidirectional IO still works. - -### Step 7: supervisor `update()` method - -**Files:** `packages/host-service/src/daemon/DaemonSupervisor.ts` - -```ts -async update(organizationId: string): Promise; - -interface UpdateResult { - ok: boolean; - successorPid?: number; - reason?: string; -} -``` - -Supervisor's job is now narrow — it doesn't touch any fds. It just: - -1. Mark `handoffInProgress` set entry for orgId (prevents `pendingStarts`/crash-respawn races during the handoff window). -2. Send a wire-protocol `prepareUpgrade` message to daemon A over the existing daemon-client socket. Include the path of the new daemon binary. -3. Wait for daemon A to either reply `upgrade-ok { successorPid }` or `upgrade-failed { reason }`. Timeout: 10s. -4. On `upgrade-ok`: nothing more to do. The adopted-liveness loop will detect daemon A's exit and re-adopt via the manifest. Clear `handoffInProgress`. Return `{ ok: true, successorPid }`. -5. On `upgrade-failed` or timeout: clear `handoffInProgress`, return `{ ok: false, reason }`. Daemon A is still alive; sessions preserved. - -**The fd handoff itself happens entirely inside the daemon process**, not in the supervisor. See Step 5b. - -### Step 8: tRPC `terminal.daemon.update` - -**Files:** `packages/host-service/src/trpc/router/terminal/terminal.ts` - -```ts -update: protectedProcedure.mutation(async () => { - await waitForDaemonReady(env.ORGANIZATION_ID); - return getSupervisor().update(env.ORGANIZATION_ID); -}), -``` - -Existing `restart()` stays untouched. - -### Step 9: renderer Settings UI - -**Files:** desktop app's V2SessionsSection (or wherever Manage daemon lives — confirm path before editing) - -- Primary button: "Update" → calls `terminal.daemon.update.mutate()`. -- On `{ ok: true }`: show success toast, refresh listSessions. -- On `{ ok: false, reason }`: show dialog "Update couldn't preserve sessions: {reason}. Force update (closes terminals) or cancel?" — Force update calls existing `terminal.daemon.restart.mutate()`. -- Secondary button (always visible, less prominent): "Force restart" → calls `terminal.daemon.restart.mutate()` directly. - -### Step 10: tests - -- **Unit:** `protocol/handoff.test.ts`, `SessionStore/snapshot.test.ts`, `Pty.test.ts` (adoption case), `manifest.test.ts` (handoff fields). -- **Daemon integration:** `Server.handoff.node-test.ts` — spawn daemon A with N sessions, trigger handoff to daemon B (real binary), assert sessions survive and bytes are continuous (counter workload, like Phase 0). -- **Supervisor integration:** `DaemonSupervisor.handoff.node-test.ts` — call `update()`, confirm new pid is in instances, old daemon exited cleanly, sessions still listed. -- **tRPC:** `terminal.daemon.test.ts` — wire `update` mutation; mock supervisor and assert delegation. -- **Phase 0 harness extension:** run the existing harness on Linux x86_64 + high-N (1000) before merge. - -## Open implementation questions - -1. **~~Listening socket fd ownership~~ — RESOLVED by spike on 2026-05-01.** D1 was revised to drop listener inheritance entirely. New daemon binds fresh after old daemon unlinks. host-service's daemon-client reconnect handles the brief disconnect window. - -2. **PTY exit detection on adopted fd.** Current `node-pty` uses libuv to wait on the child via waitpid; the adopted-fd path won't have access to the child handle. Need to confirm: when shell child exits, does the master fd's read stream emit 'end'? (Phase 0 harness behavior suggests yes, but verify in Node specifically before Step 6.) - -3. **Resize on adopted fd.** TIOCSWINSZ ioctl from Node — `koffi` is the lightweight option. Alternative: ship a small native module. Or accept "no resize during handoff window" since the window is sub-second. - -4. **Control fd between old + new daemon.** Phase 0 nodepty harness used `child_process.spawn` with stdio[3]=raw fd. The cleaner Node option is `stdio: 'ipc'` which gives a built-in JSON duplex channel via `process.send()` / `'message'` events. Pick one before Step 5. - -## Risks / mitigations - -| Risk | Mitigation | -|---|---| -| node-pty `_fd` access breaks in a future bump | Pin to 1.1.x, startup assert | -| Listener fd transfer doesn't work via `process.send` | Spike before committing; fall back to side-channel | -| Adopted fd's onExit doesn't fire | Test in Step 6; if it doesn't, add SIGCHLD reaper at daemon level | -| Both daemons live for a moment — does macOS allow two binds to the same socket? | No — but in our flow only one daemon owns the listener fd at a time (supervisor takes ownership during the gap) | -| Snapshot file orphaned if successor crashes | Supervisor cleans on failure; daemon cleans on successful adopt | - -## Sequencing for actual commits - -Suggested commit order (each commit should pass tests): - -1. `feat(pty-daemon): expose master fd from Pty adapter` (Step 1) -2. `feat(pty-daemon): handoff protocol + snapshot encoder` (Steps 2, 3) -3. `feat(host-service): manifest fields for handoff state` (Step 4) -4. `feat(pty-daemon): adopt PTY sessions from inherited master fd` (Step 6, scoped before Step 5 because Step 5 depends on it) -5. `feat(pty-daemon): Server prepareHandoff / adoptHandoff` (Step 5) -6. `feat(host-service): DaemonSupervisor.update() with fd handoff` (Step 7) -7. `feat(host-service): terminal.daemon.update tRPC procedure` (Step 8) -8. `feat(desktop): wire Update button to handoff flow` (Step 9) -9. `test: handoff integration coverage` (parts of Step 10 not covered above) - -Bump `EXPECTED_DAEMON_VERSION` and `packages/pty-daemon/package.json#version` to e.g. `0.2.0` once Step 5 lands so the renderer can show the "update available" badge against pre-handoff daemons. - -## Out of scope for this PR - -- Linux + high-N + SIGKILL stress validation of Phase 0 harness — should be run, but lives in the design-doc branch. -- Cross-platform: Windows is unaddressed; macOS x86_64 untested. -- Telemetry promotion — current `console.log` JSON lines stay; PostHog plumbing tracked separately. -- Mid-handoff resize support (deferred per Step 6 note). 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..748ccaa887f 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 @@ -1,10 +1,10 @@ -import { useNavigate } from "@tanstack/react-router"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useDiffStats } from "renderer/hooks/host-service/useDiffStats"; import { useOptimisticCollectionActions } from "renderer/routes/_authenticated/hooks/useOptimisticCollectionActions"; 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 +69,6 @@ export function DashboardSidebarWorkspaceItem({ isMainWorkspace, }); - const navigate = useNavigate(); const { v2Workspaces: v2WorkspaceActions } = useOptimisticCollectionActions(); const [renameBranchTarget, setRenameBranchTarget] = useState( null, @@ -78,16 +77,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 +140,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 +219,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..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 @@ -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,26 @@ 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 ?? "New workspace", + branchName: + entry.snapshot.branch ?? entry.snapshot.name ?? "New workspace", + status: + entry.state === "creating" + ? ("creating" as const) + : ("failed" as const), + })), + [inFlightEntries], ); const { data: hosts = [] } = useLiveQuery( @@ -453,9 +463,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 +540,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 fd880a4ab2e..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildIntentPayload.test.ts +++ /dev/null @@ -1,330 +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", - headRefOid: "c4ecea7dec8c6d09cf54fe0ad2f9edb8a24fd45a", - baseBranch: "main", - headRepositoryOwner: "kietho", - headRepositoryName: "r", - isCrossRepository: true, - isDraft: false, - 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", - headRefOid: "c4ecea7dec8c6d09cf54fe0ad2f9edb8a24fd45a", - baseRefName: "main", - headRepositoryOwner: "kietho", - headRepositoryName: "r", - isCrossRepository: true, - isDraft: false, - 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("allows cross-repo PR with deleted fork so host-service can recover from refs/pull", () => { - const pending = makePending({ intent: "pr-checkout" }); - const payload = buildPrCheckoutPayload("pid", pending, { - ...prContent, - headRepositoryOwner: null, - isCrossRepository: true, - }); - expect(payload.pr?.headRepositoryOwner).toBe(""); - expect(payload.pr?.isCrossRepository).toBe(true); - }); - - 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 d38fe856543..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildIntentPayload.ts +++ /dev/null @@ -1,159 +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 - headRefOid: string; - baseBranch: string; // baseRefName - headRepositoryOwner: string | null; - headRepositoryName?: string | null; - isCrossRepository: boolean; - isDraft?: boolean; - state: string; - }, -): CheckoutWorkspaceInput { - 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, - headRefOid: prContent.headRefOid, - 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 ?? "", - headRepositoryName: prContent.headRepositoryName ?? null, - isCrossRepository: prContent.isCrossRepository, - isDraft: prContent.isDraft ?? false, - 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..21c0cd2122c --- /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 &&

{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..5f96d5b09f3 --- /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 &&

{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..39c0cdf92cc 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")( @@ -68,7 +67,7 @@ function V2WorkspaceLayout() { } 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..7d1af6e7359 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,84 @@ -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, + })), + ); + 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..51f3204e199 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,18 @@ 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 { useNewWorkspacePromptContext } from "renderer/stores/new-workspace-prompt-context"; 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 +42,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 +75,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 +89,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,14 +103,13 @@ export function PromptGroup({ }, [closeModal, navigate, selectedProject?.id]); const { baseBranch, - hostTarget, + hostId, prompt, workspaceName, branchName, branchNameEdited, linkedIssues, linkedPR, - friendlyFallback, } = draft; // ── Agent presets ──────────────────────────────────────────────── @@ -123,33 +130,31 @@ export function PromptGroup({ const branchPreview = branchNameEdited ? sanitizeUserBranchName(branchName) - : friendlyFallback; + : ""; // 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,83 @@ 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]); + + // ── Linked-context prefetch ────────────────────────────────────── + const promptContext = useNewWorkspacePromptContext({ + projectId, + hostId, + linkedPR, + linkedIssues, + }); + // ── 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, + promptContext, ); + 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 +306,13 @@ export function PromptGroup({ {/* Prompt input */} - {(linkedPR || - linkedIssues.length > 0 || - attachments.files.length > 0) && ( + {(linkedPR || linkedIssues.length > 0 || visibleFiles.length > 0) && (
{linkedPR && ( @@ -316,9 +358,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 +421,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); @@ -217,7 +215,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 4a30be92ef0..b474d420d94 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("all"); @@ -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/resolveNames.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useSubmitWorkspace/resolveNames.ts index 05c4222f9b7..24c04582adb 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useSubmitWorkspace/resolveNames.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useSubmitWorkspace/resolveNames.ts @@ -2,42 +2,27 @@ import { sanitizeUserBranchName } from "@superset/shared/workspace-launch"; import type { DashboardNewWorkspaceDraft } from "../../../../../DashboardNewWorkspaceDraftContext"; interface ResolvedNames { - branchName: string; - workspaceName: string; - /** - * True when `workspaceName` came from the friendly-random fallback rather - * than a value the user typed. Host-service only runs the post-create AI - * rename when this is true — a user-typed name wins. - */ - workspaceNameWasAutoGenerated: boolean; + /** User-typed (sanitized) branch, or null when not typed. */ + branchName: string | null; + /** User-typed workspace name, or null when not typed. */ + workspaceName: string | null; } /** - * Resolves the branch name and workspace display name from draft state. - * Pure function — no side effects, no hooks. - * - * Priority: - * - Branch: user-typed (sanitized) > draft's friendly random - * - Workspace: user-typed > draft's friendly random - * - * Prompt-based derivation is intentionally not used here — AI naming runs - * post-create in host-service for the workspace title. The friendly name - * lives on the draft so the picker preview matches what gets submitted. + * Returns whatever the user typed; null otherwise. The host-service + * generates a friendly random for the missing side and runs the AI + * rename for any side that wasn't user-supplied. */ export function resolveNames(draft: DashboardNewWorkspaceDraft): ResolvedNames { const branchName = draft.branchNameEdited && draft.branchName.trim() ? sanitizeUserBranchName(draft.branchName.trim()) - : draft.friendlyFallback; + : null; - const userWorkspaceName = + const workspaceName = draft.workspaceNameEdited && draft.workspaceName.trim() ? draft.workspaceName.trim() : null; - return { - branchName, - workspaceName: userWorkspaceName ?? draft.friendlyFallback, - workspaceNameWasAutoGenerated: userWorkspaceName === null, - }; + return { branchName, workspaceName }; } 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..1f19a81af2a 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,130 @@ 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 type { NewWorkspacePromptContextApi } from "renderer/stores/new-workspace-prompt-context"; +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, + promptContext: NewWorkspacePromptContextApi, ) { 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; + } + + const hostId = draft.hostId ?? machineId; + if (!hostId) { + toast.error("No active host"); + return; + } - return useCallback( - async (files: SubmitAttachment[] = []) => { - if (!projectId) { - toast.error("Select a project first"); - return; - } + 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; + } - const { branchName, workspaceName, workspaceNameWasAutoGenerated } = - resolveNames(draft); - const pendingId = crypto.randomUUID(); + const { branchName, workspaceName } = resolveNames(draft); - // 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 isPrCheckout = draft.linkedPR !== null; - if (files.length > 0) { - try { - await storeAttachments(pendingId, files); - } catch (err) { - toast.error( - err instanceof Error ? err.message : "Failed to store attachments", - ); - return; - } - } + const linkedTaskId = draft.linkedIssues.find( + (issue) => issue.source === "internal" && issue.taskId, + )?.taskId; - 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 hasAnyContext = + !!draft.prompt.trim() || + draft.linkedPR !== null || + draft.linkedIssues.length > 0 || + attachmentIds.length > 0; + const wantAgent = selectedAgent !== "none" && hasAnyContext; - closeAndResetDraft(); - void navigate({ to: `/pending/${pendingId}` as string }); - }, - [ - closeAndResetDraft, - collections, - draft, - navigate, + const finalPrompt = wantAgent + ? await promptContext.build({ + userPrompt: draft.prompt, + linkedPR: draft.linkedPR, + linkedIssues: draft.linkedIssues, + timeoutMs: 2000, + }) + : null; + + const agents = wantAgent + ? [ + { + agent: selectedAgent, + prompt: finalPrompt ?? "", + attachmentIds: attachmentIds.length > 0 ? attachmentIds : undefined, + }, + ] + : undefined; + + // PR path supplies a name (PR title) so the in-flight UI has + // something to show immediately. Branch path leaves both `name` + // and `branch` undefined when the user didn't type — the server + // generates a friendly random and AI-renames whichever side(s) + // the user didn't supply. + const prName = isPrCheckout + ? draft.linkedPR?.title || `PR #${draft.linkedPR?.prNumber}` + : undefined; + + const workspaceId = crypto.randomUUID(); + const snapshot = { + id: workspaceId, projectId, - selectedAgent, - ], - ); + name: isPrCheckout ? prName : (workspaceName ?? undefined), + branch: isPrCheckout ? undefined : (branchName ?? undefined), + pr: isPrCheckout ? draft.linkedPR?.prNumber : undefined, + baseBranch: draft.baseBranch ?? undefined, + taskId: linkedTaskId, + agents, + }; + + closeAndResetDraft(); + void navigate({ to: `/v2-workspace/${workspaceId}` as string }); + void submit({ hostId, snapshot }); + }, [ + activeOrganizationId, + closeAndResetDraft, + draft, + machineId, + navigate, + projectId, + promptContext, + 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 69b8e552939..2f84910c8f2 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 = "all", ) { - 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 60cf63f1ffe..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useCheckoutDashboardWorkspace/useCheckoutDashboardWorkspace.ts +++ /dev/null @@ -1,90 +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; - headRefOid: string; - baseRefName: string; - headRepositoryOwner: string; - headRepositoryName?: string | null; - isCrossRepository: boolean; - isDraft?: 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..72a0c52ed52 --- /dev/null +++ b/apps/desktop/src/renderer/stores/new-workspace-draft.ts @@ -0,0 +1,108 @@ +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[]; +} + +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: [], + }; +} + +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/new-workspace-prompt-context/buildSubmitPrompt.ts b/apps/desktop/src/renderer/stores/new-workspace-prompt-context/buildSubmitPrompt.ts new file mode 100644 index 00000000000..f2cd2974698 --- /dev/null +++ b/apps/desktop/src/renderer/stores/new-workspace-prompt-context/buildSubmitPrompt.ts @@ -0,0 +1,52 @@ +import type { + LinkedIssue, + LinkedPR, +} from "renderer/stores/new-workspace-draft"; +import { useNewWorkspacePromptContextStore } from "./store"; + +export interface BuildSubmitPromptArgs { + userPrompt: string; + linkedPR: LinkedPR | null; + linkedIssues: LinkedIssue[]; +} + +function readBody(key: string): string | null { + const entry = useNewWorkspacePromptContextStore.getState().entries.get(key); + if (entry?.state === "ready") return entry.body.text; + return null; +} + +export function buildSubmitPrompt(args: BuildSubmitPromptArgs): string { + const linkedSections: string[] = []; + + for (const issue of args.linkedIssues) { + if (issue.source !== "internal" || !issue.taskId) continue; + const body = readBody(`task:${issue.taskId}`); + const header = `## Linked task — ${issue.slug}: ${issue.title}`; + linkedSections.push(body ? `${header}\n${body}` : header); + } + + for (const issue of args.linkedIssues) { + if (issue.source !== "github" || issue.number == null) continue; + const body = readBody(`github-issue:${issue.number}`); + const headerLines = [ + `## Linked GitHub issue — #${issue.number}: ${issue.title}`, + ]; + if (issue.url) headerLines.push(issue.url); + const header = headerLines.join("\n"); + linkedSections.push(body ? `${header}\n\n${body}` : header); + } + + if (args.linkedPR) { + const body = readBody(`pr:${args.linkedPR.prNumber}`); + const header = `## Linked PR — #${args.linkedPR.prNumber}: ${args.linkedPR.title}\n${args.linkedPR.url}`; + linkedSections.push(body ? `${header}\n\n${body}` : header); + } + + if (linkedSections.length === 0) return args.userPrompt; + const trimmedUserPrompt = args.userPrompt.trim(); + const parts = trimmedUserPrompt + ? [trimmedUserPrompt, ...linkedSections] + : linkedSections; + return parts.join("\n\n"); +} diff --git a/apps/desktop/src/renderer/stores/new-workspace-prompt-context/fetchers.ts b/apps/desktop/src/renderer/stores/new-workspace-prompt-context/fetchers.ts new file mode 100644 index 00000000000..7c80f9e3bc9 --- /dev/null +++ b/apps/desktop/src/renderer/stores/new-workspace-prompt-context/fetchers.ts @@ -0,0 +1,57 @@ +import { apiTrpcClient } from "renderer/lib/api-trpc-client"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import type { PromptContextBody } from "./store"; + +export async function fetchPrBody(args: { + prNumber: number; + projectId: string; + hostUrl: string; +}): Promise { + try { + const client = getHostServiceClientByUrl(args.hostUrl); + const result = await client.pullRequests.getContent.query({ + projectId: args.projectId, + prNumber: args.prNumber, + }); + const text = (result.body ?? "").trim(); + return text ? { text } : null; + } catch (err) { + console.error("[promptContext] fetchPrBody failed", { args, err }); + return null; + } +} + +export async function fetchGitHubIssueBody(args: { + issueNumber: number; + projectId: string; + hostUrl: string; +}): Promise { + try { + const client = getHostServiceClientByUrl(args.hostUrl); + const result = await client.issues.getContent.query({ + projectId: args.projectId, + issueNumber: args.issueNumber, + }); + const text = (result.body ?? "").trim(); + return text ? { text } : null; + } catch (err) { + console.error("[promptContext] fetchGitHubIssueBody failed", { args, err }); + return null; + } +} + +export async function fetchInternalTaskBody(args: { + taskId: string; +}): Promise { + try { + const result = await apiTrpcClient.task.byId.query(args.taskId); + const text = (result?.description ?? "").trim(); + return text ? { text } : null; + } catch (err) { + console.error("[promptContext] fetchInternalTaskBody failed", { + args, + err, + }); + return null; + } +} diff --git a/apps/desktop/src/renderer/stores/new-workspace-prompt-context/index.ts b/apps/desktop/src/renderer/stores/new-workspace-prompt-context/index.ts new file mode 100644 index 00000000000..c52eaff7848 --- /dev/null +++ b/apps/desktop/src/renderer/stores/new-workspace-prompt-context/index.ts @@ -0,0 +1,5 @@ +export { buildSubmitPrompt } from "./buildSubmitPrompt"; +export { + type NewWorkspacePromptContextApi, + useNewWorkspacePromptContext, +} from "./useNewWorkspacePromptContext"; diff --git a/apps/desktop/src/renderer/stores/new-workspace-prompt-context/store.ts b/apps/desktop/src/renderer/stores/new-workspace-prompt-context/store.ts new file mode 100644 index 00000000000..febca6c32d2 --- /dev/null +++ b/apps/desktop/src/renderer/stores/new-workspace-prompt-context/store.ts @@ -0,0 +1,64 @@ +import { create } from "zustand"; + +export type PromptContextBody = { text: string }; + +export type PromptContextEntry = + | { state: "loading"; promise: Promise } + | { state: "ready"; body: PromptContextBody } + | { state: "failed" }; + +interface PromptContextState { + entries: Map; + register: ( + key: string, + fetcher: () => Promise, + ) => void; + awaitPending: (timeoutMs: number) => Promise; +} + +export const useNewWorkspacePromptContextStore = create( + (set, get) => { + const setEntry = (key: string, entry: PromptContextEntry) => { + set((state) => { + const next = new Map(state.entries); + next.set(key, entry); + return { entries: next }; + }); + }; + return { + entries: new Map(), + register: (key, fetcher) => { + if (get().entries.has(key)) return; + const promise = fetcher().then( + (body) => { + if (!body) { + setEntry(key, { state: "failed" }); + return null; + } + setEntry(key, { state: "ready", body }); + return body; + }, + () => { + setEntry(key, { state: "failed" }); + return null; + }, + ); + setEntry(key, { state: "loading", promise }); + }, + awaitPending: async (timeoutMs) => { + const pending: Promise[] = []; + for (const entry of get().entries.values()) { + if (entry.state === "loading") pending.push(entry.promise); + } + if (pending.length === 0) return; + const timeout = new Promise((resolve) => + setTimeout(resolve, timeoutMs), + ); + await Promise.race([ + Promise.allSettled(pending).then(() => undefined), + timeout, + ]); + }, + }; + }, +); diff --git a/apps/desktop/src/renderer/stores/new-workspace-prompt-context/useNewWorkspacePromptContext.ts b/apps/desktop/src/renderer/stores/new-workspace-prompt-context/useNewWorkspacePromptContext.ts new file mode 100644 index 00000000000..50a2730e9d4 --- /dev/null +++ b/apps/desktop/src/renderer/stores/new-workspace-prompt-context/useNewWorkspacePromptContext.ts @@ -0,0 +1,89 @@ +import { useEffect, useMemo } from "react"; +import { resolveHostUrl } from "renderer/hooks/host-service/useHostTargetUrl"; +import { authClient } from "renderer/lib/auth-client"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; +import type { + LinkedIssue, + LinkedPR, +} from "renderer/stores/new-workspace-draft"; +import { buildSubmitPrompt } from "./buildSubmitPrompt"; +import { + fetchGitHubIssueBody, + fetchInternalTaskBody, + fetchPrBody, +} from "./fetchers"; +import { useNewWorkspacePromptContextStore } from "./store"; + +export interface NewWorkspacePromptContextApi { + build: (args: { + userPrompt: string; + linkedPR: LinkedPR | null; + linkedIssues: LinkedIssue[]; + timeoutMs: number; + }) => Promise; +} + +export function useNewWorkspacePromptContext(args: { + projectId: string | null; + hostId: string | null; + linkedPR: LinkedPR | null; + linkedIssues: LinkedIssue[]; +}): NewWorkspacePromptContextApi { + const { projectId, hostId, linkedPR, linkedIssues } = args; + const { machineId, activeHostUrl } = useLocalHostService(); + const { data: session } = authClient.useSession(); + const activeOrganizationId = session?.session?.activeOrganizationId ?? null; + + const hostUrl = useMemo(() => { + const id = hostId ?? machineId; + if (!id || !activeOrganizationId) return null; + return resolveHostUrl({ + hostId: id, + machineId, + activeHostUrl, + organizationId: activeOrganizationId, + }); + }, [hostId, machineId, activeHostUrl, activeOrganizationId]); + + useEffect(() => { + if (!projectId || !hostUrl) return; + const store = useNewWorkspacePromptContextStore.getState(); + + if (linkedPR) { + const prNumber = linkedPR.prNumber; + store.register(`pr:${prNumber}`, () => + fetchPrBody({ prNumber, projectId, hostUrl }), + ); + } + + for (const issue of linkedIssues) { + if (issue.source === "github" && issue.number != null) { + const issueNumber = issue.number; + store.register(`github-issue:${issueNumber}`, () => + fetchGitHubIssueBody({ issueNumber, projectId, hostUrl }), + ); + } else if (issue.source === "internal" && issue.taskId) { + const taskId = issue.taskId; + store.register(`task:${taskId}`, () => + fetchInternalTaskBody({ taskId }), + ); + } + } + }, [projectId, hostUrl, linkedPR, linkedIssues]); + + return useMemo( + () => ({ + build: async (buildArgs) => { + await useNewWorkspacePromptContextStore + .getState() + .awaitPending(buildArgs.timeoutMs); + return buildSubmitPrompt({ + userPrompt: buildArgs.userPrompt, + linkedPR: buildArgs.linkedPR, + linkedIssues: buildArgs.linkedIssues, + }); + }, + }), + [], + ); +} 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/appendLaunchesToPaneLayout.ts b/apps/desktop/src/renderer/stores/workspace-creates/appendLaunchesToPaneLayout.ts new file mode 100644 index 00000000000..213f6da9eba --- /dev/null +++ b/apps/desktop/src/renderer/stores/workspace-creates/appendLaunchesToPaneLayout.ts @@ -0,0 +1,57 @@ +import { createWorkspaceStore, type WorkspaceState } from "@superset/panes"; +import type { PaneViewerData } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types"; + +const EMPTY_STATE: WorkspaceState = { + version: 1, + tabs: [], + activeTabId: null, +}; + +interface AppendArgs { + existing: WorkspaceState | undefined; + terminals: Array<{ terminalId: string; label?: string }>; + agents: Array< + | { ok: true; sessionId: string; label: string } + | { ok: false; error: string } + >; +} + +export function appendLaunchesToPaneLayout({ + existing, + terminals, + agents, +}: AppendArgs): WorkspaceState { + const launches = [ + ...terminals, + ...agents + .filter((entry): entry is Extract => entry.ok) + .map((entry) => ({ terminalId: entry.sessionId, label: entry.label })), + ]; + + if (launches.length === 0) { + return existing ?? EMPTY_STATE; + } + + const store = createWorkspaceStore({ + initialState: existing ?? EMPTY_STATE, + }); + + for (const launch of launches) { + store.getState().addTab({ + titleOverride: launch.label, + panes: [ + { + kind: "terminal", + data: { terminalId: launch.terminalId }, + }, + ], + }); + } + + const next = store.getState(); + return { + version: next.version, + tabs: next.tabs, + activeTabId: next.activeTabId, + }; +} 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..556a1c3e127 --- /dev/null +++ b/apps/desktop/src/renderer/stores/workspace-creates/useWorkspaceCreates.ts @@ -0,0 +1,146 @@ +import type { WorkspaceState } from "@superset/panes"; +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 type { PaneViewerData } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; +import { appendLaunchesToPaneLayout } from "./appendLaunchesToPaneLayout"; +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 collections = useCollections(); + + 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 existing = collections.v2WorkspaceLocalState.get( + result.workspace.id, + ); + const paneLayout = appendLaunchesToPaneLayout({ + existing: existing?.paneLayout as + | WorkspaceState + | undefined, + terminals: result.terminals, + agents: result.agents, + }); + if (existing) { + collections.v2WorkspaceLocalState.update( + result.workspace.id, + (draft) => { + draft.paneLayout = paneLayout; + }, + ); + } else { + collections.v2WorkspaceLocalState.insert({ + workspaceId: result.workspace.id, + createdAt: new Date(), + sidebarState: { + projectId: result.workspace.projectId, + tabOrder: 0, + sectionId: null, + changesFilter: { kind: "all" }, + activeTab: "changes", + isHidden: false, + }, + paneLayout, + viewedFiles: [], + recentlyViewedFiles: [], + }); + } + } catch (err) { + useWorkspaceCreatesStore + .getState() + .markError( + workspaceId, + err instanceof Error ? err.message : String(err), + ); + } + }, + [machineId, activeHostUrl, organizationId, collections], + ); + + 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/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/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/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", 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}`, + }; + }, +}); 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/0044_add_task_id_to_v2_workspaces.sql b/packages/db/drizzle/0044_add_task_id_to_v2_workspaces.sql new file mode 100644 index 00000000000..066660b4af2 --- /dev/null +++ b/packages/db/drizzle/0044_add_task_id_to_v2_workspaces.sql @@ -0,0 +1,3 @@ +ALTER TABLE "v2_workspaces" ADD COLUMN "task_id" uuid;--> statement-breakpoint +ALTER TABLE "v2_workspaces" ADD CONSTRAINT "v2_workspaces_task_id_tasks_id_fk" FOREIGN KEY ("task_id") REFERENCES "public"."tasks"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "v2_workspaces_task_id_idx" ON "v2_workspaces" USING btree ("task_id"); \ No newline at end of file diff --git a/packages/db/drizzle/meta/0044_snapshot.json b/packages/db/drizzle/meta/0044_snapshot.json new file mode 100644 index 00000000000..69daa98e8a8 --- /dev/null +++ b/packages/db/drizzle/meta/0044_snapshot.json @@ -0,0 +1,5989 @@ +{ + "id": "30f47cfc-32ea-4500-9022-2277f5d22bb3", + "prevId": "eec89421-4398-49a7-a83f-a51620451a11", + "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.submitted_prompts": { + "name": "submitted_prompts", + "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": false + }, + "prompt_text": { + "name": "prompt_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "submitter_name": { + "name": "submitter_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "submitted_prompts_user_id_idx": { + "name": "submitted_prompts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "submitted_prompts_organization_id_idx": { + "name": "submitted_prompts_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "submitted_prompts_created_at_idx": { + "name": "submitted_prompts_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "submitted_prompts_user_id_users_id_fk": { + "name": "submitted_prompts_user_id_users_id_fk", + "tableFrom": "submitted_prompts", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "submitted_prompts_organization_id_organizations_id_fk": { + "name": "submitted_prompts_organization_id_organizations_id_fk", + "tableFrom": "submitted_prompts", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "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 + }, + "task_id": { + "name": "task_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_task_id_idx": { + "name": "v2_workspaces_task_id_idx", + "columns": [ + { + "expression": "task_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_task_id_tasks_id_fk": { + "name": "v2_workspaces_task_id_tasks_id_fk", + "tableFrom": "v2_workspaces", + "tableTo": "tasks", + "columnsFrom": [ + "task_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.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 5e7b05e6766..ca9c145f149 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -309,6 +309,13 @@ "when": 1777854769892, "tag": "0043_submitted_prompts", "breakpoints": true + }, + { + "idx": 44, + "version": "7", + "when": 1777874337162, + "tag": "0044_add_task_id_to_v2_workspaces", + "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..962355867bd 100644 --- a/packages/db/src/schema/relations.ts +++ b/packages/db/src/schema/relations.ts @@ -118,7 +118,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 +137,7 @@ export const tasksRelations = relations(tasks, ({ one }) => ({ references: [users.id], relationName: "creator", }), + workspaces: many(v2Workspaces), })); export const taskStatusesRelations = relations( @@ -338,6 +339,10 @@ export const v2WorkspacesRelations = relations( references: [users.id], }), chatSessions: many(chatSessions), + task: one(tasks, { + fields: [v2Workspaces.taskId], + references: [tasks.id], + }), }), ); diff --git a/packages/db/src/schema/schema.ts b/packages/db/src/schema/schema.ts index a33a3567488..e16e9a259fd 100644 --- a/packages/db/src/schema/schema.ts +++ b/packages/db/src/schema/schema.ts @@ -540,6 +540,9 @@ export const v2Workspaces = pgTable( createdByUserId: uuid("created_by_user_id").references(() => users.id, { onDelete: "set null", }), + taskId: uuid("task_id").references(() => tasks.id, { + onDelete: "set null", + }), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), @@ -557,6 +560,7 @@ export const v2Workspaces = pgTable( index("v2_workspaces_project_id_idx").on(table.projectId), index("v2_workspaces_organization_id_idx").on(table.organizationId), index("v2_workspaces_host_id_idx").on(table.hostId), + index("v2_workspaces_task_id_idx").on(table.taskId), uniqueIndex("v2_workspaces_one_main_per_host") .on(table.projectId, table.hostId) .where(sql`${table.type} = 'main'`), 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/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/issues/index.ts b/packages/host-service/src/trpc/router/issues/index.ts new file mode 100644 index 00000000000..ca244763f96 --- /dev/null +++ b/packages/host-service/src/trpc/router/issues/index.ts @@ -0,0 +1 @@ +export { issuesRouter } from "./issues"; diff --git a/packages/host-service/src/trpc/router/issues/issues.ts b/packages/host-service/src/trpc/router/issues/issues.ts new file mode 100644 index 00000000000..70ae3feb9ec --- /dev/null +++ b/packages/host-service/src/trpc/router/issues/issues.ts @@ -0,0 +1,6 @@ +import { router } from "../../index"; +import { getContent } from "./procedures/get-content"; + +export const issuesRouter = router({ + getContent, +}); diff --git a/packages/host-service/src/trpc/router/workspace-creation/procedures/get-github-issue-content.ts b/packages/host-service/src/trpc/router/issues/procedures/get-content.ts similarity index 60% rename from packages/host-service/src/trpc/router/workspace-creation/procedures/get-github-issue-content.ts rename to packages/host-service/src/trpc/router/issues/procedures/get-content.ts index 99b2f163e57..9536925bf1a 100644 --- a/packages/host-service/src/trpc/router/workspace-creation/procedures/get-github-issue-content.ts +++ b/packages/host-service/src/trpc/router/issues/procedures/get-content.ts @@ -1,15 +1,30 @@ import { TRPCError } from "@trpc/server"; +import { z } from "zod"; import { protectedProcedure } from "../../../index"; -import { githubIssueContentInputSchema, issueContentSchema } from "../schemas"; -import { resolveGithubRepo } from "../shared/project-helpers"; -import { execGh } from "../utils/exec-gh"; +import { resolveGithubRepo } from "../../workspace-creation/shared/project-helpers"; +import { execGh } from "../../workspace-creation/utils/exec-gh"; + +const getContentInputSchema = z.object({ + projectId: z.string(), + issueNumber: z.number().int().positive(), +}); + +const ghIssueContentSchema = z.object({ + number: z.number(), + title: z.string(), + body: z.string().nullable().optional(), + url: z.string(), + state: z.string(), + author: z.object({ login: z.string() }).optional(), + createdAt: z.string().optional(), + updatedAt: z.string().optional(), +}); // 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) +export const getContent = protectedProcedure + .input(getContentInputSchema) .query(async ({ ctx, input }) => { const repo = await resolveGithubRepo(ctx, input.projectId); try { @@ -22,7 +37,7 @@ export const getGitHubIssueContent = protectedProcedure "--json", "number,title,body,url,state,author,createdAt,updatedAt", ]); - const data = issueContentSchema.parse(raw); + const data = ghIssueContentSchema.parse(raw); return { number: data.number, title: data.title, 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/pull-requests/procedures/get-content.ts similarity index 70% rename from packages/host-service/src/trpc/router/workspace-creation/procedures/get-github-pull-request-content.ts rename to packages/host-service/src/trpc/router/pull-requests/procedures/get-content.ts index 82d28187849..d5f8de43636 100644 --- a/packages/host-service/src/trpc/router/workspace-creation/procedures/get-github-pull-request-content.ts +++ b/packages/host-service/src/trpc/router/pull-requests/procedures/get-content.ts @@ -1,11 +1,29 @@ import { TRPCError } from "@trpc/server"; +import { z } from "zod"; import { protectedProcedure } from "../../../index"; -import { - githubPullRequestContentInputSchema, - pullRequestContentSchema, -} from "../schemas"; -import { resolveGithubRepo } from "../shared/project-helpers"; -import { execGh } from "../utils/exec-gh"; +import { resolveGithubRepo } from "../../workspace-creation/shared/project-helpers"; +import { execGh } from "../../workspace-creation/utils/exec-gh"; + +const getContentInputSchema = z.object({ + projectId: z.string(), + prNumber: z.number().int().positive(), +}); + +const ghPullRequestContentSchema = z.object({ + number: z.number(), + title: z.string(), + body: z.string().nullable().optional(), + url: z.string(), + state: z.string(), + headRefName: z.string(), + baseRefName: z.string(), + headRepositoryOwner: z.object({ login: z.string() }).nullable(), + isCrossRepository: z.boolean(), + isDraft: z.boolean(), + author: z.object({ login: z.string() }).optional(), + createdAt: z.string().optional(), + updatedAt: z.string().optional(), +}); type PullRequestContent = { number: number; @@ -14,10 +32,8 @@ type PullRequestContent = { url: string; state: string; branch: string; - headRefOid: string; baseBranch: string; headRepositoryOwner: string | null; - headRepositoryName: string | null; isCrossRepository: boolean; author: string | null; isDraft: boolean; @@ -34,8 +50,8 @@ const pullRequestContentCache = new Map< { promise: Promise; fetchedAt: number } >(); -export const getGitHubPullRequestContent = protectedProcedure - .input(githubPullRequestContentInputSchema) +export const getContent = protectedProcedure + .input(getContentInputSchema) .query(async ({ ctx, input }) => { const repo = await resolveGithubRepo(ctx, input.projectId); const cacheKey = `${repo.owner.toLowerCase()}/${repo.name.toLowerCase()}#${input.prNumber}`; @@ -57,9 +73,9 @@ export const getGitHubPullRequestContent = protectedProcedure "--repo", `${repo.owner}/${repo.name}`, "--json", - "number,title,body,url,state,author,headRefName,headRefOid,baseRefName,headRepositoryOwner,headRepository,isCrossRepository,isDraft,createdAt,updatedAt", + "number,title,body,url,state,author,headRefName,baseRefName,headRepositoryOwner,isCrossRepository,isDraft,createdAt,updatedAt", ]); - const data = pullRequestContentSchema.parse(raw); + const data = ghPullRequestContentSchema.parse(raw); return { number: data.number, title: data.title, @@ -67,10 +83,8 @@ export const getGitHubPullRequestContent = protectedProcedure url: data.url, state: data.state.toLowerCase(), branch: data.headRefName, - headRefOid: data.headRefOid, baseBranch: data.baseRefName, headRepositoryOwner: data.headRepositoryOwner?.login ?? null, - headRepositoryName: data.headRepository?.name ?? null, isCrossRepository: data.isCrossRepository, author: data.author?.login ?? null, isDraft: data.isDraft, diff --git a/packages/host-service/src/trpc/router/pull-requests/pull-requests.ts b/packages/host-service/src/trpc/router/pull-requests/pull-requests.ts index d36806c668a..9586911fd2c 100644 --- a/packages/host-service/src/trpc/router/pull-requests/pull-requests.ts +++ b/packages/host-service/src/trpc/router/pull-requests/pull-requests.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { protectedProcedure, router } from "../../index"; +import { getContent } from "./procedures/get-content"; export const pullRequestsRouter = router({ getByWorkspaces: protectedProcedure @@ -27,4 +28,5 @@ export const pullRequestsRouter = router({ ); return { ok: true }; }), + getContent, }); diff --git a/packages/host-service/src/trpc/router/router.ts b/packages/host-service/src/trpc/router/router.ts index 94566b79a46..6bcff364d32 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"; @@ -8,6 +9,7 @@ import { gitRouter } from "./git"; import { githubRouter } from "./github"; import { healthRouter } from "./health"; import { hostRouter } from "./host"; +import { issuesRouter } from "./issues"; import { notificationsRouter } from "./notifications"; import { portsRouter } from "./ports"; import { projectRouter } from "./project"; @@ -17,8 +19,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, @@ -28,6 +32,7 @@ export const appRouter = router({ git: gitRouter, github: githubRouter, cloud: cloudRouter, + issues: issuesRouter, notifications: notificationsRouter, pullRequests: pullRequestsRouter, project: projectRouter, @@ -35,6 +40,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 c886b78b920..00000000000 --- a/packages/host-service/src/trpc/router/workspace-creation/procedures/checkout.ts +++ /dev/null @@ -1,346 +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"; -import { - getErrorMessage, - recoverPrCheckoutAfterGhFailure, -} from "../utils/pr-checkout-recovery"; - -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) { - const warnings: string[] = []; - try { - await ctx.runtime.pullRequests.linkWorkspaceToCheckoutPullRequest({ - workspaceId: existing.id, - projectId: input.projectId, - pullRequest: input.pr, - }); - } catch (err) { - console.warn( - "[workspaceCreation.checkout] failed to link existing workspace PR metadata", - { workspaceId: existing.id, err }, - ); - warnings.push( - "Existing workspace found, but Superset could not link pull request status automatically.", - ); - } - 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", - }); - } - - let prCheckoutRecoveryWarning: string | null = null; - try { - await execGh( - [ - "pr", - "checkout", - String(input.pr.number), - "--branch", - branch, - "--force", - ], - { cwd: worktreePath, timeout: 120_000 }, - ); - } catch (err) { - // Distinguish recovery declined (recoveryError null, - // prCheckoutRecoveryWarning null) from recovery threw - // (recoveryError set) so the surfaced message includes the - // secondary failure only when it adds information. - let recoveryError: unknown = null; - try { - const recovery = await recoverPrCheckoutAfterGhFailure({ - git, - worktreePath, - branch, - prNumber: input.pr.number, - remoteName: localProject.remoteName ?? "origin", - expectedHeadOid: input.pr.headRefOid, - error: err, - }); - if (recovery.recovered) { - prCheckoutRecoveryWarning = recovery.warning; - } - } catch (e) { - recoveryError = e; - } - - if (!prCheckoutRecoveryWarning) { - await git - .raw(["worktree", "remove", "--force", worktreePath]) - .catch((rollbackErr) => { - console.warn( - "[workspaceCreation.checkout] failed to rollback PR worktree", - { worktreePath, err: rollbackErr }, - ); - }); - clearProgress(input.pendingId); - const recoveryMessage = recoveryError - ? ` Recovery via refs/pull/${input.pr.number}/head also failed: ${getErrorMessage(recoveryError)}` - : ""; - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `gh pr checkout failed: ${getErrorMessage(err)}${recoveryMessage}`, - }); - } - } - - if (prCheckoutRecoveryWarning) { - console.warn( - "[workspaceCreation.checkout] recovered failed gh pr checkout", - { - prNumber: input.pr.number, - branch, - warning: prCheckoutRecoveryWarning, - }, - ); - } - - // 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 (prCheckoutRecoveryWarning) { - extraWarnings.push(prCheckoutRecoveryWarning); - } - 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, - pullRequest: input.pr, - }); - } - - // ── 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-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 a3fcb14bdc6..ba6919b9833 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,79 +9,6 @@ export const searchBranchesInputSchema = z.object({ filter: z.enum(["all", "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(), - headRefOid: z.string().min(1), - baseRefName: z.string(), - headRepositoryOwner: z.string(), - headRepositoryName: z.string().nullable().optional(), - isCrossRepository: z.boolean(), - isDraft: z.boolean().optional(), - 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(), @@ -119,46 +29,3 @@ export const githubSearchInputSchema = z.object({ limit: z.number().min(1).max(100).optional(), includeClosed: z.boolean().optional(), }); - -export const githubIssueContentInputSchema = z.object({ - projectId: z.string(), - issueNumber: z.number().int().positive(), -}); - -export const githubPullRequestContentInputSchema = z.object({ - projectId: z.string(), - prNumber: z.number().int().positive(), -}); - -export const issueContentSchema = z.object({ - number: z.number(), - title: z.string(), - body: z.string().nullable().optional(), - url: z.string(), - state: z.string(), - author: z.object({ login: z.string() }).optional(), - createdAt: z.string().optional(), - updatedAt: z.string().optional(), -}); - -export const pullRequestContentSchema = z.object({ - number: z.number(), - title: z.string(), - body: z.string().nullable().optional(), - url: z.string(), - state: z.string(), - headRefName: z.string(), - headRefOid: z.string().min(1), - baseRefName: z.string(), - // `gh pr view` returns null when the PR's head fork repository has been - // deleted. Nullable so the schema parse doesn't fail; consumers decide - // how to handle a missing owner (client surfaces a clear error for - // cross-repo PRs — same-repo PRs shouldn't see null in practice). - headRepositoryOwner: z.object({ login: z.string() }).nullable(), - headRepository: z.object({ name: z.string() }).nullable(), - isCrossRepository: z.boolean(), - isDraft: z.boolean(), - author: z.object({ login: z.string() }).optional(), - createdAt: z.string().optional(), - updatedAt: z.string().optional(), -}); 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 a5fed75f266..00000000000 --- a/packages/host-service/src/trpc/router/workspace-creation/shared/finish-checkout.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { getHostId, getHostName } from "@superset/shared/host-info"; -import { TRPCError } from "@trpc/server"; -import { workspaces } from "../../../../db/schema"; -import type { CheckoutPullRequestMetadata } from "../../../../runtime/pull-requests"; -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[]; - pullRequest?: CheckoutPullRequestMetadata; - }, -): 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.pullRequest) { - try { - await ctx.runtime.pullRequests.linkWorkspaceToCheckoutPullRequest({ - workspaceId: cloudRow.id, - projectId: args.projectId, - pullRequest: args.pullRequest, - }); - } catch (err) { - console.warn( - "[workspaceCreation.checkout] failed to link checkout PR metadata", - { workspaceId: cloudRow.id, err }, - ); - warnings.push( - "Workspace was created, but Superset could not link pull request status automatically.", - ); - } - } - - 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/utils/ai-workspace-names.ts b/packages/host-service/src/trpc/router/workspace-creation/utils/ai-workspace-names.ts index c1615eeac4a..dbbd19f6d2c 100644 --- a/packages/host-service/src/trpc/router/workspace-creation/utils/ai-workspace-names.ts +++ b/packages/host-service/src/trpc/router/workspace-creation/utils/ai-workspace-names.ts @@ -105,14 +105,21 @@ interface ApplyAiRenameArgs { oldBranchName: string; oldWorkspaceName: string; prompt: string; + /** Replace the workspace title with an AI-picked one. Skip when the user typed a name. */ + renameTitle: boolean; + /** Replace the git branch name with an AI-picked one. Skip when the user typed a branch. */ + renameBranch: boolean; } /** * Generates an AI title+branch for a freshly-created workspace and - * applies both. Git rename runs first (cheap to roll back); cloud - * update is source of truth; host-local DB only writes after cloud - * confirms. On cloud failure the git rename is reverted so git, - * host-local DB, and cloud stay in lockstep. + * applies whichever side the caller asked for. Git rename runs first + * (cheap to roll back); cloud update is source of truth; host-local + * DB only writes after cloud confirms. On cloud failure the git + * rename is reverted so git, host-local DB, and cloud stay in lockstep. + * + * `renameTitle` / `renameBranch` let callers preserve user-typed + * values: skip replacing whichever side the user supplied directly. */ export async function applyAiWorkspaceRename( args: ApplyAiRenameArgs, @@ -125,15 +132,21 @@ export async function applyAiWorkspaceRename( oldBranchName, oldWorkspaceName, prompt, + renameTitle, + renameBranch, } = args; + if (!renameTitle && !renameBranch) return; + const aiNames = await generateWorkspaceNamesFromPrompt(prompt); if (!aiNames) return; const titleChanged = - aiNames.title !== "" && aiNames.title !== oldWorkspaceName; + renameTitle && aiNames.title !== "" && aiNames.title !== oldWorkspaceName; const branchChanged = - aiNames.branchName !== "" && aiNames.branchName !== oldBranchName; + renameBranch && + aiNames.branchName !== "" && + aiNames.branchName !== oldBranchName; if (!titleChanged && !branchChanged) return; let deduped = oldBranchName; 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/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..f9998a16c5b --- /dev/null +++ b/packages/host-service/src/trpc/router/workspaces/workspaces.ts @@ -0,0 +1,904 @@ +import { mkdirSync } from "node:fs"; +import { dirname } from "node:path"; +import { generateFriendlyBranchName } from "@superset/shared/workspace-launch"; +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, + type GeneratedWorkspaceNames, + 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 { + getErrorMessage, + recoverPrCheckoutAfterGhFailure, +} from "../workspace-creation/utils/pr-checkout-recovery"; +import { resolveStartPoint } from "../workspace-creation/utils/resolve-start-point"; +import { deduplicateBranchName } from "../workspace-creation/utils/sanitize-branch"; + +const agentLaunchSchema = z + .object({ + agent: z.string().min(1), + prompt: z.string(), + attachmentIds: z.array(z.string().uuid()).optional(), + }) + .refine( + (value) => + value.prompt.length > 0 || (value.attachmentIds?.length ?? 0) > 0, + { message: "Agent launch requires a prompt or attachments" }, + ); + +const createInputSchema = z + .object({ + projectId: z.string(), + // Both `name` and `branch` are optional. When omitted with a + // non-empty agent prompt, the server generates them inline via + // the same LLM call (in parallel with the worktree work). When + // omitted with no prompt, a friendly-random fallback fills in. + name: z.string().min(1).optional(), + branch: z.string().min(1).optional(), + pr: z.number().int().positive().optional(), + baseBranch: z.string().min(1).optional(), + taskId: z.string().uuid().optional(), + agents: z.array(agentLaunchSchema).optional(), + id: z.string().uuid().optional(), + }) + .refine((value) => !(value.branch && value.pr), { + message: "`branch` and `pr` cannot both 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; + headRefOid: 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,headRefOid,baseRefName,headRepositoryOwner,isCrossRepository,state", + ], + { cwd: args.cwd, timeout: 30_000 }, + ); + const parsed = result as { + number: number; + url: string; + title: string; + headRefName: string; + headRefOid: 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, + headRefOid: parsed.headRefOid, + baseRefName: parsed.baseRefName, + headRepositoryOwner: parsed.headRepositoryOwner?.login ?? "", + isCrossRepository: parsed.isCrossRepository, + state, + }; +} + +async function getLocalBranchHead( + git: GitClient, + branchName: string, +): Promise { + try { + const out = await git.raw([ + "rev-parse", + "--verify", + `refs/heads/${branchName}^{commit}`, + ]); + const trimmed = out.trim(); + return /^[0-9a-f]{40,}/.test(trimmed) ? trimmed : null; + } catch { + return null; + } +} + +interface BranchSourcePlan { + branch: string; + startPoint: ResolvedRef; + usedExistingBranch: boolean; +} + +/** + * Resolve the start point a *new* branch should fork from. No + * `resolveRef(branch)` check — callers are responsible for guaranteeing + * the branch name is fresh (e.g. via `deduplicateBranchName`). Useful + * when the branch name is being chosen at the same time the start point + * is resolved (auto-gen + AI naming path), so it can run in parallel + * with the LLM call. + */ +async function resolveNewBranchStartPoint( + git: GitClient, + baseBranch: string | undefined, +): Promise { + 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); + // `--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", `${remoteRef}^{commit}`]) + .then((out) => /^[0-9a-f]{40,}/.test(out.trim())) + .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 startPoint; +} + +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`, + }); + } + + const startPoint = await resolveNewBranchStartPoint(git, baseBranch); + 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, + ); + }); +} + +/** + * Kicks off `host.ensure` so the cloud round-trip overlaps with the + * git work in `workspaces.create`. Returned promise is awaited inside + * `registerCloudAndLocal` once we actually need the hostId. + * + * `host.ensure` is idempotent — fine to start it before we know + * whether we'll end up creating a workspace at all (e.g. the + * idempotency short-circuit returns early). Worst case is one wasted + * cloud call, no observable side effect. + */ +async function startHostEnsure( + ctx: HostServiceContext, +): Promise<{ machineId: string }> { + const { getHostId, getHostName } = await import("@superset/shared/host-info"); + return ctx.api.host.ensure.mutate({ + organizationId: ctx.organizationId, + machineId: getHostId(), + name: getHostName(), + }); +} + +async function registerCloudAndLocal(args: { + ctx: HostServiceContext; + id: string | undefined; + projectId: string; + name: string; + branch: string; + worktreePath: string; + taskId: string | undefined; + rollbackWorktree: () => Promise; + hostPromise: Promise<{ machineId: string }>; +}): Promise<{ id: string; projectId: string; name: string; branch: string }> { + const { ctx } = args; + let host: { machineId: string }; + try { + host = await args.hostPromise; + } 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, + taskId: args.taskId, + 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); + + // Kick off host.ensure immediately so the cloud round-trip + // overlaps with the git work below. Suppressing unhandled + // rejection here — the await in registerCloudAndLocal turns + // the promise rejection into a TRPCError with rollback. + const hostPromise = startHostEnsure(ctx); + hostPromise.catch(() => {}); + + // Kick off AI naming in parallel when the user supplied a prompt + // but left at least one of (name, branch) blank. The LLM call + // (~700ms) overlaps with `ensureMainWorkspace` + the start-point + // resolution, so by the time we need the resolved values for + // `worktree add` they're already in hand. PR path skips entirely + // — PR title + derived branch are already meaningful. + const composerPrompt = input.agents?.[0]?.prompt?.trim() ?? ""; + const wantAi = + input.pr === undefined && + (input.branch === undefined || input.name === undefined) && + !!composerPrompt; + const aiNamesPromise: Promise | null = + wantAi + ? generateWorkspaceNamesFromPrompt(composerPrompt).catch((err) => { + console.warn("[workspaces.create] AI naming failed", err); + return null; + }) + : null; + aiNamesPromise?.catch(() => {}); + + 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 { + const localOid = await getLocalBranchHead(git, resolvedBranch); + const adoptLocalBranch = + localOid !== null && + localOid.toLowerCase() === + prMetadata.headRefOid.trim().toLowerCase(); + if (localOid !== null && !adoptLocalBranch) { + throw new TRPCError({ + code: "CONFLICT", + message: `Local branch "${resolvedBranch}" exists outside Superset and points at a different commit than PR #${input.pr} (local ${localOid.slice(0, 7)}, PR ${prMetadata.headRefOid.slice(0, 7)}). Inspect with \`git log ${resolvedBranch}\`, then \`git branch -D ${resolvedBranch}\` if safe.`, + }); + } + + 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 }, + ); + } + }; + + if (adoptLocalBranch) { + try { + await git.raw(["worktree", "add", worktreePath, resolvedBranch]); + } catch (err) { + throw new TRPCError({ + code: "CONFLICT", + message: + err instanceof Error + ? err.message + : "Failed to add worktree for existing branch", + }); + } + } else { + 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) { + let recoveryError: unknown = null; + let recovered = false; + try { + const recovery = await recoverPrCheckoutAfterGhFailure({ + git, + worktreePath, + branch: resolvedBranch, + prNumber: input.pr, + remoteName: localProject.remoteName ?? "origin", + expectedHeadOid: prMetadata.headRefOid, + error: err, + }); + recovered = recovery.recovered; + } catch (e) { + recoveryError = e; + } + if (!recovered) { + await rollbackWorktree(); + const recoveryMessage = recoveryError + ? ` Recovery via refs/pull/${input.pr}/head also failed: ${getErrorMessage(recoveryError)}` + : ""; + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `gh pr checkout failed: ${err instanceof Error ? err.message : String(err)}${recoveryMessage}`, + }); + } + } + } + + await enablePushAutoSetupRemote( + git, + worktreePath, + "[workspaces.create]", + ); + + workspaceRow = await registerCloudAndLocal({ + ctx, + id: input.id, + projectId: input.projectId, + name: input.name ?? prMetadata.title ?? resolvedBranch, + branch: resolvedBranch, + worktreePath, + taskId: input.taskId, + rollbackWorktree, + hostPromise, + }); + + if (prMetadata.baseRefName) { + await recordBaseBranchConfig({ + git, + worktreePath, + branch: resolvedBranch, + baseBranch: prMetadata.baseRefName, + }); + } + } + } else { + const typedBranch = input.branch?.trim(); + let plan: BranchSourcePlan; + let aiTitle: string | null = null; + + if (typedBranch) { + // Typed branch: resolve start point via the existing-branch- + // aware planner. Title-rename can race with that lookup. + resolvedBranch = typedBranch; + const [planResult, aiNames] = await Promise.all([ + planBranchSource(git, resolvedBranch, input.baseBranch), + aiNamesPromise ?? Promise.resolve(null), + ]); + plan = planResult; + aiTitle = aiNames?.title ?? null; + } else { + // Auto-gen branch: kick the LLM, the start-point resolve, + // and the dedupe list off in parallel — none of them depend + // on the others. Whichever finishes last gates the worktree + // add. AI's branch name wins when available; friendly random + // is a fallback for no-prompt or LLM failure. + const [aiNames, startPoint, existing] = await Promise.all([ + aiNamesPromise ?? Promise.resolve(null), + resolveNewBranchStartPoint(git, input.baseBranch), + listBranchNames(ctx, localProject.repoPath), + ]); + aiTitle = aiNames?.title ?? null; + const candidate = aiNames?.branchName || generateFriendlyBranchName(); + resolvedBranch = deduplicateBranchName(candidate, existing); + plan = { + branch: resolvedBranch, + startPoint, + usedExistingBranch: false, + }; + } + + 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. Only meaningful when the user + // supplied the branch — auto-gen names are deduped and can't + // collide with anything pre-existing. + const adopted = + !!typedBranch && + (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, + }); + } + }; + + if (!adopted) { + 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.usedExistingBranch && plan.startPoint.kind !== "head") { + const baseShortName = plan.startPoint.shortName; + await git + .raw(["config", `branch.${resolvedBranch}.base`, baseShortName]) + .catch((err) => { + console.warn( + `[workspaces.create] failed to record base branch ${baseShortName}:`, + err, + ); + }); + } + + workspaceRow = await registerCloudAndLocal({ + ctx, + id: input.id, + projectId: input.projectId, + name: input.name ?? aiTitle ?? resolvedBranch, + branch: resolvedBranch, + worktreePath, + taskId: input.taskId, + rollbackWorktree, + hostPromise, + }); + } + } + + 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, + }); + } + } + } + + 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, + renameTitle: true, + renameBranch: true, + }).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..016f3f47752 100644 --- a/packages/mcp-v2/src/tools/workspaces/create.ts +++ b/packages/mcp-v2/src/tools/workspaces/create.ts @@ -7,20 +7,62 @@ 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."), + taskId: z + .string() + .uuid() + .optional() + .describe("Optional Superset task id to link to the new workspace."), }, 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; + taskId?: 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 +70,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, + taskId: input.taskId, }, ); }, 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..936c92d8723 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, + taskId: params.taskId, + 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 id to link to the new workspace. */ + taskId?: 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/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})`, }); } diff --git a/packages/trpc/src/router/v2-workspace/v2-workspace.ts b/packages/trpc/src/router/v2-workspace/v2-workspace.ts index 8a2f2fcbc66..f8e3f9c3e3a 100644 --- a/packages/trpc/src/router/v2-workspace/v2-workspace.ts +++ b/packages/trpc/src/router/v2-workspace/v2-workspace.ts @@ -1,6 +1,7 @@ import { db, dbWs } from "@superset/db/client"; import { v2WorkspaceTypeValues } from "@superset/db/enums"; import { + tasks, v2Hosts, v2Projects, v2UsersHosts, @@ -168,6 +169,8 @@ export const v2WorkspaceRouter = { branch: z.string().min(1), hostId: z.string().min(1), type: z.enum(v2WorkspaceTypeValues).default("worktree"), + taskId: z.string().uuid().optional(), + id: z.string().uuid().optional(), }), ) .mutation(async ({ ctx, input }) => { @@ -184,70 +187,115 @@ 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 (inserted) { - posthog.capture({ - distinctId: ctx.userId, - event: "workspace_created", - properties: { - workspace_id: inserted.id, - project_id: inserted.projectId, - organization_id: inserted.organizationId, - host_id: inserted.hostId, - branch: inserted.branch, - type: inserted.type, - }, + if (input.taskId) { + const found = await dbWs.query.tasks.findFirst({ + columns: { id: true, organizationId: true }, + where: eq(tasks.id, input.taskId), }); - return inserted; + if (!found) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "taskId not found", + }); + } + if (found.organizationId !== input.organizationId) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "taskId must belong to the workspace's organization", + }); + } } - 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; - } + // 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, + taskId: input.taskId ?? null, + }) + .onConflictDoNothing() + .returning(); + + if (inserted) { + posthog.capture({ + distinctId: ctx.userId, + event: "workspace_created", + properties: { + workspace_id: inserted.id, + project_id: inserted.projectId, + organization_id: inserted.organizationId, + host_id: inserted.hostId, + branch: inserted.branch, + type: inserted.type, + }, + }); + 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", @@ -255,6 +303,45 @@ export const v2WorkspaceRouter = { }); }), + setTask: protectedProcedure + .input( + z.object({ + workspaceId: z.string().uuid(), + taskId: z.string().uuid().nullable(), + }), + ) + .mutation(async ({ ctx, input }) => { + const organizationId = requireActiveOrgId(ctx, "No active organization"); + const workspace = await getWorkspaceAccess( + ctx.session.user.id, + input.workspaceId, + { organizationId }, + ); + if (input.taskId) { + 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 + .update(v2Workspaces) + .set({ taskId: input.taskId }) + .where(eq(v2Workspaces.id, input.workspaceId)); + return { success: true as const }; + }), + getFromHost: jwtProcedure .input( z.object({ diff --git a/plans/20260501-linear-team-entity.md b/plans/20260501-linear-team-entity.md deleted file mode 100644 index 216a803ee2f..00000000000 --- a/plans/20260501-linear-team-entity.md +++ /dev/null @@ -1,736 +0,0 @@ -# Linear integration overhaul: teams entity, per-team numbering, OAuth refresh, app-actor - -Three workstreams bundled because they all touch the Linear integration code and read better as one cohesive design: - -1. **Teams entity + per-team task numbering** — replaces the inconsistent `tasks.slug` column with stable `{teamKey}-{number}` identifiers backed by a teams table with an explicit linkage to Linear. -2. **OAuth token refresh** — fixes silent 401-after-24-hours that's currently breaking connections. Linear migrated to short-lived tokens on 2026-04-01; our code was written for the old long-lived model and was never updated. -3. **`actor=app` switch + connect/error UX** — preparation for submitting Superset to Linear's integration directory. Bundles cleanly here since we're already touching the connect route. - -Ship order favours user-visible urgency: **OAuth refresh first** (workstream 2), then **teams + numbering + actor switch + UX** (workstreams 1 + 3 together, since they share files). - ---- - -## Workstream 1: Teams entity + per-team numbering - -### Context - -`tasks.slug` is text + `unique(organizationId, slug)`. Two writers populate it inconsistently: - -- **Local creation** (`packages/trpc/src/router/task/task.ts:207-220` via `generateBaseTaskSlug`/`generateUniqueTaskSlug` in `packages/shared/src/task-slug.ts`) → kebab-case-from-title with numeric suffix on collision. Agents produce 30+ char nonsense slugs. -- **Linear sync** (`apps/api/.../sync-task/route.ts:217`, `apps/api/.../initial-sync/utils.ts:183`, `apps/api/.../webhook/route.ts:173`) → overwrites `slug` with Linear's `issue.identifier` (`SUPER-237`). - -Same column carries two semantically different things. Result: hybrid identifier space, hard to predict, hard to reference. - -### Goals - -- Replace `tasks.slug` with a stable, human-readable identifier in the form `{teamKey}-{number}` (e.g. `SUPER-103`). -- Per-team monotonic numbering allocated atomically. -- Identifier is canonical for both local-only and Linear-synced tasks. Linear's identifier (`ENG-42`) becomes metadata on `external_key`. -- Renaming a team's key keeps old links working via redirect. -- Linear teams link to our teams via an explicit admin-set linkage (one of our teams ↔ one Linear team). Issues from non-linked Linear teams are ignored. -- One default team per org for now; multi-team UI deferred. - -### Non-goals (this workstream) - -- Auto-mirroring Linear teams 1:1 in our data model. Linkage is admin-driven via a UI dropdown, not auto-discovered from webhooks/sync. -- Auto-detecting Linear team-key renames. Linear emits no Team webhook events; opportunistic sync via Issue payloads is deferred. -- Surfacing teams as a multi-team UI in org settings. One default team per org, configurable Linear-link only. - -### Schema - -#### `teams` - -Stable team identity. No `key` column — keys are temporal and live in `team_keys`. Carries the Linear linkage directly, mirroring the `external_provider/id/key` pattern already used on `tasks` and `task_statuses`. - -```ts -export const teams = pgTable("teams", { - id: uuid().primaryKey().defaultRandom(), - organizationId: uuid("organization_id").notNull() - .references(() => organizations.id, { onDelete: "cascade" }), - name: text().notNull(), - archivedAt: timestamp("archived_at"), - - // Linkage to an external integration's team (Linear team UUID). - // Set via the integrations UI dropdown. Null = unlinked, no external sync. - externalProvider: integrationProvider("external_provider"), - externalId: text("external_id"), // Linear team UUID - externalKey: text("external_key"), // Linear's team key, e.g. "ENG" — denormalized for display - - createdAt: timestamp("created_at").notNull().defaultNow(), - updatedAt: timestamp("updated_at").notNull().defaultNow().$onUpdate(() => new Date()), -}, (t) => [ - index("teams_organization_id_idx").on(t.organizationId), - unique("teams_org_external_unique").on(t.organizationId, t.externalProvider, t.externalId), -]); -``` - -`teams.externalKey` is Linear's team key (`ENG`) — distinct from `team_keys.key` (our team's identifier prefix, e.g. `SUPER`). They're independent: an admin can link our `SUPER` team to Linear's `ENG` team, and tasks in our team get identifiers like `SUPER-103` in our app and `ENG-42` in Linear, with `external_key` on the task storing `ENG-42`. - -#### `team_keys` - -Lifecycle of every key a team has ever used. Current key = `retired_at IS NULL`. Resolution of `SUPER-103` and `OLDPREFIX-103` (after a rename) both hit this table — no UNION across "current" and "history." - -```ts -export const teamKeys = pgTable("team_keys", { - id: uuid().primaryKey().defaultRandom(), - teamId: uuid("team_id").notNull().references(() => teams.id, { onDelete: "cascade" }), - organizationId: uuid("organization_id").notNull() - .references(() => organizations.id, { onDelete: "cascade" }), - key: text().notNull(), - effectiveAt: timestamp("effective_at").notNull().defaultNow(), - retiredAt: timestamp("retired_at"), -}, (t) => [ - unique("team_keys_org_key_unique").on(t.organizationId, t.key), - uniqueIndex("team_keys_team_id_current_unique") - .on(t.teamId) - .where(sql`${t.retiredAt} IS NULL`), - index("team_keys_team_id_idx").on(t.teamId), -]); -``` - -Full `unique(organization_id, key)` (not partial): a key, once used in an org, is reserved forever. Prevents teamA renaming away from `FOO`, teamB later claiming `FOO`, and `FOO-7` becoming ambiguous. - -#### `team_sequences` - -Atomic per-team counter. One row per team. Separate table — keeps hot counter updates off the teams entity row. - -```ts -export const teamSequences = pgTable("team_sequences", { - teamId: uuid("team_id").primaryKey() - .references(() => teams.id, { onDelete: "cascade" }), - lastNumber: integer("last_number").notNull().default(0), -}); -``` - -Allocation is one statement, atomic via row-level X-lock: - -```ts -const [{ number }] = await tx - .insert(teamSequences) - .values({ teamId, lastNumber: 1 }) - .onConflictDoUpdate({ - target: teamSequences.teamId, - set: { lastNumber: sql`${teamSequences.lastNumber} + 1` }, - }) - .returning({ number: teamSequences.lastNumber }); -``` - -Surrounding tx rollback unwinds the counter — no gaps from failed inserts. (Postgres native sequences advance on rollback; row UPDATE is what we want here.) - -#### `tasks` changes - -Add `team_id` (FK), `number` (integer). Drop `slug` after one release. Keep `external_key` as Linear metadata. - -```ts -{ - // … existing columns … - teamId: uuid("team_id").notNull().references(() => teams.id, { onDelete: "restrict" }), - number: integer().notNull(), -} -// indexes / constraints: -// unique("tasks_team_number_unique").on(team_id, number) -// index("tasks_team_id_idx").on(team_id) -// partial unique on (organization_id, external_key) where external_key IS NOT NULL -// keep tasks_external_unique(organization_id, external_provider, external_id) -// drop tasks_org_slug_unique, tasks_slug_idx (after slug column drop) -``` - -`onDelete: "restrict"` on `team_id`: a task can't dangle without a team. Org delete still cascades through teams → tasks. - -Partial unique on `external_key` lets us resolve `@task:ENG-42` mentions to a single task (see Read paths). - -### Migration - -Single Drizzle migration plus one deploy-time script. Backfill is uniform — every org gets one team, every task flattens into that team's number space. - -```sql --- 1. DDL: create teams, team_keys, team_sequences (per definitions above). - --- 2. For each org with any tasks, create a default team. -INSERT INTO teams (id, organization_id, name) -SELECT gen_random_uuid(), o.id, o.name -FROM auth.organizations o -WHERE EXISTS (SELECT 1 FROM tasks t WHERE t.organization_id = o.id); - --- 3. (TS deploy script) Insert the initial team_keys row for each new team. --- rawKey = upper(replace(org.slug, /[^A-Z0-9]/g, '')) --- key = rawKey.length > 0 ? rawKey : 'TASK' --- INSERT INTO team_keys (team_id, organization_id, key) VALUES (...) - --- 4. (TS deploy script) For each org with a Linear connection AND a non-null --- linearConfig.newTasksTeamId, populate the team's external linkage: --- a) call client.team(newTasksTeamId) to get { id, key, name } --- b) UPDATE teams SET external_provider='linear', external_id=$id, external_key=$key --- WHERE id = $defaultTeamId --- Orgs without newTasksTeamId set: leave unlinked, surface a "Link Linear team" --- prompt next time they visit integrations page. - --- 5. Set tasks.team_id and tasks.number — flatten everything into the org's default team. -WITH numbered AS ( - SELECT t.id, - (SELECT id FROM teams tm WHERE tm.organization_id = t.organization_id LIMIT 1) AS team_id, - ROW_NUMBER() OVER (PARTITION BY t.organization_id ORDER BY t.created_at, t.id) AS num - FROM tasks t -) -UPDATE tasks SET team_id = numbered.team_id, number = numbered.num -FROM numbered WHERE tasks.id = numbered.id; - --- 6. Seed team_sequences. -INSERT INTO team_sequences (team_id, last_number) -SELECT team_id, COALESCE(MAX(number), 0) FROM tasks GROUP BY team_id; - --- 7. NOT NULL + unique on tasks. -ALTER TABLE tasks ALTER COLUMN team_id SET NOT NULL; -ALTER TABLE tasks ALTER COLUMN number SET NOT NULL; -ALTER TABLE tasks ADD CONSTRAINT tasks_team_number_unique UNIQUE (team_id, number); - --- 8. Partial unique on external_key for mention-fallback resolution. -CREATE UNIQUE INDEX tasks_org_external_key_unique - ON tasks (organization_id, external_key) - WHERE external_key IS NOT NULL; - --- 9. Keep slug column + tasks_org_slug_unique for one release. --- Dual-write `${currentTeamKey}-${number}` so shipped CLI/renderer keep working. --- Drop in a follow-up migration after SDK consumers migrate. -``` - -Backfill notes: - -- **Linear-synced tasks lose their Linear-shaped identifier as the canonical key.** A task that was `ENG-42` in our slug column gets renumbered to (e.g.) `SUPER-103`. The Linear identifier is preserved in `external_key`. UI surfaces both as `SUPER-103 · ENG-42`. -- **Pre-existing tasks from non-linked Linear teams stay in our DB but stop receiving updates.** They become orphans. Surface as a one-time notification to admins ("X issues from Linear team `DESIGN` are no longer syncing — keep or delete?"). The actual cleanup UI is a follow-up. -- **Org-slug-derived team key**: empty/non-alphanumeric slugs fall back to `TASK`. The deploy script handles regex sanitization; SQL alone would be ugly. - -### Read paths - -#### Identifier resolution - -`task.byIdOrKey` (renamed from `byIdOrSlug`) accepts a UUID or a key like `SUPER-103`: - -``` -input = "SUPER-103" or UUID - -1. UUID? → tasks.id lookup. -2. Match /^([A-Za-z][A-Za-z0-9]*)-(\d+)$/i: - a. SELECT t.* FROM tasks t - JOIN team_keys tk ON tk.team_id = t.team_id - WHERE tk.organization_id = $org AND tk.key = $prefix AND t.number = $number; - → if hit and tk.retired_at IS NULL, return. - → if hit and tk.retired_at IS NOT NULL, return with redirected: true plus - the canonical identifier. - b. If no match, fallback: SELECT * FROM tasks - WHERE organization_id = $org AND external_key = $input; - → handles old `@task:ENG-42` mentions where ENG-42 is Linear's identifier. -3. Else: not found. -``` - -Single query for the common case. `team_keys` consulted whether the matched key is current or retired — no UNION. - -URL `/tasks/$taskId`: same logic. On redirect (`tk.retired_at IS NOT NULL`), client calls `navigate({ replace: true })` to the canonical key. - -#### Display projection - -```ts -db.select({ - task: tasks, - teamKey: teamKeys.key, -}) -.from(tasks) -.innerJoin(teamKeys, and( - eq(teamKeys.teamId, tasks.teamId), - isNull(teamKeys.retiredAt), -)) -``` - -`identifier = teamKey + '-' + task.number`. Computed in the projection step, not stored. Ship as `Task.identifier` on the SDK and in tRPC return shapes. `Task.slug` stays for one release as a deprecated alias = `identifier`. - -### Write paths - -#### Local task creation - -`packages/trpc/src/router/task/task.ts` (`createTask`): - -```ts -async function createTask(ctx, input) { - const organizationId = await requireActiveOrgMembership(ctx); - - return dbWs.transaction(async (tx) => { - const teamId = await resolveDefaultTeam(tx, organizationId); - const statusId = input.statusId - ? await getScopedStatusId(tx, organizationId, input.statusId, ...) - : await seedDefaultStatuses(organizationId, tx); - const assigneeId = input.assigneeId - ? await getScopedAssigneeId(tx, organizationId, input.assigneeId, ...) - : null; - - const [{ number }] = await tx - .insert(teamSequences) - .values({ teamId, lastNumber: 1 }) - .onConflictDoUpdate({ - target: teamSequences.teamId, - set: { lastNumber: sql`${teamSequences.lastNumber} + 1` }, - }) - .returning({ number: teamSequences.lastNumber }); - - const [task] = await tx.insert(tasks).values({ - organizationId, teamId, number, ...input, - }).returning(); - - const txid = await getCurrentTxid(tx); - return { task, txid }; - }).then(async (result) => { - if (result.task) syncTask(result.task.id); - return result; - }); -} -``` - -Deleted: -- `packages/shared/src/task-slug.ts` (entire file + test) -- `TASK_SLUG_RETRY_LIMIT` retry loop and `isConstraintError` helper -- Pre-insert `existingSlugs` SELECT - -`resolveDefaultTeam(tx, organizationId)`: -- Query for an existing non-archived team in the org. -- If none, INSERT one + initial `team_keys` row + `team_sequences` row, all in tx. -- Returns the team UUID. - -Lazy creation keeps orgs without tasks from getting empty default teams. - -#### Linear sync — outbound (local task → Linear issue) - -`apps/api/.../sync-task/route.ts`: - -The local task already has its canonical identifier (`SUPER-103`) from creation. The QStash job pushes it to the **linked Linear team** and writes the Linear identifier back into `external_key`. **No change to `team_id` or `number` after the Linear call.** Our identifier is stable; Linear's is metadata. - -```ts -const task = await db.query.tasks.findFirst({ - where: eq(tasks.id, taskId), - with: { team: true }, -}); - -if (task.team.externalProvider !== "linear" || !task.team.externalId) { - // Task's team isn't linked to Linear — outbound sync is a no-op - return; -} - -// push to Linear using task.team.externalId as teamId -// on success: -await db.update(tasks).set({ - externalProvider: "linear", - externalId: issue.id, - externalKey: issue.identifier, - externalUrl: issue.url, - lastSyncedAt: new Date(), - syncError: null, -}).where(eq(tasks.id, task.id)); -``` - -Drop the `slug: issue.identifier` line from the existing code. `linearConfig.newTasksTeamId` becomes redundant — the linked team IS the target. - -#### Linear sync — inbound (Linear webhook → our task) - -`apps/api/.../webhook/route.ts`: - -Filter inbound by linkage. Issues from Linear teams not linked to any Superset team are skipped: - -```ts -const linkedTeam = await db.query.teams.findFirst({ - where: and( - eq(teams.organizationId, connection.organizationId), - eq(teams.externalProvider, "linear"), - eq(teams.externalId, payload.data.team.id), - ), -}); - -if (!linkedTeam) { - await markEventSkipped(webhookEvent.id, "team_not_linked"); - return Response.json({ success: true, skipped: true }); -} - -const [{ number }] = /* same atomic increment, scoped to linkedTeam.id */; - -await tx.insert(tasks).values({ - organizationId: connection.organizationId, - teamId: linkedTeam.id, - number, - title: issue.title, - // … other fields … - externalProvider: "linear", - externalId: issue.id, - externalKey: issue.identifier, - externalUrl: issue.url, -}).onConflictDoUpdate({ - target: [tasks.organizationId, tasks.externalProvider, tasks.externalId], - set: { /* same fields, BUT do NOT change team_id or number on conflict */ }, -}); -``` - -Critical: the `onConflictDoUpdate.set` clause must NOT touch `team_id` or `number`. Once a task has them, they're stable for life. Re-running the webhook is idempotent for identifier. - -#### Initial sync - -`apps/api/.../initial-sync/route.ts`: - -`syncWorkflowStates` loop is unchanged — that handles `taskStatuses`. For tasks: only fetch issues for the linked Linear team(s): - -```ts -const linkedTeams = await db.query.teams.findMany({ - where: and(eq(teams.organizationId, organizationId), eq(teams.externalProvider, "linear")), -}); - -for (const ourTeam of linkedTeams) { - const issues = await fetchIssuesForTeam(client, ourTeam.externalId); - // map and insert with teamId: ourTeam.id, batched number allocation -} -``` - -`mapIssueToTask` (`apps/api/.../initial-sync/utils.ts:154`) drops `slug: issue.identifier`. Tasks are inserted without a number; the loop assigns numbers from the team sequence in batches: - -```ts -const [{ lastNumber: end }] = await tx - .insert(teamSequences) - .values({ teamId: ourTeam.id, lastNumber: issues.length }) - .onConflictDoUpdate({ - target: teamSequences.teamId, - set: { lastNumber: sql`${teamSequences.lastNumber} + ${issues.length}` }, - }) - .returning({ lastNumber: teamSequences.lastNumber }); -const start = end - issues.length + 1; -// issues[i] gets number = start + i -``` - -One round-trip for the whole batch. - -#### Linear disconnect - -`packages/trpc/src/router/integration/linear/linear.ts:32-119`: - -Today: deletes `tasks WHERE externalProvider='linear'` and `taskStatuses WHERE externalProvider='linear'`, remaps statuses, deletes the connection. - -Add: clear the team's external linkage (`UPDATE teams SET external_provider=NULL, external_id=NULL, external_key=NULL`) but keep the team and its keys. The org's default team and its number sequence persist regardless of integration state. Linear-synced tasks are still deleted; their numbers are not reused (matches Linear's own behavior re: deleted issue numbers). - -### Mention/search fallback for `external_key` - -Pre-migration `@task:ENG-42` mentions worked because `slug = 'ENG-42'`. Post-migration, `ENG-42` no longer matches `team_keys` (the team's key is `SUPER`). - -Resolution falls back to `external_key` (step 2b in the resolver). Partial unique index `(organization_id, external_key) WHERE external_key IS NOT NULL` guarantees uniqueness. - -UI display of Linear-synced tasks shows both: `SUPER-103 · ENG-42` (canonical · external). Search indexes both. - ---- - -## Workstream 2: OAuth token refresh (urgent) - -### What's broken - -Linear migrated all OAuth apps to short-lived (24h) access tokens with rotating refresh tokens on **2026-04-01**. Our code was written for the old long-lived model and was never updated. Specifically: - -1. **Refresh token never stored.** `apps/api/.../linear/callback/route.ts:76-77` types the response as `{ access_token, expires_in? }` — `refresh_token` isn't even read. `integrationConnections.refreshToken` column exists in the schema (`packages/db/src/schema/schema.ts:188`) but is never populated for Linear. -2. **Expiration never checked.** `getLinearClient` (`packages/trpc/src/router/integration/linear/utils.ts:38-53`) reads the row and constructs `new LinearClient({ accessToken: connection.accessToken })`. Doesn't look at `tokenExpiresAt`. Doesn't refresh. -3. **No refresh logic anywhere.** -4. **No connection-level error state.** When a token 401s, the error gets written to per-task `syncError`. The connection row still says "Connected." UI gives no signal. - -Result: any connection re-authed since 2026-04-01 silently breaks within 24h. This matches the symptoms users are reporting. - -### Fix - -#### Schema - -Add a connection-broken signal so the UI can surface "Reconnect Linear": - -```ts -// integrationConnections — add: -disconnectedAt: timestamp("disconnected_at"), // set when refresh returns invalid_grant or admin disconnects -disconnectReason: text("disconnect_reason"), // "invalid_grant" | "user_revoked" | "admin_disconnected" -``` - -#### Callback writes the full token triple - -`apps/api/.../linear/callback/route.ts`: - -```ts -const tokenData: { - access_token: string; - refresh_token: string; - expires_in: number; - token_type: string; - scope: string; -} = await tokenResponse.json(); - -const tokenExpiresAt = new Date(Date.now() + tokenData.expires_in * 1000); - -await db.insert(integrationConnections).values({ - // … existing fields … - accessToken: tokenData.access_token, - refreshToken: tokenData.refresh_token, // NEW — was never stored - tokenExpiresAt, - disconnectedAt: null, - disconnectReason: null, -}).onConflictDoUpdate({ - target: [integrationConnections.organizationId, integrationConnections.provider], - set: { - accessToken: tokenData.access_token, - refreshToken: tokenData.refresh_token, - tokenExpiresAt, - disconnectedAt: null, - disconnectReason: null, - // … etc - }, -}); -``` - -#### Refresh helper (single-flight via Postgres advisory lock) - -New file `apps/api/src/lib/integrations/linear/refresh-token.ts`: - -```ts -const REFRESH_LOCK_NAMESPACE = 0x4c494e52; // "LINR" — arbitrary, just needs to be stable - -export async function refreshLinearToken(connectionId: string): Promise { - await dbWs.transaction(async (tx) => { - // Single-flight: parallel refreshes will race and both invalidate each other, - // because Linear rotates refresh tokens. Advisory lock serializes per connection. - const lockKey = hashStringToInt(connectionId); - await tx.execute(sql`SELECT pg_advisory_xact_lock(${REFRESH_LOCK_NAMESPACE}, ${lockKey})`); - - const conn = await tx.query.integrationConnections.findFirst({ - where: eq(integrationConnections.id, connectionId), - }); - if (!conn?.refreshToken) { - throw new Error("No refresh token"); - } - - // Re-check expiry under lock — another process may have just refreshed. - if (conn.tokenExpiresAt && conn.tokenExpiresAt > new Date(Date.now() + 60_000)) { - return; // still valid for >60s, someone else refreshed - } - - const response = await fetch("https://api.linear.app/oauth/token", { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: conn.refreshToken, - client_id: env.LINEAR_CLIENT_ID, - client_secret: env.LINEAR_CLIENT_SECRET, - }), - }); - - if (!response.ok) { - const body = await response.json().catch(() => ({})); - if (body?.error === "invalid_grant") { - // Refresh token expired (inactivity) or user revoked the app. - await tx.update(integrationConnections).set({ - disconnectedAt: new Date(), - disconnectReason: "invalid_grant", - }).where(eq(integrationConnections.id, connectionId)); - } - throw new Error(`Linear token refresh failed: ${response.status}`); - } - - const data = await response.json(); - await tx.update(integrationConnections).set({ - accessToken: data.access_token, - refreshToken: data.refresh_token, // rotated; old one is now dead - tokenExpiresAt: new Date(Date.now() + data.expires_in * 1000), - }).where(eq(integrationConnections.id, connectionId)); - }); -} -``` - -#### `getLinearClient` refreshes proactively - -`packages/trpc/src/router/integration/linear/utils.ts`: - -```ts -export async function getLinearClient(organizationId: string): Promise { - const connection = await db.query.integrationConnections.findFirst({ - where: and( - eq(integrationConnections.organizationId, organizationId), - eq(integrationConnections.provider, "linear"), - ), - }); - - if (!connection || connection.disconnectedAt) return null; - - // Refresh if expired or expiring within 5 minutes. - const expiresIn = connection.tokenExpiresAt - ? connection.tokenExpiresAt.getTime() - Date.now() - : Infinity; - - if (expiresIn < 5 * 60 * 1000) { - await refreshLinearToken(connection.id); - // Re-fetch to get the fresh access token written by refreshLinearToken. - const refreshed = await db.query.integrationConnections.findFirst({ - where: eq(integrationConnections.id, connection.id), - }); - if (!refreshed || refreshed.disconnectedAt) return null; - return new LinearClient({ accessToken: refreshed.accessToken }); - } - - return new LinearClient({ accessToken: connection.accessToken }); -} -``` - -#### 401 fallback in API call sites - -The Linear SDK throws errors with status info. Wrap call sites that hit Linear (sync-task route, initial-sync, getTeams in tRPC) so a 401 attempts one refresh-then-retry before propagating: - -```ts -async function callLinear(orgId: string, fn: (client: LinearClient) => Promise): Promise { - let client = await getLinearClient(orgId); - if (!client) throw new Error("Linear not connected"); - - try { - return await fn(client); - } catch (e) { - if (isLinearAuthError(e)) { - const conn = await db.query.integrationConnections.findFirst({/* … */}); - if (conn) await refreshLinearToken(conn.id); - client = await getLinearClient(orgId); - if (!client) throw new Error("Linear connection broken"); - return await fn(client); - } - throw e; - } -} -``` - -#### One-time migration of legacy long-lived tokens - -Linear provides a [migration endpoint](https://linear.app/developers/oauth-2-0-authentication) to upgrade pre-rotation long-lived tokens to the new (access + refresh) pair. Backfill script in `packages/scripts/`: - -```ts -// For each connection where refreshToken IS NULL: -// POST to Linear's migration endpoint with the existing long-lived access_token -// Receive { access_token, refresh_token, expires_in } -// Atomically update the connection -// On error: mark disconnected (token may already be dead) -``` - -Run once at deploy time. Logs each connection's outcome. - -#### UI: surface broken connections - -Integrations page (`apps/web/...integrations/linear/page.tsx`): if `disconnectedAt IS NOT NULL`, replace the "Connected" state with a "Reconnect Linear" CTA that re-runs the OAuth flow. Show `disconnectReason` as supporting copy. - -### Why ship this first - -Token expiry is actively breaking users right now. The team-entity migration is more invasive but less urgent. Ordering: - -1. **Workstream 2 in its own PR**, fast turnaround. Schema changes are additive (`disconnectedAt`, `disconnectReason`, populate `refreshToken`). Backfill script runs at deploy. -2. **Workstream 1 + 3 together** in a follow-up PR. - ---- - -## Workstream 3: `actor=app` switch + connect/error UX - -### `actor=app` - -`apps/api/.../linear/connect/route.ts:50` — change the OAuth scope params to include `actor=app`. Issues created/updated by Superset will then appear as authored by the Superset OAuth app instead of by whoever connected. Standard for listed integrations (Slack, GitHub, Devin all do this). - -```ts -linearAuthUrl.searchParams.set("scope", "read,write,issues:create"); -linearAuthUrl.searchParams.set("actor", "app"); // NEW -``` - -One-line change. No data migration. Existing tokens keep working with their old actor; only newly authored issues after re-auth show "Superset" as author. Worth re-auth-ing once after rollout for consistency, but not required. - -### Integrations UI revamp - -`apps/web/src/app/(dashboard-legacy)/integrations/linear/`: - -Today's UI: -- Connect button → OAuth -- `TeamSelector` dropdown → "Where to create new tasks" → writes `linearConfig.newTasksTeamId` -- `ConnectionControls` → disconnect button -- `ErrorHandler` → reads `?error=` query param - -After: -- Connect button → OAuth (with `actor=app`) -- **"Link Linear team to Superset" picker** → writes `teams.external_provider/id/key` for the org's default team (replaces the `newTasksTeamId` mutation entirely) -- **Connection status panel** → shows `disconnectedAt`/`disconnectReason` from workstream 2, with "Reconnect" CTA when broken -- **Connect-flow consent copy** → "Issues from the Linear team you link will be visible to all members of your Superset organization" (documents the visibility-broadening risk for private Linear teams without engineering around it) -- **Orphaned-issues notice** → if there are tasks with `external_provider='linear'` but no longer matching the linked team's `externalId`, show "X issues from previously-linked teams are no longer syncing — keep or delete?" (UI for actually cleaning up is a follow-up) - -`linearConfig.newTasksTeamId` is dropped from the `LinearConfig` type. The `updateConfig` tRPC mutation is removed. Replaced by a `linkTeam` mutation that takes `(superseTeamId, linearTeamId, linearTeamKey, linearTeamName)` and writes the linkage. - ---- - -## Surface area (combined) - -| Area | Files | Notes | -|---|---|---| -| Schema | `packages/db/src/schema/{schema,relations,types}.ts` + 2 migrations + 1 deploy script | new tables, tasks alter, connection-broken fields, drop `LinearConfig.newTasksTeamId` | -| OAuth refresh | `apps/api/src/lib/integrations/linear/refresh-token.ts` (new) + `apps/api/.../linear/callback/route.ts` + `packages/trpc/src/router/integration/linear/utils.ts` + `packages/scripts/migrate-linear-tokens.ts` (new) | core refresh logic + 1-time migration | -| 401 retry wrapper | `apps/api/.../linear/jobs/{sync-task,initial-sync}/*` + `packages/trpc/.../linear/linear.ts` (getTeams) | call-site wrapping | -| Connect route | `apps/api/.../linear/connect/route.ts` | add `actor=app` | -| tRPC tasks | `packages/trpc/src/router/task/{task,schema}.ts` | rewrite createTask, byIdOrKey, drop bySlug | -| tRPC integrations | `packages/trpc/src/router/integration/linear/linear.ts` | replace `updateConfig` with `linkTeam`, disconnect tweak | -| Linear API routes | `apps/api/.../linear/{webhook,jobs/sync-task,jobs/initial-sync}/*` | drop slug writes, switch to team_id+number, filter by linkage | -| MCP tools | `packages/mcp/src/tools/tasks/*` (5 files) + `packages/mcp-v2/src/tools/tasks/*` | input descriptions, slug→identifier | -| SDK | `packages/sdk/src/resources/tasks.ts` | add `identifier`, deprecate `slug` | -| Desktop UI | TasksTable, KanbanCard, TaskDetailHeader, TaskActionMenu, RunInWorkspacePopover, IssueLinkCommand, LinkedTaskChip, ChatInputFooter, $taskId/page.tsx | display + nav | -| Mention parser | `apps/desktop/.../parseUserMentions/parseUserMentions.ts` | rename output field; logic unchanged | -| Web integrations UI | `apps/web/.../integrations/linear/{page.tsx, components/*}` | reskin TeamSelector → LinearTeamLinker, add disconnected state, consent copy | -| local-db | `packages/local-db/src/schema/schema.ts` + sqlite migration | parallel teams/team_keys/team_sequences mirror | -| Agent launch | `packages/shared/src/agent-launch.ts` | `task.slug` → `task.identifier` for prompt filenames + workspace names | -| Tests | delete `task-slug.test.ts`; new tests for sequence allocation, identifier resolution, retired-key redirect, external_key fallback, refresh single-flight, 401 retry | | - -Estimated 1.5k–2k LOC across both PRs. - ---- - -## Phases - -### PR 1 — Workstream 2 (OAuth refresh, urgent) - -1. Schema additions (`refreshToken` populated, `disconnectedAt`, `disconnectReason`). -2. Callback updated to store refresh token + expiry. -3. `refreshLinearToken` helper with advisory-lock single-flight. -4. `getLinearClient` proactive refresh. -5. 401 retry wrapper at call sites. -6. Deploy-time backfill script for legacy long-lived tokens. -7. UI: surface disconnected state with "Reconnect" CTA. - -Independently shippable. No dependency on workstream 1. - -### PR 2 — Workstreams 1 + 3 (teams + numbering + actor=app + UI revamp) - -1. Schema migration (teams, team_keys, team_sequences, tasks alter) + deploy script. -2. Backend writers + readers switch to identifier. tRPC, MCP tools, SDK adds `identifier` as canonical. `slug` still dual-written. -3. Linear sync routes filter by linkage; outbound uses `team.externalId`. -4. `actor=app` switch in connect route. -5. Web integrations UI revamp. -6. Desktop UI + agent-launch switch to identifier. - -### PR 3 — Cleanup (follow-up after one release) - -1. Drop `tasks.slug` column. -2. Drop SDK `slug` deprecation alias. - ---- - -## Open decisions (defaulted, flag if wrong) - -1. **`@task:ENG-42` fallback via `external_key`**: yes, with partial unique index. -2. **PR titles + branches use our identifier (`SUPER-N`)**, not Linear's. Linear users see different identifiers between our app and Linear's UI — `external_key` is the bridge. -3. **`slug` deprecated for one release**, dual-written, then dropped. -4. **Periodic Linear team poll** for opportunistic key sync: deferred. -5. **Team key derivation on org creation**: uppercase + sanitize org slug, fallback to `TASK` if empty. -6. **No multi-team UI for now**: single default team auto-created lazily on first task. -7. **`actor=app`** for new auths; pre-existing tokens keep their old actor until re-auth. -8. **Orphaned-issue cleanup** on link change: notify only, defer the actual delete UI. - ---- - -## Out of scope / follow-ups - -- Multi-team UI (create/rename/archive teams in org settings). -- Per-team Linear linkage at scale (multiple Superset teams each linking to different Linear teams). -- Team key rename UI (with redirect-history notification to users). -- Periodic Linear teams poll for opportunistic detection of Linear-side renames. -- Drop `tasks.slug` column (separate migration after SDK rollout). -- Cleanup UI for orphaned Linear-synced tasks (post-link-change). -- GitHub integration analog (`#123` style identifiers — would use the same `external_key` fallback mechanism). -- Linear integration directory submission (separate workstream — depends on this work landing first).