Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
"@hookform/resolvers": "^5.2.2",
"@lezer/highlight": "^1.2.3",
"@mastra/core": "1.26.0-alpha.3",
"@paper-design/shaders-react": "^0.0.76",
"@parcel/watcher": "^2.5.6",
"@pierre/diffs": "1.1.3",
"@radix-ui/react-dialog": "^1.1.15",
Expand Down
123 changes: 123 additions & 0 deletions apps/desktop/plans/20260421-v2-main-workspace-creation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# V2 Main Workspace Creation

## Problem

V1 auto-creates a singleton `type='branch'` workspace per project via
`ensureMainWorkspace` (`apps/desktop/src/lib/trpc/routers/projects/projects.ts:174`),
called inline from five mutations. V2 has no equivalent — `v2_workspaces` rows only
come from `workspaceCreation`, which always produces worktrees. Users finish
`project.setup` and see an empty sidebar.

## Goals

- Each `(projectId, hostId)` gets one "main" workspace whose path == the host's
`repoPath`. Multiple mains per project (one per host) are allowed.
- Created automatically on project create/setup success — no type picker, no UI
step.
- Main workspaces are real v2 workspace rows but are not deletable through normal
workspace delete flows. Users can remove them from the sidebar; host/project
removal owns deleting the cloud row.

## Design

### Schema

Add to `v2_workspaces` (`packages/db/src/schema/schema.ts:524`):

```ts
type: v2WorkspaceType().notNull().default("worktree"),
```

Backed by a `pgEnum("v2_workspace_type", ["main", "worktree"])` for DB-level
enforcement, matching the `v2ClientType` / `v2UsersHostRole` precedent.
Partial unique index: `(projectId, hostId) WHERE type = 'main'`.

Column name `type` over `isMain: boolean` so the workspace-creation modal's
contract is explicit — it only ever writes `'worktree'`.

No `pinnedAt`/pinning column. Sidebar membership remains renderer-local in
`v2WorkspaceLocalState`, and main workspace auto-visibility is derived from
`type='main'`, current host, and project sidebar membership.

### `ensureMainWorkspace` helper (host-service)

New helper in `packages/host-service/src/trpc/router/project/`. Given
`(projectId, repoPath)`:

1. `ensureV2Host` (reuse call from `workspace-creation.ts:372`).
2. Resolve current branch: `git symbolic-ref --short HEAD` at `repoPath`.
3. `ctx.api.v2Workspace.create.mutate({ ..., type: "main", branch, name: branch })`.
Skip if the unique index rejects — idempotent.
4. Insert local `workspaces` row (`packages/host-service/src/db/schema.ts:95`)
with `worktreePath = repoPath`. The column is named `worktreePath` but holds
any absolute checkout path; for main that's the repo root.

Log-and-continue on failure: any cloud/local error is caught, logged, and
swallowed (the helper returns `null`). `project.setup` doesn't regress when a
transient cloud blip hits — the startup sweep backfills on the next boot.
Idempotency via the partial unique index handles duplicates on retry.

If a main row already exists and the repo root branch changed, cloud branch
metadata is refreshed. The display name only follows the branch while it still
equals the previous branch name; user-renamed main workspaces keep their label.

### Call sites

1. **`project.create` success** — after cloning/importing a new cloud project and
persisting the local project row.
2. **`project.setup` success** (`packages/host-service/src/trpc/router/project/project.ts:134`) —
after `persistLocalProject` in both `clone` and `import` branches.
3. **Workspace creation/adoption/checkout preflight** — before creating or
adopting a worktree, call the helper for the local project. This keeps normal
workspace flows self-healing if setup returned before main creation completed.
4. **Host-service startup sweep** — on boot, iterate local `projects` rows and
call the helper for each. Idempotent via the unique index, so it's safe on
every boot; in practice only does work once per pre-existing project. This is
the recovery path for projects already set up before this change ships.

### Sidebar and modal

Workspace-creation modal continues to write worktree workspaces only. Project
setup/create responses include `mainWorkspaceId` when the helper succeeds, so the
renderer can add the main workspace to the sidebar immediately.

Main workspaces are auto-visible when:

- the workspace is `type='main'`
- the host is the current machine
- the project is in the sidebar
- no renderer-local hidden tombstone exists for that workspace

Removing a main workspace from the sidebar sets
`v2WorkspaceLocalState.sidebarState.isHidden = true` instead of deleting the local
state row. This prevents auto-visibility from immediately re-adding the row and
keeps hidden-row filtering centralized through the dashboard sidebar local-state
visibility helper.

### Delete behavior

`v2Workspace.delete` rejects `type='main'` rows. Regular workspace cleanup/delete
flows are worktree-only. Host project removal uses the explicit
`v2Workspace.deleteMainForHost` endpoint for the repo-root main row, then removes
the local project row. Cloud project deletion still cascades through the database
like any other project-level delete.

## Migration

`bunx drizzle-kit generate --name="v2_workspaces_main_type"`. No SQL backfill —
the cloud doesn't know `repoPath` or current branch. Existing setups are filled
in by the startup sweep the first time the updated host-service boots.

## Rollout

Cloud/API before desktop (per deploy ordering). Verify:

- Fresh `project.setup` creates exactly one main row + local row.
- Re-running `project.setup` on the same host is idempotent.
- Two hosts on one project each get their own main row.
- Startup sweep fills in a main row for a project set up pre-update, without
duplicating on subsequent boots.
- `project.remove` cleans up the main row alongside worktrees.
- Normal workspace delete and cleanup flows reject main workspaces.
- Removing a main workspace from the sidebar hides it and does not re-add it on
the next sidebar recompute.
12 changes: 5 additions & 7 deletions apps/desktop/plans/20260422-2100-v1-to-v2-port.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,13 @@ No wizard steps, no drag-and-drop, no per-row edit UI. If users want to rename a

## Duplicate project prevention

v2's schema has two uniques per org:
v2's schema only requires unique slugs per org:
- `(organization_id, slug)`
- `(organization_id, lower(repo_clone_url))` — NULLs don't collide

The migration relies on both, plus explicit dedup logic:
`repo_clone_url` is intentionally not unique. Multiple v2 projects may point at
the same GitHub repository.

The migration relies on explicit dedup logic:

### Happy-path flow per project

Expand All @@ -101,10 +103,6 @@ if parsed_url:
else:
try v2Project.create({ orgId, name, slug, repoCloneUrl: parsed_url })
on success: record mapping, continue
on UNIQUE_VIOLATION(repo_clone_url):
# race: someone else in the org just created it
existing = v2Project.findByGitHubRemote(...) # should now hit
link to existing
on UNIQUE_VIOLATION(slug):
retry with slug-2, slug-3, ... (bounded: 10 attempts)
else:
Expand Down
6 changes: 3 additions & 3 deletions apps/desktop/src/lib/trpc/routers/auth/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import { AUTH_PROVIDERS } from "@superset/shared/constants";
import { getDeviceName, getHashedDeviceId } from "@superset/shared/device-info";
import { getHostId, getHostName } from "@superset/shared/host-info";
import { observable } from "@trpc/server/observable";
import { shell } from "electron";
import { env } from "main/env.main";
Expand All @@ -23,8 +23,8 @@ export const createAuthRouter = () => {
getStoredToken: publicProcedure.query(() => loadToken()),

getDeviceInfo: publicProcedure.query(() => ({
deviceId: getHashedDeviceId(),
deviceName: getDeviceName(),
deviceId: getHostId(),
deviceName: getHostName(),
})),

persistToken: publicProcedure
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
randomBytes,
scryptSync,
} from "node:crypto";
import { getMachineId } from "@superset/shared/device-info";
import { getMachineId } from "@superset/shared/host-info";

const ALGORITHM = "aes-256-gcm";
const KEY_LENGTH = 32;
Expand Down
19 changes: 1 addition & 18 deletions apps/desktop/src/lib/trpc/routers/migration/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
workspaces,
worktrees,
} from "@superset/local-db";
import { and, eq, isNotNull, isNull } from "drizzle-orm";
import { eq, isNotNull, isNull } from "drizzle-orm";
import { localDb } from "main/lib/local-db";
import { z } from "zod";
import { publicProcedure, router } from "../..";
Expand Down Expand Up @@ -96,22 +96,5 @@ export const createMigrationRouter = () => {
.where(eq(v1MigrationState.organizationId, input.organizationId))
.run();
}),

findMigrationByOtherOrg: publicProcedure
.input(z.object({ organizationId: z.string().min(1) }))
.query(({ input }) => {
const other = localDb
.select({ organizationId: v1MigrationState.organizationId })
.from(v1MigrationState)
.where(
and(
eq(v1MigrationState.kind, "project"),
eq(v1MigrationState.status, "success"),
),
)
.all()
.find((row) => row.organizationId !== input.organizationId);
return other?.organizationId ?? null;
}),
});
};
22 changes: 16 additions & 6 deletions apps/desktop/src/main/lib/host-service-coordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { EventEmitter } from "node:events";
import * as fs from "node:fs";
import path from "node:path";
import { settings } from "@superset/local-db";
import { getDeviceName, getHashedDeviceId } from "@superset/shared/device-info";
import { getHostId, getHostName } from "@superset/shared/host-info";
import { app } from "electron";
import { env } from "main/env.main";
import semver from "semver";
Expand All @@ -30,8 +30,18 @@ import { localDb } from "./local-db";
import { killPersistentScope, spawnPersistent } from "./process-persistence";
import { HOOK_PROTOCOL_VERSION } from "./terminal/env";

/** Minimum host-service version this app can work with. */
const MIN_HOST_SERVICE_VERSION = "0.1.0";
/**
* Minimum host-service version this app can work with. Bumping this forces
* the coordinator to kill + respawn any adopted service older than this,
* which is how we prevent the renderer from talking to a stale host-service
* that's missing newly-added procedures/params.
*
* 0.3.0: host-service registers via cloud `host.ensure` (was
* `device.ensureV2Host`); v2_hosts/v2_users_hosts/v2_workspaces use
* machineId text instead of uuid surrogates.
* 0.2.0: `workspaceCreation.adopt` gained optional `worktreePath`.
*/
const MIN_HOST_SERVICE_VERSION = "0.3.0";

export type HostServiceStatus = "starting" | "running" | "stopped";

Expand Down Expand Up @@ -77,7 +87,7 @@ export class HostServiceCoordinator extends EventEmitter {
ReturnType<typeof setInterval>
>();
private scriptPath = path.join(__dirname, "host-service.js");
private machineId = getHashedDeviceId();
private machineId = getHostId();
private devReloadWatcher: fs.FSWatcher | null = null;

async start(
Expand Down Expand Up @@ -514,8 +524,8 @@ export class HostServiceCoordinator extends EventEmitter {
...(process.env as Record<string, string>),
ELECTRON_RUN_AS_NODE: "1",
ORGANIZATION_ID: organizationId,
DEVICE_CLIENT_ID: getHashedDeviceId(),
DEVICE_NAME: getDeviceName(),
HOST_CLIENT_ID: getHostId(),
HOST_NAME: getHostName(),
HOST_SERVICE_SECRET: secret,
HOST_SERVICE_PORT: String(port),
HOST_MANIFEST_DIR: organizationDir,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { useHostTargetUrl } from "./useHostTargetUrl";
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
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();
const { data: session } = authClient.useSession();
const activeOrganizationId = session?.session?.activeOrganizationId ?? null;

return useMemo(() => {
if (!hostTarget) return null;
if (hostTarget.kind === "local") return activeHostUrl;
if (!activeOrganizationId) return null;
const routingKey = buildHostRoutingKey(
activeOrganizationId,
hostTarget.hostId,
);
return `${env.RELAY_URL}/hosts/${routingKey}`;
}, [hostTarget, activeOrganizationId, activeHostUrl]);
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { buildHostRoutingKey } from "@superset/shared/host-routing";
import { eq } from "@tanstack/db";
import { useLiveQuery } from "@tanstack/react-db";
import { useMemo } from "react";
Expand All @@ -18,10 +19,11 @@ export function useWorkspaceHostUrl(workspaceId: string | null): string | null {
q
.from({ workspaces: collections.v2Workspaces })
.leftJoin({ hosts: collections.v2Hosts }, ({ workspaces, hosts }) =>
eq(workspaces.hostId, hosts.id),
eq(workspaces.hostId, hosts.machineId),
)
.where(({ workspaces }) => eq(workspaces.id, workspaceId ?? ""))
.select(({ workspaces, hosts }) => ({
organizationId: workspaces.organizationId,
hostId: workspaces.hostId,
hostMachineId: hosts?.machineId ?? null,
})),
Expand All @@ -33,6 +35,7 @@ export function useWorkspaceHostUrl(workspaceId: string | null): string | null {
return useMemo(() => {
if (!match) return null;
if (match.hostMachineId === machineId) return activeHostUrl;
return `${env.RELAY_URL}/hosts/${match.hostId}`;
const routingKey = buildHostRoutingKey(match.organizationId, match.hostId);
return `${env.RELAY_URL}/hosts/${routingKey}`;
}, [match, machineId, activeHostUrl]);
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { useCallback } from "react";
import type { FileMentionSearchFn } from "renderer/components/MarkdownEditor/components/FileMention";
import { env } from "renderer/env.renderer";
import { useHostTargetUrl } 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";
import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider";

const SEARCH_LIMIT = 15;

Expand All @@ -14,12 +13,7 @@ export function useProjectFileSearch({
hostTarget: WorkspaceHostTarget;
projectId: string | null;
}): FileMentionSearchFn | undefined {
const { activeHostUrl } = useLocalHostService();

const hostUrl =
hostTarget.kind === "local"
? activeHostUrl
: `${env.RELAY_URL}/hosts/${hostTarget.hostId}`;
const hostUrl = useHostTargetUrl(hostTarget);

return useCallback<FileMentionSearchFn>(
async (query) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ function AutomationsPage() {
(q) =>
q
.from({ h: collections.v2Hosts })
.select(({ h }) => ({ id: h.id, name: h.name })),
.select(({ h }) => ({ machineId: h.machineId, name: h.name })),
[collections.v2Hosts],
);

Expand Down Expand Up @@ -167,7 +167,10 @@ function AutomationsPage() {
const hostsById = useMemo(
() =>
new Map(
(hostRows as Pick<SelectV2Host, "id" | "name">[]).map((h) => [h.id, h]),
(hostRows as Pick<SelectV2Host, "machineId" | "name">[]).map((h) => [
h.machineId,
h,
]),
),
[hostRows],
);
Expand Down
Loading
Loading