diff --git a/apps/api/src/onboarding-scan.test.ts b/apps/api/src/onboarding-scan.test.ts index ea2e625..c919310 100644 --- a/apps/api/src/onboarding-scan.test.ts +++ b/apps/api/src/onboarding-scan.test.ts @@ -1,11 +1,42 @@ -import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, realpath, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import type * as db from "@rakazo/db"; +import { SpaceLimitError } from "@rakazo/db"; import { afterEach, describe, expect, it, vi } from "vitest"; import { getScanStatus, startOnboardingScan } from "./onboarding-scan.js"; -const owner = { userId: "user-owner", isDeploymentOwner: true }; -const member = { userId: "user-member", isDeploymentOwner: false }; +const createSpaceForMember = vi.hoisted(() => vi.fn()); +vi.mock("@rakazo/db", async (original) => ({ + ...(await original()), + createSpaceForMember, +})); + +const owner = { userId: "user-owner", spaceId: "space-owner", isDeploymentOwner: true }; +const member = { userId: "user-member", spaceId: "space-member", isDeploymentOwner: false }; + +type SpaceUpdate = { where: { id: string }; data: { workspacePath: string } }; + +/** The spaces the scan reads and links; new ones come from `createSpaceForMember`. */ +function fakeDatabase({ spaceLimit = Number.POSITIVE_INFINITY } = {}) { + const spaces: Array<{ id: string; name: string; workspacePath: string | null }> = []; + createSpaceForMember.mockImplementation(async (_prisma: unknown, input: { name: string }) => { + if (spaces.length >= spaceLimit) throw new SpaceLimitError(); + const space = { id: `space-${spaces.length + 1}`, name: input.name, workspacePath: null }; + spaces.push(space); + return { id: space.id, name: space.name }; + }); + const prisma = { + space: { + findMany: async () => spaces.filter((space) => space.workspacePath), + update: async ({ where, data }: SpaceUpdate) => { + const space = spaces.find((candidate) => candidate.id === where.id); + if (space) space.workspacePath = data.workspacePath; + }, + }, + } as unknown as db.PrismaClient; + return { prisma, spaces }; +} async function scanHomeWithOneFile(): Promise { const home = await mkdtemp(join(tmpdir(), "evoo-scan-")); @@ -14,6 +45,12 @@ async function scanHomeWithOneFile(): Promise { return home; } +async function scanHomeWithRepos(...repos: string[]): Promise { + const home = await scanHomeWithOneFile(); + for (const repo of repos) await mkdir(join(home, repo, ".git"), { recursive: true }); + return home; +} + async function completed(scanId: string, userId: string) { return await vi.waitFor( async () => { @@ -25,13 +62,21 @@ async function completed(scanId: string, userId: string) { ); } +/** The emulated runtime that the shared test setup pins keeps the scan simulated. */ +function allowHostScan() { + vi.stubEnv("AGENT_RUNTIME", ""); + vi.stubEnv("SANDBOX_PROVIDER", ""); +} + describe("onboarding scan", () => { afterEach(() => { vi.unstubAllEnvs(); + createSpaceForMember.mockReset(); }); it("hides a scan from every account except the one that started it", async () => { - const { scanId } = await startOnboardingScan({ actor: member }, {}); + const { prisma } = fakeDatabase(); + const { scanId } = await startOnboardingScan({ actor: member }, prisma); await expect(getScanStatus(scanId, owner.userId)).rejects.toThrow(/Scan not found/); await expect(getScanStatus(scanId, member.userId)).resolves.toMatchObject({ status: expect.any(String), @@ -40,25 +85,77 @@ describe("onboarding scan", () => { }); it("never reads the host without a configured scan home", async () => { - const { scanId } = await startOnboardingScan({ actor: owner }, {}); + const { prisma } = fakeDatabase(); + const { scanId } = await startOnboardingScan({ actor: owner }, prisma); const status = await completed(scanId, owner.userId); expect(status.status).toBe("complete"); expect(status.result).toMatchObject({ localRepos: [], fileSummary: { totalFiles: 0 } }); }); it("lets only the deployment owner discover the configured scan home", async () => { - // The shared test setup pins the emulated runtime, which also keeps the scan simulated. - vi.stubEnv("AGENT_RUNTIME", ""); - vi.stubEnv("SANDBOX_PROVIDER", ""); - const scanHome = await scanHomeWithOneFile(); + allowHostScan(); + const scanHome = await scanHomeWithRepos("Projects/app"); + const { prisma } = fakeDatabase(); - const asMember = await startOnboardingScan({ actor: member, scanHome }, {}); + const asMember = await startOnboardingScan({ actor: member, scanHome }, prisma); const memberStatus = await completed(asMember.scanId, member.userId); expect(memberStatus.result).toMatchObject({ fileSummary: { totalFiles: 0 } }); + expect(createSpaceForMember).not.toHaveBeenCalled(); - const asOwner = await startOnboardingScan({ actor: owner, scanHome }, {}); + const asOwner = await startOnboardingScan({ actor: owner, scanHome }, prisma); const ownerStatus = await completed(asOwner.scanId, owner.userId); expect(ownerStatus.status).toBe("complete"); expect(ownerStatus.result).toMatchObject({ fileSummary: { totalFiles: 1 } }); }); + + it("links each repository of the deployment owner to one project of its own", async () => { + allowHostScan(); + const scanHome = await scanHomeWithRepos("Projects/app", ".config/nvim"); + const app = await realpath(join(scanHome, "Projects", "app")); + const { prisma, spaces } = fakeDatabase(); + + const first = await startOnboardingScan({ actor: owner, scanHome }, prisma); + const firstStatus = await completed(first.scanId, owner.userId); + expect(firstStatus.status).toBe("complete"); + expect(firstStatus.result).toMatchObject({ + localRepos: [{ name: "app" }], + createdProjects: [{ spaceId: "space-1", name: "app", path: app }], + }); + expect(createSpaceForMember).toHaveBeenCalledWith(prisma, { + currentSpaceId: owner.spaceId, + userId: owner.userId, + name: "app", + }); + expect(spaces).toEqual([{ id: "space-1", name: "app", workspacePath: app }]); + + // Scanning again finds the linked project instead of creating a second one. + const again = await startOnboardingScan({ actor: owner, scanHome }, prisma); + const againStatus = await completed(again.scanId, owner.userId); + expect(againStatus.result).toMatchObject({ createdProjects: [] }); + expect(createSpaceForMember).toHaveBeenCalledTimes(1); + }); + + it("joins a running scan instead of linking its repositories twice", async () => { + allowHostScan(); + const scanHome = await scanHomeWithRepos("Projects/app"); + const { prisma, spaces } = fakeDatabase(); + + const first = await startOnboardingScan({ actor: owner, scanHome }, prisma); + const second = await startOnboardingScan({ actor: owner, scanHome }, prisma); + expect(second.scanId).toBe(first.scanId); + await completed(first.scanId, owner.userId); + expect(spaces).toHaveLength(1); + }); + + it("stops creating projects at the space limit and still completes", async () => { + allowHostScan(); + const scanHome = await scanHomeWithRepos("Projects/a", "Projects/b", "Projects/c"); + const { prisma } = fakeDatabase({ spaceLimit: 1 }); + + const { scanId } = await startOnboardingScan({ actor: owner, scanHome }, prisma); + const status = await completed(scanId, owner.userId); + expect(status.status).toBe("complete"); + expect(status.result).toMatchObject({ createdProjects: [{ spaceId: "space-1" }] }); + expect(createSpaceForMember).toHaveBeenCalledTimes(2); + }); }); diff --git a/apps/api/src/onboarding-scan.ts b/apps/api/src/onboarding-scan.ts index 8d1c786..765336b 100644 --- a/apps/api/src/onboarding-scan.ts +++ b/apps/api/src/onboarding-scan.ts @@ -1,9 +1,16 @@ // Onboarding scan — real filesystem scan, Git analysis, and profile compilation. // Runs synchronously on the host machine (no sandbox needed). import { spawn } from "node:child_process"; -import { access, readdir, stat } from "node:fs/promises"; -import { dirname, join } from "node:path"; +import { access, readdir, realpath, stat } from "node:fs/promises"; +import { basename, dirname, join } from "node:path"; import type { Actor } from "@rakazo/contracts"; +import type { PrismaClient } from "@rakazo/db"; +import { + createSpaceForMember, + InvalidSpaceNameError, + SpaceLimitError, + wireProjectWorkspace, +} from "@rakazo/db"; // --------------------------------------------------------------------------- // Types @@ -54,10 +61,9 @@ interface ProfileSummary { } interface CreatedProject { - id: string; - name: string; spaceId: string; - description: string; + name: string; + path: string; } interface ScanRecord { @@ -115,8 +121,10 @@ async function runSimulatedScan(record: ScanRecord) { record.completedAt = new Date(); } +type ScanActor = Pick; + export interface OnboardingScanRequest { - actor: Pick; + actor: ScanActor; botId?: string; /** Home-style directory to discover. Unset keeps the scan off the host filesystem. */ scanHome?: string; @@ -124,8 +132,12 @@ export interface OnboardingScanRequest { export async function startOnboardingScan( { actor, botId, scanHome }: OnboardingScanRequest, - prisma: any, + prisma: PrismaClient, ): Promise<{ scanId: string }> { + // A repeated start (a remount, a retry) joins the running scan, so no repository is linked twice. + for (const running of scanRegistry.values()) { + if (running.ownerUserId === actor.userId && !running.completedAt) return { scanId: running.id }; + } const scanId = `scan_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; const record: ScanRecord = { id: scanId, @@ -154,7 +166,7 @@ export async function startOnboardingScan( await runSimulatedScan(record); return; } - const result = await executeFullScan(scanHome, botId ?? "", prisma); + const result = await executeFullScan(scanHome, actor, prisma); record.status = "complete"; record.progress = 100; record.message = "Scan complete — profile ready"; @@ -189,7 +201,11 @@ export async function getScanStatus( // Full scan pipeline // --------------------------------------------------------------------------- -async function executeFullScan(homeDir: string, _botId: string, prisma: any): Promise { +async function executeFullScan( + homeDir: string, + actor: ScanActor, + prisma: PrismaClient, +): Promise { // Phase 1: Local file scan const [fileSummary, projectFolders] = await scanFiles(homeDir); @@ -200,8 +216,8 @@ async function executeFullScan(homeDir: string, _botId: string, prisma: any): Pr // Phase 4: Profile compilation from findings const profileSummary = compileProfile(localRepos, fileSummary, projectFolders); - // Phase 6: Project folder creation - const createdProjects = await createProjectFolders(localRepos, prisma); + // Phase 6: Projects for discovered repositories + const createdProjects = await createProjects(localRepos, actor, prisma); // Phase 7: Sub-agent role creation const subAgentRoles = generateSubAgentRoles(profileSummary, localRepos); @@ -413,9 +429,9 @@ async function findGitDirectories(dir: string, depth: number = 0): Promise { +/** Give each discovered repository a project linked to its folder, unless one already links it. */ +async function createProjects( + repos: RepoInfo[], + actor: ScanActor, + prisma: PrismaClient, +): Promise { + const linked = await prisma.space.findMany({ + where: { workspacePath: { not: null }, memberships: { some: { userId: actor.userId } } }, + select: { workspacePath: true }, + }); + const linkedFolders = new Set(linked.map((space) => space.workspacePath)); const created: CreatedProject[] = []; for (const repo of repos) { + // Compare and link the real folder, so the next scan recognizes the projects of this one. + const folder = await realpath(repo.path).catch(() => null); + if (!folder || linkedFolders.has(folder)) continue; + let space: { id: string; name: string }; try { - // Check if space already exists - const existing = await prisma.space.findFirst({ where: { title: repo.name } }); - if (existing) { - created.push({ - id: existing.id, - name: repo.name, - spaceId: existing.id, - description: repo.remote || repo.path, - }); - continue; - } - // Create new space - const space = await prisma.space.create({ - data: { title: repo.name, path: repo.path }, + space = await createSpaceForMember(prisma, { + currentSpaceId: actor.spaceId, + userId: actor.userId, + name: basename(folder), }); - created.push({ - id: space.id, - name: repo.name, - spaceId: space.id, - description: repo.remote || repo.path, - }); - } catch { - // Space might already exist or DB unavailable — skip + } catch (error) { + if (error instanceof SpaceLimitError) break; + if (error instanceof InvalidSpaceNameError) continue; + throw error; } + const workspace = await wireProjectWorkspace(prisma, { + spaceId: space.id, + workspacePath: folder, + }); + created.push({ spaceId: space.id, name: space.name, path: workspace.path }); } return created; diff --git a/packages/core/src/git-checkout.ts b/packages/core/src/git-checkout.ts index ccf1ff7..210f4e3 100644 --- a/packages/core/src/git-checkout.ts +++ b/packages/core/src/git-checkout.ts @@ -1,19 +1,10 @@ +// Server-only. Kept out of the package index so browser bundles never load `node:child_process`. +import { spawn } from "node:child_process"; import { access } from "node:fs/promises"; import path from "node:path"; import { gitStatusArgv, gitUntrackedFilesArgv, parseGitNameOnly } from "./compose-update.js"; import { DEFAULT_UPDATE_REMOTE, parseGitStatusPorcelain } from "./self-update.js"; -// Lazy import to avoid bundling `node:child_process` in browser bundles. -// `defaultGitRunner` is server-only; lazy-loading ensures tree-shaking works. -type SpawnFn = typeof import("node:child_process").spawn; -let _spawn: SpawnFn | undefined; -const getSpawn = (): SpawnFn => { - if (_spawn === undefined) { - _spawn = require("node:child_process").spawn as SpawnFn; - } - return _spawn; -}; - export interface GitCheckoutStatus { present: boolean; remoteUrl: string | null; @@ -32,7 +23,6 @@ export type GitRunner = (args: string[], cwd: string) => Promise { return new Promise((resolve) => { - const spawn = getSpawn(); const child = spawn("git", args, { cwd, env: process.env }); let output = ""; child.stdout.on("data", (chunk: Buffer | string) => {