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:
cynarAI 2026-09-24 03:38:40 +02:00 committed by GitHub
parent c18a04ce3f
commit 5426bb73c5
7 changed files with 337 additions and 92 deletions

View File

@ -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({

View File

@ -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 });

View File

@ -8,7 +8,6 @@ import {
type Prisma,
type PrismaClient,
type ThreadEvents,
createPool,
} from "@rakazo/db";
/**

View File

@ -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;
}

View File

@ -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}
>

View File

@ -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}

View File

@ -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"]),