Compare commits
1 Commits
claude/ama
...
claude/ele
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2797eaf6ca |
@ -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<typeof db>()),
|
||||
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<string> {
|
||||
const home = await mkdtemp(join(tmpdir(), "evoo-scan-"));
|
||||
@ -14,6 +45,12 @@ async function scanHomeWithOneFile(): Promise<string> {
|
||||
return home;
|
||||
}
|
||||
|
||||
async function scanHomeWithRepos(...repos: string[]): Promise<string> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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<Actor, "userId" | "spaceId" | "isDeploymentOwner">;
|
||||
|
||||
export interface OnboardingScanRequest {
|
||||
actor: Pick<Actor, "userId" | "isDeploymentOwner">;
|
||||
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<ScanResult> {
|
||||
async function executeFullScan(
|
||||
homeDir: string,
|
||||
actor: ScanActor,
|
||||
prisma: PrismaClient,
|
||||
): Promise<ScanResult> {
|
||||
// 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<strin
|
||||
if (entry.name === ".git") {
|
||||
results.push(dirname(fullPath));
|
||||
} else if (
|
||||
// Dot folders hold tools and caches (shell and editor plugins), not the person's projects.
|
||||
!entry.name.startsWith(".") &&
|
||||
entry.name !== "node_modules" &&
|
||||
entry.name !== ".git" &&
|
||||
entry.name !== ".venv" &&
|
||||
entry.name !== "dist"
|
||||
) {
|
||||
results.push(...(await findGitDirectories(fullPath, depth + 1)));
|
||||
@ -622,38 +638,43 @@ function generateBriefing(
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 5: Project folder creation
|
||||
// Phase 5: Projects for discovered repositories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function createProjectFolders(repos: RepoInfo[], prisma: any): Promise<CreatedProject[]> {
|
||||
/** 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<CreatedProject[]> {
|
||||
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,
|
||||
space = await createSpaceForMember(prisma, {
|
||||
currentSpaceId: actor.spaceId,
|
||||
userId: actor.userId,
|
||||
name: basename(folder),
|
||||
});
|
||||
continue;
|
||||
} catch (error) {
|
||||
if (error instanceof SpaceLimitError) break;
|
||||
if (error instanceof InvalidSpaceNameError) continue;
|
||||
throw error;
|
||||
}
|
||||
// Create new space
|
||||
const space = await prisma.space.create({
|
||||
data: { title: repo.name, path: repo.path },
|
||||
});
|
||||
created.push({
|
||||
id: space.id,
|
||||
name: repo.name,
|
||||
const workspace = await wireProjectWorkspace(prisma, {
|
||||
spaceId: space.id,
|
||||
description: repo.remote || repo.path,
|
||||
workspacePath: folder,
|
||||
});
|
||||
} catch {
|
||||
// Space might already exist or DB unavailable — skip
|
||||
}
|
||||
created.push({ spaceId: space.id, name: space.name, path: workspace.path });
|
||||
}
|
||||
|
||||
return created;
|
||||
|
||||
@ -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<GitRunnerResult
|
||||
|
||||
export async function defaultGitRunner(args: string[], cwd: string): Promise<GitRunnerResult> {
|
||||
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) => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user