fix(e2e): wait for shell-ready after RPC bot create (#87)
* fix(e2e): wait for shell-ready after RPC bot create createNamedBot now waits for data-ready and the Message composer after navigating to a freshly created bot, instead of asserting immediately on a cold page load where bootstrap had not finished. Also fix onboarding scan advancing to bot creation (onComplete was a no-op) and use a fast simulated scan when AGENT_RUNTIME=scripted so CI E2E does not walk the real filesystem during onboarding. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * style: apply biome format to touched onboarding files Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * style: clear biome lint in onboarding scan files touched by the fix Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * style: format onboarding-scan after rebase onto main Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * style: fix biome lint on main files blocking Product CI Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(onboarding): allow scan before first bot exists The scan RPC required a non-empty botId, but onboarding runs scan before Chief is created and the UI sent an empty string. That rejected the RPC, left the flow on /onboarding, and every completeOnboarding helper timed out. Make botId optional, omit it from the client when unknown, advance to bot creation when scan completes, and keep scripted/fake runs on the fast scan path. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
This commit is contained in:
parent
c18a04ce3f
commit
5426bb73c5
@ -506,12 +506,18 @@ export async function createApp(
|
||||
// Security headers
|
||||
app.use("*", async (c, next) => {
|
||||
await next();
|
||||
c.res.headers.set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' ws:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'");
|
||||
c.res.headers.set(
|
||||
"Content-Security-Policy",
|
||||
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' ws:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'",
|
||||
);
|
||||
c.res.headers.set("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
|
||||
c.res.headers.set("X-Frame-Options", "DENY");
|
||||
c.res.headers.set("X-Content-Type-Options", "nosniff");
|
||||
c.res.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||
c.res.headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()");
|
||||
c.res.headers.set(
|
||||
"Permissions-Policy",
|
||||
"camera=(), microphone=(), geolocation=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()",
|
||||
);
|
||||
});
|
||||
app.get("/api/auth/capabilities", (c) =>
|
||||
c.json({
|
||||
|
||||
@ -76,19 +76,69 @@ interface ScanRecord {
|
||||
|
||||
const scanRegistry = new Map<string, ScanRecord>();
|
||||
|
||||
export async function startOnboardingScan(botId: string, prisma: any): Promise<{ scanId: string }> {
|
||||
const emptyScanResult = (): ScanResult => ({
|
||||
localRepos: [],
|
||||
projectFolders: [],
|
||||
fileSummary: { totalFiles: 0, totalSizeBytes: 0, byType: {}, byExtension: {} },
|
||||
profileSummary: {
|
||||
interests: [],
|
||||
skills: [],
|
||||
workStyle: "structured",
|
||||
communication: "direct",
|
||||
languages: [],
|
||||
techStack: [],
|
||||
},
|
||||
agentBriefing: "Profile ready",
|
||||
createdProjects: [],
|
||||
subAgentRoles: [],
|
||||
});
|
||||
|
||||
async function runSimulatedScan(record: ScanRecord) {
|
||||
const phases: Array<{ status: ScanRecord["status"]; progress: number; message: string }> = [
|
||||
{ status: "scanning", progress: 25, message: "Scanning local files and repositories..." },
|
||||
{ status: "connecting", progress: 40, message: "Connecting to your services..." },
|
||||
{ status: "building", progress: 60, message: "Building your profile..." },
|
||||
{ status: "briefing", progress: 80, message: "Briefing your agent team..." },
|
||||
];
|
||||
for (const phase of phases) {
|
||||
record.status = phase.status;
|
||||
record.progress = phase.progress;
|
||||
record.message = phase.message;
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
}
|
||||
record.status = "complete";
|
||||
record.progress = 100;
|
||||
record.message = "Scan complete — profile ready";
|
||||
record.result = JSON.stringify(emptyScanResult());
|
||||
record.completedAt = new Date();
|
||||
}
|
||||
|
||||
export async function startOnboardingScan(
|
||||
botId: string | undefined,
|
||||
prisma: any,
|
||||
): Promise<{ scanId: string }> {
|
||||
const scanId = `scan_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
const record: ScanRecord = {
|
||||
id: scanId, botId, status: "scanning", progress: 0, message: "Scanning your computer...",
|
||||
result: "[]", startedAt: new Date(), completedAt: null,
|
||||
id: scanId,
|
||||
botId: botId ?? "",
|
||||
status: "scanning",
|
||||
progress: 0,
|
||||
message: "Scanning your computer...",
|
||||
result: "[]",
|
||||
startedAt: new Date(),
|
||||
completedAt: null,
|
||||
};
|
||||
scanRegistry.set(scanId, record);
|
||||
|
||||
// Run async
|
||||
(async () => {
|
||||
void (async () => {
|
||||
try {
|
||||
if (process.env.AGENT_RUNTIME === "scripted" || process.env.SANDBOX_PROVIDER === "fake") {
|
||||
await runSimulatedScan(record);
|
||||
return;
|
||||
}
|
||||
const homeDir = process.env.HOME ?? "/home/cynar";
|
||||
const result = await executeFullScan(homeDir, botId, prisma);
|
||||
const result = await executeFullScan(homeDir, botId ?? "", prisma);
|
||||
record.status = "complete";
|
||||
record.progress = 100;
|
||||
record.message = "Scan complete — profile ready";
|
||||
@ -105,7 +155,10 @@ export async function startOnboardingScan(botId: string, prisma: any): Promise<{
|
||||
}
|
||||
|
||||
export async function getScanStatus(scanId: string): Promise<{
|
||||
status: ScanRecord["status"]; progress: number; message: string; result?: unknown;
|
||||
status: ScanRecord["status"];
|
||||
progress: number;
|
||||
message: string;
|
||||
result?: unknown;
|
||||
}> {
|
||||
const record = scanRegistry.get(scanId);
|
||||
if (!record) throw new Error("Scan not found");
|
||||
@ -137,7 +190,15 @@ async function executeFullScan(homeDir: string, _botId: string, prisma: any): Pr
|
||||
// Phase 5: Agent briefing (includes sub-agent roles)
|
||||
const agentBriefing = generateBriefing(localRepos, profileSummary, fileSummary, subAgentRoles);
|
||||
|
||||
return { localRepos, projectFolders, fileSummary, profileSummary, agentBriefing, createdProjects, subAgentRoles };
|
||||
return {
|
||||
localRepos,
|
||||
projectFolders,
|
||||
fileSummary,
|
||||
profileSummary,
|
||||
agentBriefing,
|
||||
createdProjects,
|
||||
subAgentRoles,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -150,8 +211,19 @@ async function scanFiles(homeDir: string): Promise<[FileSummary, ProjectFolder[]
|
||||
|
||||
// Scan common directories
|
||||
const scanDirs = [
|
||||
"Projects", "Documents", "Pictures", "Music", "Downloads", "Desktop",
|
||||
"src", "code", "work", "data", "repos", "dev", "workspace",
|
||||
"Projects",
|
||||
"Documents",
|
||||
"Pictures",
|
||||
"Music",
|
||||
"Downloads",
|
||||
"Desktop",
|
||||
"src",
|
||||
"code",
|
||||
"work",
|
||||
"data",
|
||||
"repos",
|
||||
"dev",
|
||||
"workspace",
|
||||
];
|
||||
|
||||
for (const subDir of scanDirs) {
|
||||
@ -173,7 +245,9 @@ async function scanFiles(homeDir: string): Promise<[FileSummary, ProjectFolder[]
|
||||
modifiedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
} catch { /* directory doesn't exist, skip */ }
|
||||
} catch {
|
||||
/* directory doesn't exist, skip */
|
||||
}
|
||||
}
|
||||
|
||||
return [summary, folders];
|
||||
@ -186,7 +260,11 @@ interface DirStats {
|
||||
byExtension: Record<string, number>;
|
||||
}
|
||||
|
||||
async function scanDirectory(dirPath: string, _homeDir: string, _relativePath: string): Promise<DirStats> {
|
||||
async function scanDirectory(
|
||||
dirPath: string,
|
||||
_homeDir: string,
|
||||
_relativePath: string,
|
||||
): Promise<DirStats> {
|
||||
const stats: DirStats = { totalFiles: 0, totalSizeBytes: 0, byType: {}, byExtension: {} };
|
||||
|
||||
try {
|
||||
@ -197,7 +275,8 @@ async function scanDirectory(dirPath: string, _homeDir: string, _relativePath: s
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
// Skip node_modules, .git, etc.
|
||||
if (entry.name === "node_modules" || entry.name === ".git" || entry.name === ".venv") continue;
|
||||
if (entry.name === "node_modules" || entry.name === ".git" || entry.name === ".venv")
|
||||
continue;
|
||||
const subStats = await scanDirectory(fullPath, _homeDir, _relativePath);
|
||||
stats.totalFiles += subStats.totalFiles;
|
||||
stats.totalSizeBytes += subStats.totalSizeBytes;
|
||||
@ -216,17 +295,37 @@ async function scanDirectory(dirPath: string, _homeDir: string, _relativePath: s
|
||||
// Classify by extension
|
||||
const ext = entry.name.split(".").pop()?.toLowerCase() || "none";
|
||||
stats.byExtension[ext] = (stats.byExtension[ext] || 0) + 1;
|
||||
} catch { /* permission denied */ }
|
||||
} catch {
|
||||
/* permission denied */
|
||||
}
|
||||
}
|
||||
} catch { /* permission denied */ }
|
||||
}
|
||||
} catch {
|
||||
/* permission denied */
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
function classifyFileType(filename: string): string {
|
||||
const ext = filename.split(".").pop()?.toLowerCase() || "";
|
||||
const codeExts = ["ts", "tsx", "js", "jsx", "py", "go", "rs", "java", "c", "cpp", "h", "rb", "php", "swift", "kt"];
|
||||
const codeExts = [
|
||||
"ts",
|
||||
"tsx",
|
||||
"js",
|
||||
"jsx",
|
||||
"py",
|
||||
"go",
|
||||
"rs",
|
||||
"java",
|
||||
"c",
|
||||
"cpp",
|
||||
"h",
|
||||
"rb",
|
||||
"php",
|
||||
"swift",
|
||||
"kt",
|
||||
];
|
||||
const docExts = ["md", "txt", "doc", "docx", "pdf", "rst", "tex"];
|
||||
const imageExts = ["png", "jpg", "jpeg", "gif", "svg", "ico", "webp", "bmp"];
|
||||
const musicExts = ["mp3", "wav", "flac", "aac", "ogg", "m4a", "wma"];
|
||||
@ -235,16 +334,26 @@ function classifyFileType(filename: string): string {
|
||||
if (docExts.includes(ext)) return "documents";
|
||||
if (imageExts.includes(ext)) return "images";
|
||||
if (musicExts.includes(ext)) return "music";
|
||||
if (["json", "yaml", "yml", "toml", "cfg", "ini", "env", "sh", "bash", "zsh"].includes(ext)) return "config";
|
||||
if (["json", "yaml", "yml", "toml", "cfg", "ini", "env", "sh", "bash", "zsh"].includes(ext))
|
||||
return "config";
|
||||
return "other";
|
||||
}
|
||||
|
||||
function classifyFolderType(name: string): ProjectFolder["type"] {
|
||||
const lower = name.toLowerCase();
|
||||
if (lower.includes("music")) return "music";
|
||||
if (lower.includes("picture") || lower.includes("image") || lower.includes("photo")) return "media";
|
||||
if (lower.includes("picture") || lower.includes("image") || lower.includes("photo"))
|
||||
return "media";
|
||||
if (lower.includes("document") || lower.includes("doc")) return "documents";
|
||||
if (lower.includes("project") || lower.includes("code") || lower.includes("src") || lower.includes("dev") || lower.includes("workspace") || lower.includes("repo")) return "code";
|
||||
if (
|
||||
lower.includes("project") ||
|
||||
lower.includes("code") ||
|
||||
lower.includes("src") ||
|
||||
lower.includes("dev") ||
|
||||
lower.includes("workspace") ||
|
||||
lower.includes("repo")
|
||||
)
|
||||
return "code";
|
||||
return "workspace";
|
||||
}
|
||||
|
||||
@ -256,18 +365,19 @@ async function discoverGitRepos(homeDir: string): Promise<RepoInfo[]> {
|
||||
const repos: RepoInfo[] = [];
|
||||
|
||||
try {
|
||||
// Use git to find all repositories under home directory
|
||||
const { stdout } = await spawnPromise("git", ["root"], { cwd: homeDir });
|
||||
// This only finds the current repo. Instead, search for .git directories
|
||||
const gitDirs = await findGitDirectories(homeDir);
|
||||
|
||||
for (const repoDir of gitDirs) {
|
||||
try {
|
||||
const repo = await analyzeGitRepo(repoDir, homeDir);
|
||||
if (repo) repos.push(repo);
|
||||
} catch { /* skip broken repos */ }
|
||||
} catch {
|
||||
/* skip broken repos */
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* git not available */
|
||||
}
|
||||
} catch { /* git not available */ }
|
||||
|
||||
return repos;
|
||||
}
|
||||
@ -283,31 +393,43 @@ async function findGitDirectories(dir: string, depth: number = 0): Promise<strin
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === ".git") {
|
||||
results.push(dirname(fullPath));
|
||||
} else if (entry.name !== "node_modules" && entry.name !== ".git" && entry.name !== ".venv" && entry.name !== "dist") {
|
||||
results.push(...await findGitDirectories(fullPath, depth + 1));
|
||||
} else if (
|
||||
entry.name !== "node_modules" &&
|
||||
entry.name !== ".git" &&
|
||||
entry.name !== ".venv" &&
|
||||
entry.name !== "dist"
|
||||
) {
|
||||
results.push(...(await findGitDirectories(fullPath, depth + 1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* permission denied */ }
|
||||
} catch {
|
||||
/* permission denied */
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async function analyzeGitRepo(repoDir: string, homeDir: string): Promise<RepoInfo | null> {
|
||||
async function analyzeGitRepo(repoDir: string, _homeDir: string): Promise<RepoInfo | null> {
|
||||
try {
|
||||
const name = repoDir.split("/").pop() || "";
|
||||
const relPath = repoDir.replace(homeDir + "/", "");
|
||||
|
||||
// Get remote URL
|
||||
const remoteResult = await spawnPromise("git", ["remote", "get-url", "origin"], { cwd: repoDir }).catch(() => ({ stdout: "" }));
|
||||
const remoteResult = await spawnPromise("git", ["remote", "get-url", "origin"], {
|
||||
cwd: repoDir,
|
||||
}).catch(() => ({ stdout: "" }));
|
||||
const remote = remoteResult.stdout.trim() || null;
|
||||
|
||||
// Get current branch
|
||||
const branchResult = await spawnPromise("git", ["branch", "--show-current"], { cwd: repoDir }).catch(() => ({ stdout: "" }));
|
||||
const branchResult = await spawnPromise("git", ["branch", "--show-current"], {
|
||||
cwd: repoDir,
|
||||
}).catch(() => ({ stdout: "" }));
|
||||
const branch = branchResult.stdout.trim() || "unknown";
|
||||
|
||||
// Get last commit
|
||||
const commitResult = await spawnPromise("git", ["log", "-1", "--format=%H"], { cwd: repoDir }).catch(() => ({ stdout: "" }));
|
||||
const commitResult = await spawnPromise("git", ["log", "-1", "--format=%H"], {
|
||||
cwd: repoDir,
|
||||
}).catch(() => ({ stdout: "" }));
|
||||
const lastCommit = commitResult.stdout.trim() || null;
|
||||
|
||||
// Get language stats via git ls-files + wc
|
||||
@ -326,19 +448,34 @@ async function analyzeGitRepo(repoDir: string, homeDir: string): Promise<RepoInf
|
||||
async function getRepoLanguages(repoDir: string): Promise<Record<string, number>> {
|
||||
const langs: Record<string, number> = {};
|
||||
try {
|
||||
const { stdout } = await spawnPromise("git", ["ls-files"], { cwd: repoDir }).catch(() => ({ stdout: "" }));
|
||||
const { stdout } = await spawnPromise("git", ["ls-files"], { cwd: repoDir }).catch(() => ({
|
||||
stdout: "",
|
||||
}));
|
||||
const files = stdout.trim().split("\n").filter(Boolean);
|
||||
for (const file of files) {
|
||||
const ext = file.split(".").pop()?.toLowerCase() || "";
|
||||
const langMap: Record<string, string> = {
|
||||
ts: "TypeScript", tsx: "TypeScript", js: "JavaScript", jsx: "JavaScript",
|
||||
py: "Python", go: "Go", rs: "Rust", java: "Java", c: "C", cpp: "C++",
|
||||
rb: "Ruby", php: "PHP", swift: "Swift", kt: "Kotlin",
|
||||
ts: "TypeScript",
|
||||
tsx: "TypeScript",
|
||||
js: "JavaScript",
|
||||
jsx: "JavaScript",
|
||||
py: "Python",
|
||||
go: "Go",
|
||||
rs: "Rust",
|
||||
java: "Java",
|
||||
c: "C",
|
||||
cpp: "C++",
|
||||
rb: "Ruby",
|
||||
php: "PHP",
|
||||
swift: "Swift",
|
||||
kt: "Kotlin",
|
||||
};
|
||||
const lang = langMap[ext] || ext || "unknown";
|
||||
langs[lang] = (langs[lang] || 0) + 1;
|
||||
}
|
||||
} catch { /* no languages */ }
|
||||
} catch {
|
||||
/* no languages */
|
||||
}
|
||||
return langs;
|
||||
}
|
||||
|
||||
@ -346,7 +483,11 @@ async function getRepoLanguages(repoDir: string): Promise<Record<string, number>
|
||||
// Phase 3: Profile compilation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function compileProfile(repos: RepoInfo[], fileSummary: FileSummary, folders: ProjectFolder[]): ProfileSummary {
|
||||
function compileProfile(
|
||||
repos: RepoInfo[],
|
||||
fileSummary: FileSummary,
|
||||
_folders: ProjectFolder[],
|
||||
): ProfileSummary {
|
||||
const interests: string[] = [];
|
||||
const skills: string[] = [];
|
||||
const techStack: string[] = [];
|
||||
@ -369,9 +510,16 @@ function compileProfile(repos: RepoInfo[], fileSummary: FileSummary, folders: Pr
|
||||
|
||||
// Derive skills from languages
|
||||
const langToSkill: Record<string, string> = {
|
||||
"TypeScript": "TypeScript/JavaScript", Python: "Python", Go: "Go",
|
||||
"Rust": "Rust", Java: "Java", C: "C/C++", "C++": "C/C++", Ruby: "Ruby",
|
||||
"Swift": "Swift/Kotlin", PHP: "PHP",
|
||||
TypeScript: "TypeScript/JavaScript",
|
||||
Python: "Python",
|
||||
Go: "Go",
|
||||
Rust: "Rust",
|
||||
Java: "Java",
|
||||
C: "C/C++",
|
||||
"C++": "C/C++",
|
||||
Ruby: "Ruby",
|
||||
Swift: "Swift/Kotlin",
|
||||
PHP: "PHP",
|
||||
};
|
||||
for (const lang of techStack) {
|
||||
const skill = langToSkill[lang];
|
||||
@ -386,7 +534,12 @@ function compileProfile(repos: RepoInfo[], fileSummary: FileSummary, folders: Pr
|
||||
return {
|
||||
interests: uniqueInterests,
|
||||
skills: uniqueSkills,
|
||||
workStyle: uniqueTech.length > 3 ? "multi-language" : uniqueTech[0] ? `${uniqueTech[0]}-focused` : "general",
|
||||
workStyle:
|
||||
uniqueTech.length > 3
|
||||
? "multi-language"
|
||||
: uniqueTech[0]
|
||||
? `${uniqueTech[0]}-focused`
|
||||
: "general",
|
||||
communication: "structured",
|
||||
languages: Array.from(languageSet),
|
||||
techStack: uniqueTech,
|
||||
@ -413,13 +566,19 @@ function generateBriefing(
|
||||
lines.push(``);
|
||||
lines.push(`## Local Repositories (${repos.length} found)`);
|
||||
for (const repo of repos) {
|
||||
lines.push(`- **${repo.name}** — ${repo.branch}, ${Object.values(repo.languages).reduce((a, b) => a + b, 0)} files, ${repo.remote || "no remote"}`);
|
||||
lines.push(
|
||||
`- **${repo.name}** — ${repo.branch}, ${Object.values(repo.languages).reduce((a, b) => a + b, 0)} files, ${repo.remote || "no remote"}`,
|
||||
);
|
||||
}
|
||||
lines.push(``);
|
||||
lines.push(`## File System Summary`);
|
||||
lines.push(`- **Total Files:** ${fileSummary.totalFiles.toLocaleString()}`);
|
||||
lines.push(`- **Total Size:** ${(fileSummary.totalSizeBytes / (1024 * 1024)).toFixed(0)} MB`);
|
||||
lines.push(`- **Categories:** ${Object.entries(fileSummary.byType).map(([t, c]) => `${t}(${c})`).join(", ")}`);
|
||||
lines.push(
|
||||
`- **Categories:** ${Object.entries(fileSummary.byType)
|
||||
.map(([t, c]) => `${t}(${c})`)
|
||||
.join(", ")}`,
|
||||
);
|
||||
lines.push(``);
|
||||
lines.push(`## User Interests`);
|
||||
lines.push(`- ${profile.interests.slice(0, 5).join("\n- ")}`);
|
||||
@ -437,7 +596,9 @@ function generateBriefing(
|
||||
lines.push("");
|
||||
}
|
||||
lines.push(`---`);
|
||||
lines.push(`This profile should be used to tailor all future interactions, project recommendations, and agent assignments.`);
|
||||
lines.push(
|
||||
`This profile should be used to tailor all future interactions, project recommendations, and agent assignments.`,
|
||||
);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
@ -453,14 +614,24 @@ async function createProjectFolders(repos: RepoInfo[], prisma: any): Promise<Cre
|
||||
// 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 });
|
||||
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 },
|
||||
});
|
||||
created.push({ id: space.id, name: repo.name, spaceId: space.id, description: repo.remote || repo.path });
|
||||
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
|
||||
}
|
||||
@ -484,11 +655,25 @@ interface SubAgentRole {
|
||||
function generateSubAgentRoles(profile: ProfileSummary, repos: RepoInfo[]): SubAgentRole[] {
|
||||
const roles: SubAgentRole[] = [];
|
||||
const hasCode = profile.languages?.length > 0;
|
||||
const hasDesign = profile.interests?.some(i => i.toLowerCase().includes("design") || i.toLowerCase().includes("ui"));
|
||||
const hasData = profile.interests?.some(i => i.toLowerCase().includes("data") || i.toLowerCase().includes("analytics") || i.toLowerCase().includes("database"));
|
||||
const hasDevOps = profile.interests?.some(i => i.toLowerCase().includes("infra") || i.toLowerCase().includes("ops") || i.toLowerCase().includes("deploy"));
|
||||
const hasMusic = profile.interests?.some(i => i.toLowerCase().includes("music"));
|
||||
const hasWriting = profile.interests?.some(i => i.toLowerCase().includes("write") || i.toLowerCase().includes("blog"));
|
||||
const hasDesign = profile.interests?.some(
|
||||
(i) => i.toLowerCase().includes("design") || i.toLowerCase().includes("ui"),
|
||||
);
|
||||
const hasData = profile.interests?.some(
|
||||
(i) =>
|
||||
i.toLowerCase().includes("data") ||
|
||||
i.toLowerCase().includes("analytics") ||
|
||||
i.toLowerCase().includes("database"),
|
||||
);
|
||||
const hasDevOps = profile.interests?.some(
|
||||
(i) =>
|
||||
i.toLowerCase().includes("infra") ||
|
||||
i.toLowerCase().includes("ops") ||
|
||||
i.toLowerCase().includes("deploy"),
|
||||
);
|
||||
const hasMusic = profile.interests?.some((i) => i.toLowerCase().includes("music"));
|
||||
const hasWriting = profile.interests?.some(
|
||||
(i) => i.toLowerCase().includes("write") || i.toLowerCase().includes("blog"),
|
||||
);
|
||||
|
||||
if (hasCode) {
|
||||
const primaryLang = profile.languages?.[0] || "TypeScript";
|
||||
@ -497,7 +682,9 @@ function generateSubAgentRoles(profile: ProfileSummary, repos: RepoInfo[]): SubA
|
||||
role: "software-engineer",
|
||||
description: `Specializes in ${primaryLang} development and code review`,
|
||||
instructions: `You are the Code Engineer specialist. Your expertise is in ${profile.languages?.join(", ") || "software development"}. Review code, write tests, debug issues, and implement features. You have access to the user's local repositories and can run commands to build and test code.`,
|
||||
skills: profile.skills.filter(s => s.toLowerCase().includes("code") || s.toLowerCase().includes("program")),
|
||||
skills: profile.skills.filter(
|
||||
(s) => s.toLowerCase().includes("code") || s.toLowerCase().includes("program"),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
@ -506,7 +693,8 @@ function generateSubAgentRoles(profile: ProfileSummary, repos: RepoInfo[]): SubA
|
||||
name: "UI/UX Designer",
|
||||
role: "ui-designer",
|
||||
description: "Creates polished interfaces and design systems",
|
||||
instructions: "You are the UI/UX Designer specialist. Focus on creating beautiful, accessible interfaces. Review designs, suggest improvements, and ensure consistency across the user's projects.",
|
||||
instructions:
|
||||
"You are the UI/UX Designer specialist. Focus on creating beautiful, accessible interfaces. Review designs, suggest improvements, and ensure consistency across the user's projects.",
|
||||
skills: ["design", "UI/UX"],
|
||||
});
|
||||
}
|
||||
@ -516,7 +704,8 @@ function generateSubAgentRoles(profile: ProfileSummary, repos: RepoInfo[]): SubA
|
||||
name: "Data Analyst",
|
||||
role: "data-analyst",
|
||||
description: "Analyzes data, creates reports, and extracts insights",
|
||||
instructions: "You are the Data Analyst specialist. Process data from the user's projects, create visualizations, generate reports, and provide actionable insights.",
|
||||
instructions:
|
||||
"You are the Data Analyst specialist. Process data from the user's projects, create visualizations, generate reports, and provide actionable insights.",
|
||||
skills: ["data analysis", "statistics"],
|
||||
});
|
||||
}
|
||||
@ -526,7 +715,8 @@ function generateSubAgentRoles(profile: ProfileSummary, repos: RepoInfo[]): SubA
|
||||
name: "DevOps Engineer",
|
||||
role: "devops-engineer",
|
||||
description: "Manages infrastructure, deployments, and CI/CD",
|
||||
instructions: "You are the DevOps Engineer specialist. Handle deployments, manage infrastructure, configure CI/CD pipelines, and ensure system reliability.",
|
||||
instructions:
|
||||
"You are the DevOps Engineer specialist. Handle deployments, manage infrastructure, configure CI/CD pipelines, and ensure system reliability.",
|
||||
skills: ["DevOps", "infrastructure"],
|
||||
});
|
||||
}
|
||||
@ -536,7 +726,8 @@ function generateSubAgentRoles(profile: ProfileSummary, repos: RepoInfo[]): SubA
|
||||
name: "Music Producer",
|
||||
role: "music-producer",
|
||||
description: "Assists with music production and audio engineering",
|
||||
instructions: "You are the Music Producer specialist. Help with music composition, audio engineering, mixing, and mastering. You understand the user's music projects and can provide expert advice.",
|
||||
instructions:
|
||||
"You are the Music Producer specialist. Help with music composition, audio engineering, mixing, and mastering. You understand the user's music projects and can provide expert advice.",
|
||||
skills: ["music production", "audio engineering"],
|
||||
});
|
||||
}
|
||||
@ -546,7 +737,8 @@ function generateSubAgentRoles(profile: ProfileSummary, repos: RepoInfo[]): SubA
|
||||
name: "Content Writer",
|
||||
role: "content-writer",
|
||||
description: "Creates and edits written content",
|
||||
instructions: "You are the Content Writer specialist. Write blog posts, documentation, articles, and other content for the user's projects and personal use.",
|
||||
instructions:
|
||||
"You are the Content Writer specialist. Write blog posts, documentation, articles, and other content for the user's projects and personal use.",
|
||||
skills: ["writing", "content creation"],
|
||||
});
|
||||
}
|
||||
@ -556,7 +748,12 @@ function generateSubAgentRoles(profile: ProfileSummary, repos: RepoInfo[]): SubA
|
||||
name: "General Assistant",
|
||||
role: "general-assistant",
|
||||
description: "Handles general tasks and coordinates other specialists",
|
||||
instructions: `You are the General Assistant and team coordinator. Your role is to handle general queries, coordinate with other specialists (${roles.slice(1).map(r => r.name).join(", ") || "none yet"}), and manage the user's daily workflow. You have access to all tools and can delegate tasks to specialists when needed.`,
|
||||
instructions: `You are the General Assistant and team coordinator. Your role is to handle general queries, coordinate with other specialists (${
|
||||
roles
|
||||
.slice(1)
|
||||
.map((r) => r.name)
|
||||
.join(", ") || "none yet"
|
||||
}), and manage the user's daily workflow. You have access to all tools and can delegate tasks to specialists when needed.`,
|
||||
skills: ["task management", "coordination"],
|
||||
});
|
||||
|
||||
@ -567,13 +764,21 @@ function generateSubAgentRoles(profile: ProfileSummary, repos: RepoInfo[]): SubA
|
||||
// Utility
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function spawnPromise(cmd: string, args: string[], options?: { cwd?: string }): Promise<{ stdout: string; stderr: string }> {
|
||||
function spawnPromise(
|
||||
cmd: string,
|
||||
args: string[],
|
||||
options?: { cwd?: string },
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(cmd, args, { ...options, timeout: 15000 });
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout?.on("data", (d: Buffer) => { stdout += d.toString(); });
|
||||
child.stderr?.on("data", (d: Buffer) => { stderr += d.toString(); });
|
||||
child.stdout?.on("data", (d: Buffer) => {
|
||||
stdout += d.toString();
|
||||
});
|
||||
child.stderr?.on("data", (d: Buffer) => {
|
||||
stderr += d.toString();
|
||||
});
|
||||
child.on("error", () => reject(new Error("spawn failed")));
|
||||
child.on("close", (code) => {
|
||||
if (code === 0 || code === null) resolve({ stdout, stderr });
|
||||
|
||||
@ -8,7 +8,6 @@ import {
|
||||
type Prisma,
|
||||
type PrismaClient,
|
||||
type ThreadEvents,
|
||||
createPool,
|
||||
} from "@rakazo/db";
|
||||
|
||||
/**
|
||||
|
||||
@ -39,7 +39,7 @@ export async function completeOnboarding(page: Page, testInfo?: TestInfo) {
|
||||
if (page.url().includes("/onboarding")) {
|
||||
await expect(page.locator('[data-rakazo-surface="onboarding"]')).toHaveAttribute(
|
||||
"data-onboarding-step",
|
||||
/^(emoji|preferences|language|voice|integrations)$/,
|
||||
/^(emoji|preferences|language|voice|integrations|scan|bot)$/,
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
}
|
||||
@ -71,8 +71,10 @@ export async function completeOnboarding(page: Page, testInfo?: TestInfo) {
|
||||
await page.getByRole("button", { name: "Skip", exact: true }).click();
|
||||
}
|
||||
|
||||
await page.waitForURL(/\/app\/[^/]+/, { timeout: 20_000 });
|
||||
// Scan step auto-advances to first bot creation before landing in /app.
|
||||
await page.waitForURL(/\/app\/[^/]+/, { timeout: 30_000 });
|
||||
await expect(chief).toBeVisible({ timeout: 15_000 });
|
||||
await waitForChatComposer(page);
|
||||
|
||||
if (testInfo) {
|
||||
await captureScreenshot(page, testInfo, "03-create-first-bot");
|
||||
@ -181,6 +183,14 @@ export async function openUserSettings(
|
||||
return settings;
|
||||
}
|
||||
|
||||
/** Wait until the shell thread is loaded and the message composer is interactive. */
|
||||
export async function waitForChatComposer(page: Page) {
|
||||
await expect(page.getByTestId("shell-root")).toHaveAttribute("data-ready", "true", {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
|
||||
}
|
||||
|
||||
/** Create a named bot via RPC for test setup (skips the + picker). */
|
||||
export async function createNamedBot(
|
||||
page: Page,
|
||||
@ -194,7 +204,8 @@ export async function createNamedBot(
|
||||
notifyOnFinish: true,
|
||||
computerMode: options.computerMode ?? "team",
|
||||
});
|
||||
await page.goto(`/app/${bot.id}`);
|
||||
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
|
||||
await page.goto(`/app/${bot.id}`, { waitUntil: "domcontentloaded" });
|
||||
await page.waitForURL(/\/app\/[^/]+$/, { timeout: 20_000 });
|
||||
await waitForChatComposer(page);
|
||||
return bot.id;
|
||||
}
|
||||
|
||||
@ -9,24 +9,38 @@ interface ScanStatus {
|
||||
}
|
||||
|
||||
const PHASES = [
|
||||
{ key: "scanning" as const, label: "Scanning your computer...", sublabel: "Files, folders, git repositories" },
|
||||
{ key: "connecting" as const, label: "Connecting to your services...", sublabel: "GitHub, email, calendar" },
|
||||
{ key: "building" as const, label: "Building your profile...", sublabel: "Interests, skills, preferences" },
|
||||
{ key: "briefing" as const, label: "Setting up your agent team...", sublabel: "Project folders, role assignments" },
|
||||
{
|
||||
key: "scanning" as const,
|
||||
label: "Scanning your computer...",
|
||||
sublabel: "Files, folders, git repositories",
|
||||
},
|
||||
{
|
||||
key: "connecting" as const,
|
||||
label: "Connecting to your services...",
|
||||
sublabel: "GitHub, email, calendar",
|
||||
},
|
||||
{
|
||||
key: "building" as const,
|
||||
label: "Building your profile...",
|
||||
sublabel: "Interests, skills, preferences",
|
||||
},
|
||||
{
|
||||
key: "briefing" as const,
|
||||
label: "Setting up your agent team...",
|
||||
sublabel: "Project folders, role assignments",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function OnboardingScanProgress({
|
||||
onComplete,
|
||||
botId,
|
||||
onScanComplete,
|
||||
}: {
|
||||
onComplete: () => void;
|
||||
botId?: string;
|
||||
onScanComplete?: () => void;
|
||||
}) {
|
||||
const [status, setStatus] = useState<ScanStatus | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [scanId, setScanId] = useState<string | null>(null);
|
||||
const [, setScanId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@ -34,7 +48,7 @@ export function OnboardingScanProgress({
|
||||
|
||||
const run = async () => {
|
||||
try {
|
||||
const scanResult = await rpc.onboarding.scan({ botId: botId ?? "" });
|
||||
const scanResult = await rpc.onboarding.scan(botId ? { botId } : {});
|
||||
if (cancelled) return;
|
||||
setScanId(scanResult.scanId);
|
||||
|
||||
@ -45,7 +59,6 @@ export function OnboardingScanProgress({
|
||||
if (cancelled) return;
|
||||
setStatus(s);
|
||||
if (s.status === "complete") {
|
||||
onScanComplete?.();
|
||||
onComplete();
|
||||
} else if (s.status === "failed") {
|
||||
setError(s.message);
|
||||
@ -68,13 +81,13 @@ export function OnboardingScanProgress({
|
||||
cancelled = true;
|
||||
if (pollingInterval) clearInterval(pollingInterval);
|
||||
};
|
||||
}, [botId, onComplete, onScanComplete]);
|
||||
}, [botId, onComplete]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl text-center">
|
||||
<p className="text-destructive">{error}</p>
|
||||
<button className="mt-4 rounded-lg border px-4 py-2" onClick={onComplete}>
|
||||
<button type="button" className="mt-4 rounded-lg border px-4 py-2" onClick={onComplete}>
|
||||
Continue anyway
|
||||
</button>
|
||||
</div>
|
||||
@ -90,7 +103,7 @@ export function OnboardingScanProgress({
|
||||
);
|
||||
}
|
||||
|
||||
const currentPhaseIndex = PHASES.findIndex(p => p.key === status.status);
|
||||
const currentPhaseIndex = PHASES.findIndex((p) => p.key === status.status);
|
||||
const isComplete = status.status === "complete";
|
||||
|
||||
return (
|
||||
@ -121,15 +134,23 @@ export function OnboardingScanProgress({
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
{isDone ? (
|
||||
<span className="grid size-6 items-center justify-center rounded-full bg-emerald-500/20 text-emerald-500 text-sm">✓</span>
|
||||
<span className="grid size-6 items-center justify-center rounded-full bg-emerald-500/20 text-emerald-500 text-sm">
|
||||
✓
|
||||
</span>
|
||||
) : isActive ? (
|
||||
<span className="grid size-6 items-center justify-center rounded-full bg-primary/20 animate-pulse text-primary text-sm">●</span>
|
||||
<span className="grid size-6 items-center justify-center rounded-full bg-primary/20 animate-pulse text-primary text-sm">
|
||||
●
|
||||
</span>
|
||||
) : (
|
||||
<span className="grid size-6 items-center justify-center rounded-full bg-muted/30 text-muted-foreground text-sm">○</span>
|
||||
<span className="grid size-6 items-center justify-center rounded-full bg-muted/30 text-muted-foreground text-sm">
|
||||
○
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={`text-sm font-medium ${isActive ? "text-foreground" : "text-muted-foreground"}`}>
|
||||
<p
|
||||
className={`text-sm font-medium ${isActive ? "text-foreground" : "text-muted-foreground"}`}
|
||||
>
|
||||
{phase.label}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/60">{phase.sublabel}</p>
|
||||
@ -152,6 +173,7 @@ export function OnboardingScanProgress({
|
||||
{isComplete && (
|
||||
<div className="mt-8 text-center">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-xl bg-primary px-6 py-3 text-sm font-medium text-white"
|
||||
onClick={onComplete}
|
||||
>
|
||||
|
||||
@ -158,7 +158,6 @@ export function OnboardingPage() {
|
||||
const [bootstrapMe, setBootstrapMe] = useState<Awaited<ReturnType<typeof rpc.me>> | null>(null);
|
||||
const [profilePersisted, setProfilePersisted] = useState(false);
|
||||
const [crewReady, setCrewReady] = useState(false);
|
||||
const [scanComplete, setScanComplete] = useState(false);
|
||||
const [_helpVibe, setHelpVibe] = useState<UserHelpVibeHints | null>(null);
|
||||
const [scanBotId, setScanBotId] = useState<string | null>(null);
|
||||
|
||||
@ -188,9 +187,13 @@ export function OnboardingPage() {
|
||||
// Get bot ID for scan phase
|
||||
try {
|
||||
const botList = await rpc.bots.list();
|
||||
const firstBot = botList.find(b => b.spawnKey === FIRST_BOT_SPAWN_KEY || b.name === FIRST_BOT_NAME);
|
||||
const firstBot = botList.find(
|
||||
(b) => b.spawnKey === FIRST_BOT_SPAWN_KEY || b.name === FIRST_BOT_NAME,
|
||||
);
|
||||
if (firstBot && !cancelled) setScanBotId(firstBot.id);
|
||||
} catch { /* best effort */ }
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
setIntegrationSetup(integrations);
|
||||
setStep(me.needsModel ? "emoji" : integrations?.needsSetup ? "integrations" : "emoji");
|
||||
// A space that already has a bot does not need the designer again.
|
||||
@ -312,9 +315,9 @@ export function OnboardingPage() {
|
||||
const createStartedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (step !== "bot" || !scanComplete) return;
|
||||
if (step !== "bot") return;
|
||||
void createFirstBot();
|
||||
}, [step, scanComplete]);
|
||||
}, [step]);
|
||||
|
||||
function goAfterVoice(skipped: boolean) {
|
||||
setVoiceSkipped(skipped);
|
||||
@ -544,9 +547,8 @@ export function OnboardingPage() {
|
||||
|
||||
{step === "scan" ? (
|
||||
<OnboardingScanProgress
|
||||
onComplete={() => {}}
|
||||
onComplete={() => setStep("bot")}
|
||||
botId={scanBotId ?? undefined}
|
||||
onScanComplete={() => setScanComplete(true)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
|
||||
@ -650,7 +650,7 @@ export const appContract = {
|
||||
z.object({ botId: Id, provider: z.string(), connectorId: z.string().default("composio") }),
|
||||
)
|
||||
.output(z.object({ ok: z.literal(true) })),
|
||||
scan: oc.input(z.object({ botId: Id })).output(z.object({ scanId: z.string() })),
|
||||
scan: oc.input(z.object({ botId: Id.optional() })).output(z.object({ scanId: z.string() })),
|
||||
scanStatus: oc.input(z.object({ scanId: z.string() })).output(
|
||||
z.object({
|
||||
status: z.enum(["scanning", "connecting", "building", "briefing", "complete", "failed"]),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user