mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-16 19:29:26 +02:00
[codex] Simplify onboarding runtime bootstrap (#2836)
* feat(onboarding): simplify runtime bootstrap * fix(onboarding): close private-helper reuse hole and guide-issue nav race - server: when bootstrap looks for an existing Multica Helper, require Visibility="workspace" so a private helper owned by another member can't be auto-assigned to the onboarding issue (and trigger a task as that private agent), which would have bypassed canAccessPrivateAgent. - web onboarding page: refreshMe() inside bootstrap flips hasOnboarded before onComplete fires, letting the guard's router.replace overtake onComplete's router.push to the new guide issue. Mark the page as "completing" right before navigating so the guard stays silent during the in-flight transition. Co-authored-by: multica-agent <github@multica.ai> * fix(runtimes): escape daemon command literals to satisfy i18next/no-literal-string Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: multica-agent <github@multica.ai> Co-authored-by: Lambda <lambda@multica.ai>
This commit is contained in:
@@ -62,13 +62,13 @@ function WindowOverlayInner() {
|
||||
{overlay.type === "invitations" && <InvitationsPage />}
|
||||
{overlay.type === "onboarding" && (
|
||||
<OnboardingFlow
|
||||
onComplete={(ws) => {
|
||||
onComplete={(ws, issueId) => {
|
||||
close();
|
||||
// Post-onboarding landing is always the workspace issues
|
||||
// list. The welcome-issue flow moved into a dialog that
|
||||
// renders on that page (StarterContentPrompt), so the
|
||||
// flow doesn't need to thread a target issue id back here.
|
||||
if (ws) {
|
||||
// Runtime-connected onboarding lands on its single guide
|
||||
// issue. Runtime-less exits still land on the issues list.
|
||||
if (ws && issueId) {
|
||||
push(paths.workspace(ws.slug).issueDetail(issueId));
|
||||
} else if (ws) {
|
||||
push(paths.workspace(ws.slug).issues());
|
||||
} else {
|
||||
push(paths.root());
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
@@ -17,9 +17,9 @@ import { CliInstallInstructions, OnboardingFlow } from "@multica/views/onboardin
|
||||
* web (matching `WindowOverlay` on desktop); content is the shared
|
||||
* `<OnboardingFlow />`. Kept minimal — guard on auth, render, exit.
|
||||
*
|
||||
* On complete: if a workspace was just created, navigate into it;
|
||||
* otherwise fall back to root (proxy / landing picks the user's first ws
|
||||
* or bounces to onboarding if still zero).
|
||||
* On complete: runtime-connected onboarding may provide a guide issue id;
|
||||
* navigate there. Otherwise land on the workspace issues list, or root if
|
||||
* the flow never produced a workspace.
|
||||
*
|
||||
* `CliInstallInstructions` is passed in as the `runtimeInstructions`
|
||||
* slot so the flow can render it inside the CLI dialog. The commands it
|
||||
@@ -34,6 +34,14 @@ export default function OnboardingPage() {
|
||||
...workspaceListOptions(),
|
||||
enabled: !!user,
|
||||
});
|
||||
// The bootstrap path calls refreshMe() before returning, which flips
|
||||
// hasOnboarded to true while the page is still mounted. Without this
|
||||
// flag the guard below races onComplete: the guard's router.replace
|
||||
// (issues list) can overtake onComplete's router.push (guide issue),
|
||||
// dropping the user on the wrong destination. Marking the page as
|
||||
// "completing" right before onComplete navigates keeps the guard
|
||||
// silent for the in-flight transition.
|
||||
const completingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || !user) {
|
||||
@@ -41,6 +49,7 @@ export default function OnboardingPage() {
|
||||
return;
|
||||
}
|
||||
if (!workspacesFetched) return;
|
||||
if (completingRef.current) return;
|
||||
// Bounce out only when onboarding genuinely doesn't apply: the user is
|
||||
// already onboarded. We deliberately don't bounce on `workspaces.length`
|
||||
// here — Step 3 of the flow creates a workspace mid-onboarding, and a
|
||||
@@ -62,12 +71,14 @@ export default function OnboardingPage() {
|
||||
return (
|
||||
<div className="h-full overflow-y-auto bg-background">
|
||||
<OnboardingFlow
|
||||
onComplete={(ws) => {
|
||||
// No more firstIssueId handoff — the welcome issue is created
|
||||
// inside the workspace via StarterContentPrompt, not during
|
||||
// onboarding. Always land on the workspace issues list (or
|
||||
// root if the flow never produced a workspace).
|
||||
if (ws) {
|
||||
onComplete={(ws, issueId) => {
|
||||
// Runtime-connected onboarding now creates one focused
|
||||
// onboarding issue. Skip/runtime-less exits still land on the
|
||||
// workspace issues list.
|
||||
completingRef.current = true;
|
||||
if (ws && issueId) {
|
||||
router.push(paths.workspace(ws.slug).issueDetail(issueId));
|
||||
} else if (ws) {
|
||||
router.push(paths.workspace(ws.slug).issues());
|
||||
} else {
|
||||
router.push(paths.root());
|
||||
|
||||
@@ -32,7 +32,7 @@ test("onboarding v2 — welcome → source → role → use_case (skip path)", a
|
||||
|
||||
// 2. Source step
|
||||
await expect(page.getByText("How did you hear about Multica?")).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText(`Step 1 of 7`)).toBeVisible();
|
||||
await expect(page.getByText(`Step 1 of 6`)).toBeVisible();
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: `${SHOTS_DIR}/02-source.png` });
|
||||
|
||||
@@ -42,7 +42,7 @@ test("onboarding v2 — welcome → source → role → use_case (skip path)", a
|
||||
|
||||
// 3. Role step
|
||||
await expect(page.getByText("Which best describes you?")).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText(`Step 2 of 7`)).toBeVisible();
|
||||
await expect(page.getByText(`Step 2 of 6`)).toBeVisible();
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: `${SHOTS_DIR}/03-role.png` });
|
||||
|
||||
@@ -51,7 +51,7 @@ test("onboarding v2 — welcome → source → role → use_case (skip path)", a
|
||||
|
||||
// 4. Use case step
|
||||
await expect(page.getByText("What do you want to use Multica for?")).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText(`Step 3 of 7`)).toBeVisible();
|
||||
await expect(page.getByText(`Step 3 of 6`)).toBeVisible();
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: `${SHOTS_DIR}/04-use-case.png` });
|
||||
|
||||
|
||||
@@ -129,6 +129,8 @@ import {
|
||||
GroupedIssuesResponseSchema,
|
||||
ListIssuesResponseSchema,
|
||||
ListWebhookDeliveriesResponseSchema,
|
||||
OnboardingNoRuntimeBootstrapResponseSchema,
|
||||
OnboardingRuntimeBootstrapResponseSchema,
|
||||
SquadMemberStatusListResponseSchema,
|
||||
SubscribersListSchema,
|
||||
TimelineEntriesSchema,
|
||||
@@ -160,6 +162,30 @@ export interface LoginResponse {
|
||||
user: User;
|
||||
}
|
||||
|
||||
export interface OnboardingRuntimeBootstrapResponse {
|
||||
workspace_id: string;
|
||||
agent_id: string;
|
||||
issue_id: string;
|
||||
}
|
||||
|
||||
const EMPTY_ONBOARDING_RUNTIME_BOOTSTRAP_RESPONSE:
|
||||
OnboardingRuntimeBootstrapResponse = {
|
||||
workspace_id: "",
|
||||
agent_id: "",
|
||||
issue_id: "",
|
||||
};
|
||||
|
||||
export interface OnboardingNoRuntimeBootstrapResponse {
|
||||
workspace_id: string;
|
||||
issue_id: string;
|
||||
}
|
||||
|
||||
const EMPTY_ONBOARDING_NO_RUNTIME_BOOTSTRAP_RESPONSE:
|
||||
OnboardingNoRuntimeBootstrapResponse = {
|
||||
workspace_id: "",
|
||||
issue_id: "",
|
||||
};
|
||||
|
||||
// --- Starter content (post-onboarding import) -----------------------------
|
||||
// Shape mirrors the Go request/response in handler/onboarding.go.
|
||||
//
|
||||
@@ -414,6 +440,43 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async bootstrapOnboardingRuntime(payload: {
|
||||
workspace_id: string;
|
||||
runtime_id: string;
|
||||
}): Promise<OnboardingRuntimeBootstrapResponse> {
|
||||
const raw = await this.fetch<unknown>(
|
||||
"/api/me/onboarding/runtime-bootstrap",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
return parseWithFallback(
|
||||
raw,
|
||||
OnboardingRuntimeBootstrapResponseSchema,
|
||||
EMPTY_ONBOARDING_RUNTIME_BOOTSTRAP_RESPONSE,
|
||||
{ endpoint: "POST /api/me/onboarding/runtime-bootstrap" },
|
||||
);
|
||||
}
|
||||
|
||||
async bootstrapOnboardingNoRuntime(payload: {
|
||||
workspace_id: string;
|
||||
}): Promise<OnboardingNoRuntimeBootstrapResponse> {
|
||||
const raw = await this.fetch<unknown>(
|
||||
"/api/me/onboarding/no-runtime-bootstrap",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
return parseWithFallback(
|
||||
raw,
|
||||
OnboardingNoRuntimeBootstrapResponseSchema,
|
||||
EMPTY_ONBOARDING_NO_RUNTIME_BOOTSTRAP_RESPONSE,
|
||||
{ endpoint: "POST /api/me/onboarding/no-runtime-bootstrap" },
|
||||
);
|
||||
}
|
||||
|
||||
async joinCloudWaitlist(payload: {
|
||||
email: string;
|
||||
reason?: string;
|
||||
|
||||
@@ -198,6 +198,17 @@ export const ChildIssuesResponseSchema = z.object({
|
||||
issues: z.array(IssueSchema).default([]),
|
||||
}).loose();
|
||||
|
||||
export const OnboardingRuntimeBootstrapResponseSchema = z.object({
|
||||
workspace_id: z.string(),
|
||||
agent_id: z.string(),
|
||||
issue_id: z.string(),
|
||||
}).loose();
|
||||
|
||||
export const OnboardingNoRuntimeBootstrapResponseSchema = z.object({
|
||||
workspace_id: z.string(),
|
||||
issue_id: z.string(),
|
||||
}).loose();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Workspace dashboard schemas
|
||||
//
|
||||
|
||||
@@ -9,6 +9,8 @@ export type {
|
||||
export {
|
||||
saveQuestionnaire,
|
||||
completeOnboarding,
|
||||
bootstrapRuntimeOnboarding,
|
||||
bootstrapNoRuntimeOnboarding,
|
||||
joinCloudWaitlist,
|
||||
} from "./store";
|
||||
export { ONBOARDING_STEP_ORDER } from "./step-order";
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { QuestionnaireAnswers, Role, UseCase } from "./types";
|
||||
|
||||
/**
|
||||
* Identifier for the four agent templates offered during onboarding
|
||||
* Step 6 (Agent). Keep in sync with the template registry inside
|
||||
* StepAgent in `packages/views/onboarding/steps/step-agent.tsx`.
|
||||
* Identifier for the four legacy onboarding agent templates. Keep in
|
||||
* sync with the template registry inside StepAgent in
|
||||
* `packages/views/onboarding/steps/step-agent.tsx`.
|
||||
*/
|
||||
export type AgentTemplateId = "coding" | "planning" | "writing" | "assistant";
|
||||
|
||||
|
||||
@@ -20,6 +20,5 @@ export const ONBOARDING_STEP_ORDER: readonly OnboardingStep[] = [
|
||||
"use_case",
|
||||
"workspace",
|
||||
"runtime",
|
||||
"agent",
|
||||
"first_issue",
|
||||
"teammate",
|
||||
] as const;
|
||||
|
||||
@@ -53,6 +53,38 @@ export async function completeOnboarding(
|
||||
await useAuthStore.getState().refreshMe();
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime-connected onboarding path. The server creates or reuses the
|
||||
* default Multica Helper agent and the single onboarding issue, marks
|
||||
* onboarding complete, and suppresses the older starter-content prompt.
|
||||
*/
|
||||
export async function bootstrapRuntimeOnboarding(
|
||||
workspaceId: string,
|
||||
runtimeId: string,
|
||||
): Promise<{ workspace_id: string; agent_id: string; issue_id: string }> {
|
||||
const result = await api.bootstrapOnboardingRuntime({
|
||||
workspace_id: workspaceId,
|
||||
runtime_id: runtimeId,
|
||||
});
|
||||
await useAuthStore.getState().refreshMe();
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime-skipped onboarding path. The server creates or reuses one
|
||||
* self-serve onboarding issue, marks onboarding complete, and suppresses
|
||||
* the older starter-content prompt so the user is not flooded with tasks.
|
||||
*/
|
||||
export async function bootstrapNoRuntimeOnboarding(
|
||||
workspaceId: string,
|
||||
): Promise<{ workspace_id: string; issue_id: string }> {
|
||||
const result = await api.bootstrapOnboardingNoRuntime({
|
||||
workspace_id: workspaceId,
|
||||
});
|
||||
await useAuthStore.getState().refreshMe();
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records interest in cloud runtimes. Pure side effect — does NOT
|
||||
* complete onboarding; the user still has to pick a real Step 3
|
||||
|
||||
@@ -5,6 +5,7 @@ export type OnboardingStep =
|
||||
| "use_case"
|
||||
| "workspace"
|
||||
| "runtime"
|
||||
| "teammate"
|
||||
| "agent"
|
||||
| "first_issue";
|
||||
|
||||
|
||||
@@ -190,6 +190,20 @@
|
||||
"add_more_hint": "Add more agents anytime. A small team of specialized agents beats one jack-of-all-trades.",
|
||||
"docs_link": "Creating your first agent →"
|
||||
},
|
||||
"step_teammate": {
|
||||
"eyebrow": "First teammate",
|
||||
"headline": "Create your first teammate: Multica Helper",
|
||||
"lede": "Multica Helper is a starter agent connected to {{runtime}}. It will open your first guided issue and help you learn the core workflow.",
|
||||
"avatar_label": "Multica Helper avatar",
|
||||
"name": "Multica Helper",
|
||||
"role": "Onboarding guide",
|
||||
"point_issue": "Starts from one onboarding issue",
|
||||
"point_context": "Uses your workspace context and issue details",
|
||||
"point_customize": "You can rename or replace it later",
|
||||
"footer_hint": "We'll create one agent and one onboarding issue.",
|
||||
"create_action": "Create Multica Helper",
|
||||
"create_failed": "Failed to create Multica Helper"
|
||||
},
|
||||
"step_workspace": {
|
||||
"eyebrow_first": "Your first workspace",
|
||||
"eyebrow_resume": "Pick up or start fresh",
|
||||
@@ -232,8 +246,8 @@
|
||||
"perk_invite": "Invite teammates — they see only this workspace",
|
||||
"perk_switch": "Switch to other workspaces anytime from the top-left",
|
||||
"next_runtime": "Connect a computer so your agents have somewhere to run",
|
||||
"next_agent": "Create your first agent matched to your role",
|
||||
"next_starter": "Watch it pick up a starter task and reply",
|
||||
"next_agent": "Create Multica Helper from your connected runtime",
|
||||
"next_starter": "Open one onboarding issue for your next step",
|
||||
"preview": {
|
||||
"inbox_label": "Inbox",
|
||||
"inbox_meta": "your notifications",
|
||||
|
||||
@@ -190,6 +190,20 @@
|
||||
"add_more_hint": "随时添加更多智能体。一支专长各异的小团队胜过一个万能选手。",
|
||||
"docs_link": "创建第一个智能体 →"
|
||||
},
|
||||
"step_teammate": {
|
||||
"eyebrow": "第一个队友",
|
||||
"headline": "创建你的第一个队友:Multica Helper",
|
||||
"lede": "Multica Helper 是连接到 {{runtime}} 的入门智能体。它会打开一个上手引导 issue,带你走完核心流程。",
|
||||
"avatar_label": "Multica Helper 头像",
|
||||
"name": "Multica Helper",
|
||||
"role": "上手引导",
|
||||
"point_issue": "只从一个上手引导 issue 开始",
|
||||
"point_context": "读取工作区上下文和 issue 详情",
|
||||
"point_customize": "之后可以改名或替换",
|
||||
"footer_hint": "我们会创建一个智能体和一个上手引导 issue。",
|
||||
"create_action": "创建 Multica Helper",
|
||||
"create_failed": "创建 Multica Helper 失败"
|
||||
},
|
||||
"step_workspace": {
|
||||
"eyebrow_first": "你的第一个工作区",
|
||||
"eyebrow_resume": "继续,或重新开始",
|
||||
@@ -232,8 +246,8 @@
|
||||
"perk_invite": "邀请同事 —— 他们只会看到这个工作区",
|
||||
"perk_switch": "随时从左上角切换到其他工作区",
|
||||
"next_runtime": "连接一台电脑,给智能体一个跑的地方",
|
||||
"next_agent": "创建符合你角色的第一个智能体",
|
||||
"next_starter": "看它接下入门 task 并回复",
|
||||
"next_agent": "用你连接的运行时创建 Multica Helper",
|
||||
"next_starter": "打开一个上手引导 issue,继续下一步",
|
||||
"preview": {
|
||||
"inbox_label": "收件箱",
|
||||
"inbox_meta": "你的通知",
|
||||
|
||||
@@ -31,7 +31,7 @@ describe("StepHeader", () => {
|
||||
});
|
||||
|
||||
it("shows 'Step N of M' text matching the current step's position", () => {
|
||||
// workspace is index 3 (after source/role/use_case) → Step 4 of 7
|
||||
// workspace is index 3 (after source/role/use_case) → Step 4.
|
||||
render(<StepHeader currentStep="workspace" />);
|
||||
expect(
|
||||
screen.getByText(`Step 4 of ${ONBOARDING_STEP_ORDER.length}`),
|
||||
@@ -39,9 +39,9 @@ describe("StepHeader", () => {
|
||||
});
|
||||
|
||||
it("sets accessible progressbar attrs", () => {
|
||||
render(<StepHeader currentStep="agent" />);
|
||||
render(<StepHeader currentStep="runtime" />);
|
||||
const bar = screen.getByRole("progressbar");
|
||||
expect(bar).toHaveAttribute("aria-valuenow", "6"); // agent is index 5 → step 6
|
||||
expect(bar).toHaveAttribute("aria-valuenow", "5"); // runtime is index 4 → step 5
|
||||
expect(bar).toHaveAttribute("aria-valuemax", String(ONBOARDING_STEP_ORDER.length));
|
||||
});
|
||||
|
||||
|
||||
@@ -7,17 +7,17 @@ import { captureEvent } from "@multica/core/analytics";
|
||||
import { setCurrentWorkspace } from "@multica/core/platform";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import {
|
||||
bootstrapNoRuntimeOnboarding,
|
||||
bootstrapRuntimeOnboarding,
|
||||
completeOnboarding,
|
||||
ONBOARDING_STEP_ORDER,
|
||||
saveQuestionnaire,
|
||||
type OnboardingCompletionPath,
|
||||
type OnboardingStep,
|
||||
type QuestionnaireAnswers,
|
||||
} from "@multica/core/onboarding";
|
||||
import { issueKeys } from "@multica/core/issues/queries";
|
||||
import { workspaceListOptions, workspaceKeys } from "@multica/core/workspace/queries";
|
||||
import type { Agent, AgentRuntime, Workspace } from "@multica/core/types";
|
||||
import { DragStrip } from "@multica/views/platform";
|
||||
import { StepHeader } from "./components/step-header";
|
||||
import type { AgentRuntime, Workspace } from "@multica/core/types";
|
||||
import { StepWelcome } from "./steps/step-welcome";
|
||||
import { StepSource } from "./steps/step-source";
|
||||
import { StepRole } from "./steps/step-role";
|
||||
@@ -25,8 +25,7 @@ import { StepUseCase } from "./steps/step-use-case";
|
||||
import { StepWorkspace } from "./steps/step-workspace";
|
||||
import { StepRuntimeConnect } from "./steps/step-runtime-connect";
|
||||
import { StepPlatformFork } from "./steps/step-platform-fork";
|
||||
import { StepAgent } from "./steps/step-agent";
|
||||
import { StepFirstIssue } from "./steps/step-first-issue";
|
||||
import { StepTeammate } from "./steps/step-teammate";
|
||||
import { useT } from "../i18n";
|
||||
|
||||
const EMPTY_QUESTIONNAIRE: QuestionnaireAnswers = {
|
||||
@@ -62,18 +61,18 @@ function mergeQuestionnaire(
|
||||
|
||||
/**
|
||||
* Shell's onComplete contract:
|
||||
* onComplete(workspace?) — if present, navigate into its issues list;
|
||||
* if omitted, fall back to root. A Starter-content opt-in dialog runs
|
||||
* on the issues page itself (see `StarterContentPrompt`), so the flow
|
||||
* doesn't carry `firstIssueId` any more — there is no welcome issue
|
||||
* created by onboarding.
|
||||
* onComplete(workspace?, issueId?) — if an issue id is present, navigate
|
||||
* straight into that onboarding issue; otherwise navigate into the
|
||||
* workspace issues list. Runtime-connected onboarding creates one
|
||||
* Multica Helper agent plus one issue; runtime-skipped onboarding creates one
|
||||
* self-serve issue. Both suppress the old starter-content prompt.
|
||||
*/
|
||||
export function OnboardingFlow({
|
||||
onComplete,
|
||||
runtimeInstructions,
|
||||
onRuntimeRefresh,
|
||||
}: {
|
||||
onComplete: (workspace?: Workspace) => void;
|
||||
onComplete: (workspace?: Workspace, issueId?: string) => void;
|
||||
runtimeInstructions?: React.ReactNode;
|
||||
/** Desktop wires this to restart the bundled daemon so a freshly
|
||||
* installed agent CLI gets picked up on the runtime step. Web omits
|
||||
@@ -99,7 +98,6 @@ export function OnboardingFlow({
|
||||
const [step, setStep] = useState<OnboardingStep>("welcome");
|
||||
const [workspace, setWorkspace] = useState<Workspace | null>(null);
|
||||
const [runtime, setRuntime] = useState<AgentRuntime | null>(null);
|
||||
const [, setAgent] = useState<Agent | null>(null);
|
||||
|
||||
// Fetched at Step 0 + Step 2. Step 2 uses it to detect a pre-existing
|
||||
// workspace from an earlier abandoned onboarding (so StepWorkspace shows
|
||||
@@ -200,34 +198,51 @@ export function OnboardingFlow({
|
||||
[advanceFrom],
|
||||
);
|
||||
|
||||
const handleRuntimeNext = useCallback((rt: AgentRuntime | null) => {
|
||||
setRuntime(rt);
|
||||
// Custom branch — not derived from ONBOARDING_STEP_ORDER because no
|
||||
// runtime → no agent possible, so we skip Step 4 ("agent") and go
|
||||
// straight to the finalizer. The post-landing StarterContentPrompt
|
||||
// will detect "no agent in this workspace" and offer the self-serve
|
||||
// template.
|
||||
setStep(rt ? "agent" : "first_issue");
|
||||
}, []);
|
||||
|
||||
const handleAgentCreated = useCallback(
|
||||
(created: Agent) => {
|
||||
setAgent(created);
|
||||
// Mark the workspace's agent list stale so the dashboard's first
|
||||
// mount refetches and includes the just-created agent. Without
|
||||
// this, anything resolving an agent ID from the cached list (the
|
||||
// welcome issue's assignee in particular) renders as "Unknown
|
||||
// Agent" until something else triggers a refetch.
|
||||
if (workspace) {
|
||||
qc.invalidateQueries({
|
||||
queryKey: workspaceKeys.agents(workspace.id),
|
||||
});
|
||||
const handleRuntimeNext = useCallback(
|
||||
async (rt: AgentRuntime | null) => {
|
||||
if (!workspace) return;
|
||||
if (!rt) {
|
||||
// No runtime -> no agent execution yet. Create one focused
|
||||
// self-serve onboarding issue instead of seeding the older
|
||||
// multi-issue starter project.
|
||||
try {
|
||||
const result = await bootstrapNoRuntimeOnboarding(workspace.id);
|
||||
await qc.invalidateQueries({ queryKey: issueKeys.all(workspace.id) });
|
||||
onComplete(workspace, result.issue_id || undefined);
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : t(($) => $.errors.skip_failed),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
advanceFrom("agent");
|
||||
|
||||
setRuntime(rt);
|
||||
advanceFrom("runtime");
|
||||
},
|
||||
[workspace, qc, advanceFrom],
|
||||
[workspace, qc, onComplete, t, advanceFrom],
|
||||
);
|
||||
|
||||
const handleCreateTeammate = useCallback(async () => {
|
||||
if (!workspace || !runtime) return;
|
||||
|
||||
try {
|
||||
const result = await bootstrapRuntimeOnboarding(workspace.id, runtime.id);
|
||||
await Promise.all([
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.agents(workspace.id) }),
|
||||
qc.invalidateQueries({ queryKey: issueKeys.all(workspace.id) }),
|
||||
]);
|
||||
onComplete(workspace, result.issue_id || undefined);
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: t(($) => $.step_teammate.create_failed),
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}, [workspace, runtime, qc, onComplete, t]);
|
||||
|
||||
const handleBack = useCallback((from: OnboardingStep) => {
|
||||
const idx = ONBOARDING_STEP_ORDER.indexOf(from);
|
||||
if (idx <= 0) {
|
||||
@@ -239,18 +254,9 @@ export function OnboardingFlow({
|
||||
setStep(prev);
|
||||
}, []);
|
||||
|
||||
// Step 5 fired `completeOnboarding` itself. Here we just route the
|
||||
// user to their workspace — the starter-content decision happens
|
||||
// inside the workspace via the `StarterContentPrompt` dialog.
|
||||
const handleFinished = useCallback(() => {
|
||||
onComplete(workspace ?? undefined);
|
||||
}, [workspace, onComplete]);
|
||||
|
||||
// Welcome, Questionnaire, and Workspace own full-bleed two-column
|
||||
// layouts (hero / side panel) with their own DragStrip + StepHeader.
|
||||
// The remaining steps (runtime / agent / first_issue) still render
|
||||
// inside a narrow legacy single-column shell below — they'll each
|
||||
// move out as they get redesigned.
|
||||
// The runtime step owns its own full-bleed shell.
|
||||
if (step === "welcome") {
|
||||
return (
|
||||
<StepWelcome
|
||||
@@ -334,48 +340,17 @@ export function OnboardingFlow({
|
||||
);
|
||||
}
|
||||
|
||||
// Step 4 owns the same full-bleed editorial shell as Workspace /
|
||||
// Questionnaire. `questionnaire` is threaded through so StepAgent
|
||||
// can recommend a template based on the user's Q1–Q3 answers.
|
||||
// No skip path: reaching Step 4 means a runtime was picked at
|
||||
// Step 3, so creating the agent IS the step's purpose. Users who
|
||||
// want a runtime-less workspace bypass at Step 3 and skip Step 4
|
||||
// entirely (flow routes runtime=null → first_issue directly).
|
||||
if (step === "agent" && runtime) {
|
||||
if (step === "teammate" && workspace && runtime) {
|
||||
return (
|
||||
<StepAgent
|
||||
<StepTeammate
|
||||
runtime={runtime}
|
||||
questionnaire={answers}
|
||||
onCreated={handleAgentCreated}
|
||||
onBack={() => handleBack("agent")}
|
||||
onCreate={handleCreateTeammate}
|
||||
onBack={() => handleBack("teammate")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Derive the completion-path label for Step 5. The cloud-waitlist
|
||||
// exit was removed from Step 3 (replaced with a "Coming soon" badge)
|
||||
// so this is now a binary: runtime → "full", no runtime → "runtime_skipped".
|
||||
const completionPath: OnboardingCompletionPath = runtime
|
||||
? "full"
|
||||
: "runtime_skipped";
|
||||
|
||||
return (
|
||||
<div className="animate-onboarding-enter flex min-h-full flex-col">
|
||||
<DragStrip />
|
||||
<div className="flex flex-1 flex-col items-center px-6 pb-12">
|
||||
<div className="flex w-full max-w-xl flex-col gap-8">
|
||||
<StepHeader currentStep={step} />
|
||||
{step === "first_issue" && (
|
||||
<StepFirstIssue
|
||||
onFinished={handleFinished}
|
||||
completionPath={completionPath}
|
||||
workspaceId={workspace?.id}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
export type { OnboardingStep };
|
||||
|
||||
@@ -13,10 +13,11 @@ import { useT } from "../../i18n";
|
||||
/**
|
||||
* Step 5 — the final onboarding beat.
|
||||
*
|
||||
* All this step does now is flip `onboarded_at` on the server. The former
|
||||
* in-flight bootstrap (welcome issue + Getting Started project + sub-issues)
|
||||
* moved out of onboarding entirely: it's a post-landing opt-in dialog
|
||||
* (`StarterContentPrompt`) that runs inside the workspace after navigation.
|
||||
* Runtime-skipped finalizer. The runtime-connected path now bootstraps one
|
||||
* default assistant plus one onboarding issue server-side and routes there
|
||||
* directly. This step remains for users who skip runtime connection: it only
|
||||
* flips `onboarded_at`, then lands them in the workspace where the self-serve
|
||||
* starter-content prompt can run.
|
||||
* Two consequences of that move:
|
||||
*
|
||||
* 1. This step can't fail in user-visible ways any more. `completeOnboarding`
|
||||
|
||||
@@ -190,8 +190,8 @@ export function StepPlatformFork({
|
||||
|
||||
{/* Inline action bar — hint on the left, Skip on the right.
|
||||
Advancement for the CLI path is owned by the CLI
|
||||
dialog's own "Connect & continue" button; Skip is the
|
||||
self-serve exit. */}
|
||||
dialog's own "Connect & continue" button; Skip creates
|
||||
the single self-serve onboarding issue. */}
|
||||
<div className="mt-8 flex max-w-[560px] flex-wrap items-center justify-between gap-x-4 gap-y-2">
|
||||
<span
|
||||
aria-live="polite"
|
||||
@@ -545,4 +545,3 @@ function CliWaitingStatus({ dialogOpen }: { dialogOpen: boolean }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import { useT } from "../../i18n";
|
||||
|
||||
/**
|
||||
* Step 2 — "Which best describes you?" Primary signal for the
|
||||
* agent template recommendation on Step 6.
|
||||
* onboarding assistant and starter issue content.
|
||||
*/
|
||||
export function StepRole({
|
||||
answers,
|
||||
|
||||
@@ -187,8 +187,8 @@ function FancyView({
|
||||
}, [onRefresh, qc, wsId, refreshing]);
|
||||
|
||||
// Skip is always available — regardless of phase. Hitting Skip routes
|
||||
// the flow through the self-serve branch (agent=null), which still
|
||||
// completes onboarding and seeds a Getting Started project.
|
||||
// through the runtime-less branch, which creates one focused self-serve
|
||||
// onboarding issue instead of seeding the old starter project.
|
||||
const handleSkip = async () => {
|
||||
if (submitting) return;
|
||||
setSubmitting(true);
|
||||
|
||||
186
packages/views/onboarding/steps/step-teammate.tsx
Normal file
186
packages/views/onboarding/steps/step-teammate.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, type ComponentType } from "react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
MessageSquareText,
|
||||
Settings2,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { useScrollFade } from "@multica/ui/hooks/use-scroll-fade";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import type { AgentRuntime } from "@multica/core/types";
|
||||
import { DragStrip } from "@multica/views/platform";
|
||||
import { StepHeader } from "../components/step-header";
|
||||
import { RuntimeAsidePanel } from "../components/runtime-aside-panel";
|
||||
import { ProviderLogo } from "../../runtimes/components/provider-logo";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
const MULTICA_HELPER_AVATAR_URL =
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 128 128'%3E%3Crect width='128' height='128' rx='30' fill='%23111217'/%3E%3Cpath d='M28 76c8-22 22-33 42-33 15 0 26 7 32 20' fill='none' stroke='%23ffffff' stroke-width='10' stroke-linecap='round'/%3E%3Cpath d='M38 88c13 13 39 17 58 1' fill='none' stroke='%238EE3C8' stroke-width='8' stroke-linecap='round'/%3E%3Ccircle cx='48' cy='56' r='7' fill='%23ffffff'/%3E%3Ccircle cx='78' cy='56' r='7' fill='%23ffffff'/%3E%3Cpath d='M64 20v14' stroke='%238EE3C8' stroke-width='8' stroke-linecap='round'/%3E%3Ccircle cx='64' cy='16' r='6' fill='%238EE3C8'/%3E%3C/svg%3E";
|
||||
|
||||
export function StepTeammate({
|
||||
runtime,
|
||||
onCreate,
|
||||
onBack,
|
||||
}: {
|
||||
runtime: AgentRuntime;
|
||||
onCreate: () => void | Promise<void>;
|
||||
onBack?: () => void;
|
||||
}) {
|
||||
const { t } = useT("onboarding");
|
||||
const mainRef = useRef<HTMLElement>(null);
|
||||
const fadeStyle = useScrollFade(mainRef);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (creating) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
await onCreate();
|
||||
} catch {
|
||||
// The parent owns the toast. This step only restores the button state.
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="animate-onboarding-enter grid h-full min-h-0 grid-cols-1 lg:grid-cols-[minmax(0,1fr)_480px]">
|
||||
<div className="flex min-h-0 flex-col">
|
||||
<DragStrip />
|
||||
|
||||
<header className="flex shrink-0 items-center gap-4 bg-background px-6 py-3 sm:px-10 md:px-14 lg:px-16">
|
||||
{onBack ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
disabled={creating}
|
||||
className="flex items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
{t(($) => $.common.back)}
|
||||
</button>
|
||||
) : (
|
||||
<span aria-hidden className="w-0" />
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<StepHeader currentStep="teammate" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main
|
||||
ref={mainRef}
|
||||
style={fadeStyle}
|
||||
className="min-h-0 flex-1 overflow-y-auto"
|
||||
>
|
||||
<div className="mx-auto w-full max-w-[660px] px-6 py-10 sm:px-10 md:px-14 lg:px-0 lg:py-14">
|
||||
<div className="mb-2 text-xs font-medium uppercase tracking-[0.08em] text-muted-foreground">
|
||||
{t(($) => $.step_teammate.eyebrow)}
|
||||
</div>
|
||||
<h1 className="text-balance font-serif text-[36px] font-medium leading-[1.1] tracking-tight text-foreground">
|
||||
{t(($) => $.step_teammate.headline)}
|
||||
</h1>
|
||||
<p className="mt-4 max-w-[580px] text-[15.5px] leading-[1.55] text-muted-foreground">
|
||||
{t(($) => $.step_teammate.lede, { runtime: runtime.name })}
|
||||
</p>
|
||||
|
||||
<section className="mt-10 overflow-hidden rounded-xl border bg-card">
|
||||
<div className="flex flex-col gap-6 p-6 sm:flex-row sm:items-center">
|
||||
<img
|
||||
src={MULTICA_HELPER_AVATAR_URL}
|
||||
alt={t(($) => $.step_teammate.avatar_label)}
|
||||
className="h-24 w-24 shrink-0 rounded-2xl shadow-sm"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-2xl font-semibold tracking-tight text-foreground">
|
||||
{t(($) => $.step_teammate.name)}
|
||||
</h2>
|
||||
<span className="inline-flex items-center rounded-full border bg-muted px-2.5 py-1 text-[12px] font-medium text-muted-foreground">
|
||||
{t(($) => $.step_teammate.role)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 inline-flex max-w-full items-center gap-2 rounded-md bg-muted/70 px-3 py-2 text-sm text-muted-foreground">
|
||||
<ProviderLogo provider={runtime.provider} className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">{runtime.name}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"h-1.5 w-1.5 shrink-0 rounded-full",
|
||||
runtime.status === "online"
|
||||
? "bg-success"
|
||||
: "bg-muted-foreground/40",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid border-t bg-muted/20 sm:grid-cols-3">
|
||||
<TeammatePoint
|
||||
icon={MessageSquareText}
|
||||
label={t(($) => $.step_teammate.point_issue)}
|
||||
/>
|
||||
<TeammatePoint
|
||||
icon={Sparkles}
|
||||
label={t(($) => $.step_teammate.point_context)}
|
||||
/>
|
||||
<TeammatePoint
|
||||
icon={Settings2}
|
||||
label={t(($) => $.step_teammate.point_customize)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="mt-8 flex flex-wrap items-center justify-end gap-x-4 gap-y-2">
|
||||
<span
|
||||
aria-live="polite"
|
||||
className="mr-auto text-xs text-muted-foreground"
|
||||
>
|
||||
{t(($) => $.step_teammate.footer_hint)}
|
||||
</span>
|
||||
<Button size="lg" disabled={creating} onClick={handleCreate}>
|
||||
{creating ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
)}
|
||||
{t(($) => $.step_teammate.create_action)}
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<aside className="hidden min-h-0 border-l bg-muted/40 lg:flex lg:flex-col">
|
||||
<DragStrip />
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-12 py-12">
|
||||
<RuntimeAsidePanel />
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TeammatePoint({
|
||||
icon: Icon,
|
||||
label,
|
||||
}: {
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-[104px] items-start gap-3 border-t p-5 first:border-t-0 sm:border-l sm:border-t-0 sm:first:border-l-0">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-background text-foreground">
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
<p className="text-sm leading-[1.45] text-foreground">{label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -236,7 +236,7 @@ function TroubleshootingDetails() {
|
||||
{/* CLI command — literal shell string, not i18n content. */}
|
||||
{/* eslint-disable-next-line i18next/no-literal-string */}
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] text-foreground">
|
||||
multica daemon status
|
||||
{"multica daemon status"}
|
||||
</code>
|
||||
</li>
|
||||
<li className="flex items-center gap-1.5">
|
||||
@@ -244,7 +244,7 @@ function TroubleshootingDetails() {
|
||||
{/* CLI command — literal shell string, not i18n content. */}
|
||||
{/* eslint-disable-next-line i18next/no-literal-string */}
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] text-foreground">
|
||||
multica daemon logs -f
|
||||
{"multica daemon logs -f"}
|
||||
</code>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -308,6 +308,8 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
|
||||
r.Patch("/api/me", h.UpdateMe)
|
||||
r.Patch("/api/me/onboarding", h.PatchOnboarding)
|
||||
r.Post("/api/me/onboarding/complete", h.CompleteOnboarding)
|
||||
r.Post("/api/me/onboarding/runtime-bootstrap", h.BootstrapOnboardingRuntime)
|
||||
r.Post("/api/me/onboarding/no-runtime-bootstrap", h.BootstrapOnboardingNoRuntime)
|
||||
r.Post("/api/me/onboarding/cloud-waitlist", h.JoinCloudWaitlist)
|
||||
r.Post("/api/me/starter-content/import", h.ImportStarterContent)
|
||||
r.Post("/api/me/starter-content/dismiss", h.DismissStarterContent)
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/analytics"
|
||||
"github.com/multica-ai/multica/server/internal/issueguard"
|
||||
"github.com/multica-ai/multica/server/internal/logger"
|
||||
"github.com/multica-ai/multica/server/internal/util"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
@@ -29,6 +30,10 @@ const (
|
||||
// letting a malicious user stuff the JSONB column.
|
||||
patchOnboardingBodyLimit = 16 * 1024
|
||||
|
||||
// Runtime bootstrap is just workspace_id + runtime_id, but keep a
|
||||
// separate small cap so this endpoint cannot be used as bulk storage.
|
||||
runtimeBootstrapBodyLimit = 8 * 1024
|
||||
|
||||
// Import payload contains the full starter-content template. Each
|
||||
// sub-issue's markdown description is ~2 KiB; with ~8 sub-issues,
|
||||
// a welcome issue (~3 KiB), and a project description, 64 KiB is
|
||||
@@ -36,6 +41,132 @@ const (
|
||||
importStarterContentBodyLimit = 64 * 1024
|
||||
)
|
||||
|
||||
const (
|
||||
onboardingAssistantName = "Multica Helper"
|
||||
onboardingIssueTitle = "Start here: learn Multica with Multica Helper"
|
||||
onboardingAgentTemplate = "multica_helper"
|
||||
noRuntimeIssueTitle = "Connect a runtime to start using agents"
|
||||
)
|
||||
|
||||
const onboardingAssistantDescription = "Default guide for your first Multica workspace."
|
||||
|
||||
const onboardingAssistantAvatarURL = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 128 128'%3E%3Crect width='128' height='128' rx='30' fill='%23111217'/%3E%3Cpath d='M28 76c8-22 22-33 42-33 15 0 26 7 32 20' fill='none' stroke='%23ffffff' stroke-width='10' stroke-linecap='round'/%3E%3Cpath d='M38 88c13 13 39 17 58 1' fill='none' stroke='%238EE3C8' stroke-width='8' stroke-linecap='round'/%3E%3Ccircle cx='48' cy='56' r='7' fill='%23ffffff'/%3E%3Ccircle cx='78' cy='56' r='7' fill='%23ffffff'/%3E%3Cpath d='M64 20v14' stroke='%238EE3C8' stroke-width='8' stroke-linecap='round'/%3E%3Ccircle cx='64' cy='16' r='6' fill='%238EE3C8'/%3E%3C/svg%3E"
|
||||
|
||||
const onboardingAssistantInstructions = `You are Multica Helper, the user's first Multica teammate. Your job is to onboard them inside the first issue.
|
||||
|
||||
When the onboarding issue starts, leave a concise first comment that:
|
||||
1. Explains that issues are where work happens in Multica.
|
||||
2. Tells the user they can reply in the thread or @mention you to continue.
|
||||
3. Asks for one concrete task they want help with.
|
||||
4. Mentions that they can create more agents and connect more runtimes later.
|
||||
|
||||
Keep the tone practical. Do not create extra issues or projects unless the user asks.`
|
||||
|
||||
const onboardingIssueDescription = `Welcome to Multica.
|
||||
|
||||
This is your guided first run. Multica Helper is assigned to this issue and will help you try the core workflow:
|
||||
|
||||
1. Read Multica Helper's first comment.
|
||||
2. Reply with something you want to build, fix, write, or plan.
|
||||
3. @mention Multica Helper when you want it to continue.
|
||||
4. Open Agents and Runtimes later when you want to customize the teammate or the computer it runs on.
|
||||
|
||||
You can close this issue when the workflow makes sense.`
|
||||
|
||||
func noRuntimeIssueDescription(language pgtype.Text) string {
|
||||
if language.Valid && strings.HasPrefix(language.String, "zh") {
|
||||
return zhNoRuntimeIssueDescription()
|
||||
}
|
||||
return enNoRuntimeIssueDescription()
|
||||
}
|
||||
|
||||
func enNoRuntimeIssueDescription() string {
|
||||
return strings.Join([]string{
|
||||
"Welcome to Multica.",
|
||||
"",
|
||||
"Agents need a runtime before they can execute work. You can still use Multica as a lightweight project-management workspace while you install one.",
|
||||
"",
|
||||
"## Try Multica first",
|
||||
"",
|
||||
"Before the runtime is ready, you can:",
|
||||
"",
|
||||
"1. Create a project for your current work.",
|
||||
"2. Create a few issues and move them across backlog, todo, in_progress, and done.",
|
||||
"3. Add priorities, labels, comments, and subscriptions.",
|
||||
"4. Use Inbox to track assignments and mentions.",
|
||||
"",
|
||||
"That gives you the project-management layer first. Once a runtime is connected, agents can start working from the same issues.",
|
||||
"",
|
||||
"## Install your first agent runtime",
|
||||
"",
|
||||
"Full guide: https://multica.ai/docs/install-agent-runtime",
|
||||
"",
|
||||
"For English users, the fastest first path is Codex:",
|
||||
"",
|
||||
"1. Make sure Node.js is installed.",
|
||||
"2. Install Codex:",
|
||||
" npm i -g @openai/codex",
|
||||
"3. Sign in:",
|
||||
" codex",
|
||||
"4. Confirm your terminal can find it:",
|
||||
" which codex",
|
||||
" codex --version",
|
||||
"5. Restart the Multica daemon:",
|
||||
" multica daemon restart",
|
||||
" If you use the desktop app, restarting the app is enough.",
|
||||
"6. Return to Runtimes and refresh. You should see a Codex runtime online.",
|
||||
"7. Create your first agent from that runtime, then assign an issue to the agent and set status to todo.",
|
||||
"",
|
||||
"Codex reference: https://developers.openai.com/codex/cli",
|
||||
"",
|
||||
"When the runtime is connected, you can create Multica Helper for a guided first run.",
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
func zhNoRuntimeIssueDescription() string {
|
||||
return strings.Join([]string{
|
||||
"欢迎来到 Multica。",
|
||||
"",
|
||||
"智能体需要先连上运行时才能执行工作。运行时还没准备好时,你也可以先把 Multica 当作轻量项目管理工具体验起来。",
|
||||
"",
|
||||
"## 先体验项目管理功能",
|
||||
"",
|
||||
"运行时安装前,你可以先做这些事:",
|
||||
"",
|
||||
"1. 为当前工作创建一个项目。",
|
||||
"2. 新建几个 issue,并在 backlog、todo、in_progress、done 之间流转。",
|
||||
"3. 给 issue 加优先级、标签、评论和订阅。",
|
||||
"4. 用收件箱追踪分配给你的事项和 @mention。",
|
||||
"",
|
||||
"这样你先熟悉项目管理层。连上运行时后,智能体会直接在这些 issue 上开始工作。",
|
||||
"",
|
||||
"## 安装第一个 Agent 运行时",
|
||||
"",
|
||||
"完整文档:https://multica.ai/docs/install-agent-runtime",
|
||||
"",
|
||||
"中文用户建议先装 Kimi CLI:",
|
||||
"",
|
||||
"1. 在 macOS / Linux 终端安装 Kimi CLI:",
|
||||
" curl -LsSf https://code.kimi.com/install.sh | bash",
|
||||
" Windows PowerShell:",
|
||||
" Invoke-RestMethod https://code.kimi.com/install.ps1 | Invoke-Expression",
|
||||
"2. 确认终端能找到 Kimi:",
|
||||
" kimi --version",
|
||||
"3. 在你想让 Kimi 工作的项目目录里启动一次:",
|
||||
" kimi",
|
||||
"4. 首次启动后输入 /login,按提示完成 Kimi Code 或 API key 配置。",
|
||||
"5. 重启 Multica 守护进程:",
|
||||
" multica daemon restart",
|
||||
" 如果你用桌面端,重启 app 即可。",
|
||||
"6. 回到 Runtimes 页面刷新。你应该能看到一个在线的 Kimi 运行时。",
|
||||
"7. 用这个运行时创建第一个智能体,再把一个 issue 分配给它,并把状态切到 todo。",
|
||||
"",
|
||||
"Kimi CLI 官方文档:https://moonshotai.github.io/kimi-cli/zh/guides/getting-started.html",
|
||||
"",
|
||||
"运行时连上后,你就可以创建 Multica Helper,开始一次有智能体参与的上手引导。",
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
// completeOnboardingRequest carries the client's view of which exit the
|
||||
// user took from the flow. The client is the only place that knows
|
||||
// whether Step 3's runtime connect was skipped, whether the cloud
|
||||
@@ -117,6 +248,429 @@ func (h *Handler) CompleteOnboarding(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, userToResponse(user))
|
||||
}
|
||||
|
||||
type bootstrapOnboardingRuntimeRequest struct {
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
RuntimeID string `json:"runtime_id"`
|
||||
}
|
||||
|
||||
type bootstrapOnboardingRuntimeResponse struct {
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
IssueID string `json:"issue_id"`
|
||||
}
|
||||
|
||||
type bootstrapOnboardingNoRuntimeRequest struct {
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
}
|
||||
|
||||
type bootstrapOnboardingNoRuntimeResponse struct {
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
IssueID string `json:"issue_id"`
|
||||
}
|
||||
|
||||
// BootstrapOnboardingRuntime is the runtime-connected onboarding exit:
|
||||
// create or reuse one default helper agent, create or reuse one onboarding
|
||||
// issue assigned to it, mark onboarding complete, and suppress the older
|
||||
// starter-content dialog. The flow is deliberately one issue, not a seeded
|
||||
// project with many tasks.
|
||||
func (h *Handler) BootstrapOnboardingRuntime(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, runtimeBootstrapBodyLimit)
|
||||
var req bootstrapOnboardingRuntimeRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.WorkspaceID == "" {
|
||||
writeError(w, http.StatusBadRequest, "workspace_id is required")
|
||||
return
|
||||
}
|
||||
if req.RuntimeID == "" {
|
||||
writeError(w, http.StatusBadRequest, "runtime_id is required")
|
||||
return
|
||||
}
|
||||
wsUUID, ok := parseUUIDOrBadRequest(w, req.WorkspaceID, "workspace_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
runtimeUUID, ok := parseUUIDOrBadRequest(w, req.RuntimeID, "runtime_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
req.WorkspaceID = uuidToString(wsUUID)
|
||||
|
||||
tx, err := h.TxStarter.Begin(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to start onboarding")
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(r.Context())
|
||||
qtx := h.Queries.WithTx(tx)
|
||||
|
||||
userBefore, err := qtx.GetUser(r.Context(), parseUUID(userID))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to load user")
|
||||
return
|
||||
}
|
||||
firstCompletion := !userBefore.OnboardedAt.Valid
|
||||
|
||||
member, err := qtx.GetMemberByUserAndWorkspace(r.Context(), db.GetMemberByUserAndWorkspaceParams{
|
||||
UserID: parseUUID(userID),
|
||||
WorkspaceID: wsUUID,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusForbidden, "not a member of this workspace")
|
||||
return
|
||||
}
|
||||
|
||||
runtime, err := qtx.GetAgentRuntimeForWorkspace(r.Context(), db.GetAgentRuntimeForWorkspaceParams{
|
||||
ID: runtimeUUID,
|
||||
WorkspaceID: wsUUID,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid runtime_id")
|
||||
return
|
||||
}
|
||||
if !canUseRuntimeForAgent(member, runtime) {
|
||||
writeError(w, http.StatusForbidden, "this runtime is private; only its owner or a workspace admin can create agents on it")
|
||||
return
|
||||
}
|
||||
|
||||
agents, err := qtx.ListAgents(r.Context(), wsUUID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list agents")
|
||||
return
|
||||
}
|
||||
isFirstAgent := len(agents) == 0
|
||||
|
||||
var assistant db.Agent
|
||||
assistantCreated := false
|
||||
// Only reuse helpers this flow could have created: name match AND
|
||||
// workspace-visible. Skipping private agents is the access-control
|
||||
// gate — a private "Multica Helper" owned by another member must not
|
||||
// be auto-assigned to the bootstrap issue, which would bypass
|
||||
// canAccessPrivateAgent and trigger a task as that private agent.
|
||||
for _, existing := range agents {
|
||||
if existing.Name == onboardingAssistantName && existing.Visibility == "workspace" {
|
||||
assistant = existing
|
||||
break
|
||||
}
|
||||
}
|
||||
if !assistant.ID.Valid {
|
||||
assistant, err = qtx.CreateAgent(r.Context(), db.CreateAgentParams{
|
||||
WorkspaceID: wsUUID,
|
||||
Name: onboardingAssistantName,
|
||||
Description: onboardingAssistantDescription,
|
||||
AvatarUrl: pgtype.Text{String: onboardingAssistantAvatarURL, Valid: true},
|
||||
RuntimeMode: runtime.RuntimeMode,
|
||||
RuntimeConfig: []byte("{}"),
|
||||
RuntimeID: runtime.ID,
|
||||
Visibility: "workspace",
|
||||
MaxConcurrentTasks: 6,
|
||||
OwnerID: parseUUID(userID),
|
||||
Instructions: onboardingAssistantInstructions,
|
||||
CustomEnv: []byte("{}"),
|
||||
CustomArgs: []byte("[]"),
|
||||
McpConfig: nil,
|
||||
Model: pgtype.Text{},
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("bootstrap onboarding: create assistant failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", req.WorkspaceID)...)
|
||||
writeError(w, http.StatusInternalServerError, "failed to create onboarding assistant")
|
||||
return
|
||||
}
|
||||
assistantCreated = true
|
||||
}
|
||||
|
||||
var emptyUUID pgtype.UUID
|
||||
issue, foundIssue, err := issueguard.LockAndFindActiveDuplicate(
|
||||
r.Context(),
|
||||
qtx,
|
||||
wsUUID,
|
||||
emptyUUID,
|
||||
emptyUUID,
|
||||
onboardingIssueTitle,
|
||||
false,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Warn("bootstrap onboarding: duplicate issue check failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", req.WorkspaceID)...)
|
||||
writeError(w, http.StatusInternalServerError, "failed to create onboarding issue")
|
||||
return
|
||||
}
|
||||
issueCreated := false
|
||||
if !foundIssue {
|
||||
issueNumber, err := qtx.IncrementIssueCounter(r.Context(), wsUUID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to allocate issue number")
|
||||
return
|
||||
}
|
||||
issue, err = qtx.CreateIssue(r.Context(), db.CreateIssueParams{
|
||||
WorkspaceID: wsUUID,
|
||||
Title: onboardingIssueTitle,
|
||||
Description: strOrNullText(onboardingIssueDescription),
|
||||
Status: "todo",
|
||||
Priority: "high",
|
||||
AssigneeType: pgtype.Text{String: "agent", Valid: true},
|
||||
AssigneeID: assistant.ID,
|
||||
CreatorType: "member",
|
||||
CreatorID: parseUUID(userID),
|
||||
ParentIssueID: emptyUUID,
|
||||
Position: 0,
|
||||
Number: issueNumber,
|
||||
ProjectID: emptyUUID,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("bootstrap onboarding: create issue failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", req.WorkspaceID)...)
|
||||
writeError(w, http.StatusInternalServerError, "failed to create onboarding issue")
|
||||
return
|
||||
}
|
||||
issueCreated = true
|
||||
}
|
||||
|
||||
updatedUser, err := qtx.MarkUserOnboarded(r.Context(), parseUUID(userID))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to mark onboarded")
|
||||
return
|
||||
}
|
||||
starterContentClaimed := false
|
||||
if !updatedUser.StarterContentState.Valid {
|
||||
updatedUser, err = qtx.SetStarterContentState(r.Context(), db.SetStarterContentStateParams{
|
||||
ID: parseUUID(userID),
|
||||
StarterContentState: pgtype.Text{String: "imported", Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to record starter content state")
|
||||
return
|
||||
}
|
||||
starterContentClaimed = true
|
||||
}
|
||||
|
||||
if err := tx.Commit(r.Context()); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to finish onboarding")
|
||||
return
|
||||
}
|
||||
|
||||
if assistantCreated {
|
||||
resp := agentToResponse(assistant)
|
||||
h.publish(protocol.EventAgentCreated, req.WorkspaceID, "member", userID, map[string]any{"agent": resp})
|
||||
h.Analytics.Capture(analytics.AgentCreated(
|
||||
userID,
|
||||
req.WorkspaceID,
|
||||
uuidToString(assistant.ID),
|
||||
runtime.Provider,
|
||||
runtime.RuntimeMode,
|
||||
onboardingAgentTemplate,
|
||||
isFirstAgent,
|
||||
))
|
||||
}
|
||||
if issueCreated {
|
||||
prefix := h.getIssuePrefix(r.Context(), issue.WorkspaceID)
|
||||
resp := issueToResponse(issue, prefix)
|
||||
h.publish(protocol.EventIssueCreated, req.WorkspaceID, "member", userID, map[string]any{"issue": resp})
|
||||
h.Analytics.Capture(analytics.IssueCreated(
|
||||
userID,
|
||||
req.WorkspaceID,
|
||||
uuidToString(issue.ID),
|
||||
uuidToString(assistant.ID),
|
||||
"",
|
||||
"",
|
||||
analytics.SourceOnboarding,
|
||||
))
|
||||
if h.shouldEnqueueAgentTask(r.Context(), issue) {
|
||||
h.TaskService.EnqueueTaskForIssue(r.Context(), issue)
|
||||
}
|
||||
}
|
||||
if firstCompletion {
|
||||
onboardedAt := ""
|
||||
if updatedUser.OnboardedAt.Valid {
|
||||
onboardedAt = updatedUser.OnboardedAt.Time.UTC().Format("2006-01-02T15:04:05Z07:00")
|
||||
}
|
||||
h.Analytics.Capture(analytics.OnboardingCompleted(
|
||||
userID,
|
||||
req.WorkspaceID,
|
||||
analytics.OnboardingPathFull,
|
||||
onboardedAt,
|
||||
updatedUser.CloudWaitlistEmail.Valid,
|
||||
))
|
||||
}
|
||||
if starterContentClaimed {
|
||||
h.Analytics.Capture(analytics.StarterContentDecided(
|
||||
userID,
|
||||
req.WorkspaceID,
|
||||
"imported",
|
||||
analytics.StarterContentBranchAgentGuided,
|
||||
))
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, bootstrapOnboardingRuntimeResponse{
|
||||
WorkspaceID: req.WorkspaceID,
|
||||
AgentID: uuidToString(assistant.ID),
|
||||
IssueID: uuidToString(issue.ID),
|
||||
})
|
||||
}
|
||||
|
||||
// BootstrapOnboardingNoRuntime is the runtime-skipped onboarding exit:
|
||||
// create or reuse one self-serve onboarding issue, mark onboarding complete,
|
||||
// and suppress the older starter-content dialog. This keeps the no-runtime
|
||||
// path focused on the single real blocker instead of seeding a project full
|
||||
// of follow-up tasks.
|
||||
func (h *Handler) BootstrapOnboardingNoRuntime(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, runtimeBootstrapBodyLimit)
|
||||
var req bootstrapOnboardingNoRuntimeRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.WorkspaceID == "" {
|
||||
writeError(w, http.StatusBadRequest, "workspace_id is required")
|
||||
return
|
||||
}
|
||||
wsUUID, ok := parseUUIDOrBadRequest(w, req.WorkspaceID, "workspace_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
req.WorkspaceID = uuidToString(wsUUID)
|
||||
|
||||
tx, err := h.TxStarter.Begin(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to start onboarding")
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(r.Context())
|
||||
qtx := h.Queries.WithTx(tx)
|
||||
|
||||
userBefore, err := qtx.GetUser(r.Context(), parseUUID(userID))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to load user")
|
||||
return
|
||||
}
|
||||
firstCompletion := !userBefore.OnboardedAt.Valid
|
||||
|
||||
if _, err := qtx.GetMemberByUserAndWorkspace(r.Context(), db.GetMemberByUserAndWorkspaceParams{
|
||||
UserID: parseUUID(userID),
|
||||
WorkspaceID: wsUUID,
|
||||
}); err != nil {
|
||||
writeError(w, http.StatusForbidden, "not a member of this workspace")
|
||||
return
|
||||
}
|
||||
|
||||
var emptyUUID pgtype.UUID
|
||||
issue, foundIssue, err := issueguard.LockAndFindActiveDuplicate(
|
||||
r.Context(),
|
||||
qtx,
|
||||
wsUUID,
|
||||
emptyUUID,
|
||||
emptyUUID,
|
||||
noRuntimeIssueTitle,
|
||||
false,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Warn("bootstrap no-runtime onboarding: duplicate issue check failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", req.WorkspaceID)...)
|
||||
writeError(w, http.StatusInternalServerError, "failed to create onboarding issue")
|
||||
return
|
||||
}
|
||||
issueCreated := false
|
||||
if !foundIssue {
|
||||
issueNumber, err := qtx.IncrementIssueCounter(r.Context(), wsUUID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to allocate issue number")
|
||||
return
|
||||
}
|
||||
issue, err = qtx.CreateIssue(r.Context(), db.CreateIssueParams{
|
||||
WorkspaceID: wsUUID,
|
||||
Title: noRuntimeIssueTitle,
|
||||
Description: strOrNullText(noRuntimeIssueDescription(userBefore.Language)),
|
||||
Status: "todo",
|
||||
Priority: "high",
|
||||
AssigneeType: pgtype.Text{String: "member", Valid: true},
|
||||
AssigneeID: parseUUID(userID),
|
||||
CreatorType: "member",
|
||||
CreatorID: parseUUID(userID),
|
||||
ParentIssueID: emptyUUID,
|
||||
Position: 0,
|
||||
Number: issueNumber,
|
||||
ProjectID: emptyUUID,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("bootstrap no-runtime onboarding: create issue failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", req.WorkspaceID)...)
|
||||
writeError(w, http.StatusInternalServerError, "failed to create onboarding issue")
|
||||
return
|
||||
}
|
||||
issueCreated = true
|
||||
}
|
||||
|
||||
updatedUser, err := qtx.MarkUserOnboarded(r.Context(), parseUUID(userID))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to mark onboarded")
|
||||
return
|
||||
}
|
||||
starterContentClaimed := false
|
||||
if !updatedUser.StarterContentState.Valid {
|
||||
updatedUser, err = qtx.SetStarterContentState(r.Context(), db.SetStarterContentStateParams{
|
||||
ID: parseUUID(userID),
|
||||
StarterContentState: pgtype.Text{String: "imported", Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to record starter content state")
|
||||
return
|
||||
}
|
||||
starterContentClaimed = true
|
||||
}
|
||||
|
||||
if err := tx.Commit(r.Context()); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to finish onboarding")
|
||||
return
|
||||
}
|
||||
|
||||
if issueCreated {
|
||||
prefix := h.getIssuePrefix(r.Context(), issue.WorkspaceID)
|
||||
resp := issueToResponse(issue, prefix)
|
||||
h.publish(protocol.EventIssueCreated, req.WorkspaceID, "member", userID, map[string]any{"issue": resp})
|
||||
h.Analytics.Capture(analytics.IssueCreated(
|
||||
userID,
|
||||
req.WorkspaceID,
|
||||
uuidToString(issue.ID),
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
analytics.SourceOnboarding,
|
||||
))
|
||||
}
|
||||
if firstCompletion {
|
||||
onboardedAt := ""
|
||||
if updatedUser.OnboardedAt.Valid {
|
||||
onboardedAt = updatedUser.OnboardedAt.Time.UTC().Format("2006-01-02T15:04:05Z07:00")
|
||||
}
|
||||
h.Analytics.Capture(analytics.OnboardingCompleted(
|
||||
userID,
|
||||
req.WorkspaceID,
|
||||
analytics.OnboardingPathRuntimeSkipped,
|
||||
onboardedAt,
|
||||
updatedUser.CloudWaitlistEmail.Valid,
|
||||
))
|
||||
}
|
||||
if starterContentClaimed {
|
||||
h.Analytics.Capture(analytics.StarterContentDecided(
|
||||
userID,
|
||||
req.WorkspaceID,
|
||||
"imported",
|
||||
analytics.StarterContentBranchSelfServe,
|
||||
))
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, bootstrapOnboardingNoRuntimeResponse{
|
||||
WorkspaceID: req.WorkspaceID,
|
||||
IssueID: uuidToString(issue.ID),
|
||||
})
|
||||
}
|
||||
|
||||
type patchOnboardingRequest struct {
|
||||
Questionnaire *json.RawMessage `json:"questionnaire,omitempty"`
|
||||
}
|
||||
|
||||
@@ -110,9 +110,9 @@ func TestJoinCloudWaitlistMissingEmailReturns400(t *testing.T) {
|
||||
userID := newWaitlistTestUser(t, "waitlist-missing@multica.ai")
|
||||
|
||||
cases := []map[string]string{
|
||||
{}, // empty body
|
||||
{"email": ""}, // blank
|
||||
{"email": " "}, // whitespace only
|
||||
{}, // empty body
|
||||
{"email": ""}, // blank
|
||||
{"email": " "}, // whitespace only
|
||||
{"reason": "no email here"},
|
||||
}
|
||||
|
||||
@@ -189,3 +189,337 @@ func TestJoinCloudWaitlistSecondCallOverwrites(t *testing.T) {
|
||||
t.Fatalf("onboarded_at should stay NULL, got %v", *onboardedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapOnboardingRuntimeCreatesSingleGuideIssue(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(ctx, `
|
||||
DELETE FROM agent_task_queue
|
||||
WHERE agent_id IN (
|
||||
SELECT id FROM agent
|
||||
WHERE workspace_id = $1 AND name = $2
|
||||
)
|
||||
`, testWorkspaceID, onboardingAssistantName)
|
||||
testPool.Exec(ctx,
|
||||
`DELETE FROM issue WHERE workspace_id = $1 AND title = $2`,
|
||||
testWorkspaceID, onboardingIssueTitle,
|
||||
)
|
||||
testPool.Exec(ctx,
|
||||
`DELETE FROM agent WHERE workspace_id = $1 AND name = $2`,
|
||||
testWorkspaceID, onboardingAssistantName,
|
||||
)
|
||||
testPool.Exec(ctx,
|
||||
`UPDATE "user" SET onboarded_at = NULL, starter_content_state = NULL WHERE id = $1`,
|
||||
testUserID,
|
||||
)
|
||||
})
|
||||
testPool.Exec(ctx,
|
||||
`DELETE FROM issue WHERE workspace_id = $1 AND title = $2`,
|
||||
testWorkspaceID, onboardingIssueTitle,
|
||||
)
|
||||
testPool.Exec(ctx,
|
||||
`DELETE FROM agent WHERE workspace_id = $1 AND name = $2`,
|
||||
testWorkspaceID, onboardingAssistantName,
|
||||
)
|
||||
testPool.Exec(ctx,
|
||||
`UPDATE "user" SET onboarded_at = NULL, starter_content_state = NULL WHERE id = $1`,
|
||||
testUserID,
|
||||
)
|
||||
|
||||
body := map[string]string{
|
||||
"workspace_id": testWorkspaceID,
|
||||
"runtime_id": testRuntimeID,
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
testHandler.BootstrapOnboardingRuntime(w, newRequest(http.MethodPost, "/api/me/onboarding/runtime-bootstrap", body))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("BootstrapOnboardingRuntime: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp bootstrapOnboardingRuntimeResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if resp.WorkspaceID != testWorkspaceID || resp.AgentID == "" || resp.IssueID == "" {
|
||||
t.Fatalf("unexpected response: %+v", resp)
|
||||
}
|
||||
|
||||
var (
|
||||
agentName string
|
||||
agentRuntime string
|
||||
instructions string
|
||||
avatarURL *string
|
||||
)
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
SELECT name, runtime_id, instructions, avatar_url
|
||||
FROM agent
|
||||
WHERE id = $1
|
||||
`, resp.AgentID).Scan(&agentName, &agentRuntime, &instructions, &avatarURL); err != nil {
|
||||
t.Fatalf("lookup assistant: %v", err)
|
||||
}
|
||||
if agentName != onboardingAssistantName {
|
||||
t.Fatalf("agent name = %q, want %q", agentName, onboardingAssistantName)
|
||||
}
|
||||
if agentRuntime != testRuntimeID {
|
||||
t.Fatalf("agent runtime = %q, want %q", agentRuntime, testRuntimeID)
|
||||
}
|
||||
if !strings.Contains(instructions, "onboard them inside the first issue") {
|
||||
t.Fatalf("assistant instructions were not seeded: %q", instructions)
|
||||
}
|
||||
if avatarURL == nil || *avatarURL != onboardingAssistantAvatarURL {
|
||||
t.Fatalf("agent avatar_url = %v, want seeded Multica Helper avatar", avatarURL)
|
||||
}
|
||||
|
||||
var (
|
||||
issueTitle string
|
||||
assigneeType string
|
||||
assigneeID string
|
||||
issueStatus string
|
||||
issuePriority string
|
||||
)
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
SELECT title, assignee_type, assignee_id, status, priority
|
||||
FROM issue
|
||||
WHERE id = $1
|
||||
`, resp.IssueID).Scan(&issueTitle, &assigneeType, &assigneeID, &issueStatus, &issuePriority); err != nil {
|
||||
t.Fatalf("lookup onboarding issue: %v", err)
|
||||
}
|
||||
if issueTitle != onboardingIssueTitle {
|
||||
t.Fatalf("issue title = %q, want %q", issueTitle, onboardingIssueTitle)
|
||||
}
|
||||
if assigneeType != "agent" || assigneeID != resp.AgentID {
|
||||
t.Fatalf("issue assignee = %s/%s, want agent/%s", assigneeType, assigneeID, resp.AgentID)
|
||||
}
|
||||
if issueStatus != "todo" || issuePriority != "high" {
|
||||
t.Fatalf("issue status/priority = %s/%s, want todo/high", issueStatus, issuePriority)
|
||||
}
|
||||
|
||||
var (
|
||||
onboardedAt *time.Time
|
||||
starterContentState *string
|
||||
)
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
SELECT onboarded_at, starter_content_state
|
||||
FROM "user"
|
||||
WHERE id = $1
|
||||
`, testUserID).Scan(&onboardedAt, &starterContentState); err != nil {
|
||||
t.Fatalf("lookup user onboarding state: %v", err)
|
||||
}
|
||||
if onboardedAt == nil {
|
||||
t.Fatal("expected onboarded_at to be set")
|
||||
}
|
||||
if starterContentState == nil || *starterContentState != "imported" {
|
||||
t.Fatalf("starter_content_state = %v, want imported", starterContentState)
|
||||
}
|
||||
|
||||
var taskCount int
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
SELECT count(*)
|
||||
FROM agent_task_queue
|
||||
WHERE issue_id = $1 AND agent_id = $2
|
||||
`, resp.IssueID, resp.AgentID).Scan(&taskCount); err != nil {
|
||||
t.Fatalf("count queued tasks: %v", err)
|
||||
}
|
||||
if taskCount == 0 {
|
||||
t.Fatal("expected onboarding issue to enqueue an agent task")
|
||||
}
|
||||
|
||||
w2 := httptest.NewRecorder()
|
||||
testHandler.BootstrapOnboardingRuntime(w2, newRequest(http.MethodPost, "/api/me/onboarding/runtime-bootstrap", body))
|
||||
if w2.Code != http.StatusOK {
|
||||
t.Fatalf("second BootstrapOnboardingRuntime: expected 200, got %d: %s", w2.Code, w2.Body.String())
|
||||
}
|
||||
var resp2 bootstrapOnboardingRuntimeResponse
|
||||
if err := json.NewDecoder(w2.Body).Decode(&resp2); err != nil {
|
||||
t.Fatalf("decode second response: %v", err)
|
||||
}
|
||||
if resp2.AgentID != resp.AgentID || resp2.IssueID != resp.IssueID {
|
||||
t.Fatalf("bootstrap should be idempotent: first=%+v second=%+v", resp, resp2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapOnboardingNoRuntimeCreatesSingleGuideIssue(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(ctx,
|
||||
`DELETE FROM issue WHERE workspace_id = $1 AND title = $2`,
|
||||
testWorkspaceID, noRuntimeIssueTitle,
|
||||
)
|
||||
testPool.Exec(ctx,
|
||||
`UPDATE "user" SET onboarded_at = NULL, starter_content_state = NULL, language = NULL WHERE id = $1`,
|
||||
testUserID,
|
||||
)
|
||||
})
|
||||
testPool.Exec(ctx,
|
||||
`DELETE FROM issue WHERE workspace_id = $1 AND title = $2`,
|
||||
testWorkspaceID, noRuntimeIssueTitle,
|
||||
)
|
||||
testPool.Exec(ctx,
|
||||
`UPDATE "user" SET onboarded_at = NULL, starter_content_state = NULL, language = 'en' WHERE id = $1`,
|
||||
testUserID,
|
||||
)
|
||||
|
||||
body := map[string]string{
|
||||
"workspace_id": testWorkspaceID,
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
testHandler.BootstrapOnboardingNoRuntime(w, newRequest(http.MethodPost, "/api/me/onboarding/no-runtime-bootstrap", body))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("BootstrapOnboardingNoRuntime: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp bootstrapOnboardingNoRuntimeResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if resp.WorkspaceID != testWorkspaceID || resp.IssueID == "" {
|
||||
t.Fatalf("unexpected response: %+v", resp)
|
||||
}
|
||||
|
||||
var (
|
||||
issueTitle string
|
||||
assigneeType string
|
||||
assigneeID string
|
||||
issueStatus string
|
||||
issuePriority string
|
||||
description string
|
||||
)
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
SELECT title, assignee_type, assignee_id, status, priority, description
|
||||
FROM issue
|
||||
WHERE id = $1
|
||||
`, resp.IssueID).Scan(&issueTitle, &assigneeType, &assigneeID, &issueStatus, &issuePriority, &description); err != nil {
|
||||
t.Fatalf("lookup no-runtime onboarding issue: %v", err)
|
||||
}
|
||||
if issueTitle != noRuntimeIssueTitle {
|
||||
t.Fatalf("issue title = %q, want %q", issueTitle, noRuntimeIssueTitle)
|
||||
}
|
||||
if assigneeType != "member" || assigneeID != testUserID {
|
||||
t.Fatalf("issue assignee = %s/%s, want member/%s", assigneeType, assigneeID, testUserID)
|
||||
}
|
||||
if issueStatus != "todo" || issuePriority != "high" {
|
||||
t.Fatalf("issue status/priority = %s/%s, want todo/high", issueStatus, issuePriority)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"Try Multica first",
|
||||
"https://multica.ai/docs/install-agent-runtime",
|
||||
"npm i -g @openai/codex",
|
||||
} {
|
||||
if !strings.Contains(description, want) {
|
||||
t.Fatalf("issue description missing %q: %q", want, description)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(description, "Agents need a runtime before they can execute work") {
|
||||
t.Fatalf("issue description was not seeded: %q", description)
|
||||
}
|
||||
|
||||
var (
|
||||
onboardedAt *time.Time
|
||||
starterContentState *string
|
||||
)
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
SELECT onboarded_at, starter_content_state
|
||||
FROM "user"
|
||||
WHERE id = $1
|
||||
`, testUserID).Scan(&onboardedAt, &starterContentState); err != nil {
|
||||
t.Fatalf("lookup user onboarding state: %v", err)
|
||||
}
|
||||
if onboardedAt == nil {
|
||||
t.Fatal("expected onboarded_at to be set")
|
||||
}
|
||||
if starterContentState == nil || *starterContentState != "imported" {
|
||||
t.Fatalf("starter_content_state = %v, want imported", starterContentState)
|
||||
}
|
||||
|
||||
var taskCount int
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
SELECT count(*)
|
||||
FROM agent_task_queue
|
||||
WHERE issue_id = $1
|
||||
`, resp.IssueID).Scan(&taskCount); err != nil {
|
||||
t.Fatalf("count queued tasks: %v", err)
|
||||
}
|
||||
if taskCount != 0 {
|
||||
t.Fatalf("expected no agent tasks for no-runtime issue, got %d", taskCount)
|
||||
}
|
||||
|
||||
w2 := httptest.NewRecorder()
|
||||
testHandler.BootstrapOnboardingNoRuntime(w2, newRequest(http.MethodPost, "/api/me/onboarding/no-runtime-bootstrap", body))
|
||||
if w2.Code != http.StatusOK {
|
||||
t.Fatalf("second BootstrapOnboardingNoRuntime: expected 200, got %d: %s", w2.Code, w2.Body.String())
|
||||
}
|
||||
var resp2 bootstrapOnboardingNoRuntimeResponse
|
||||
if err := json.NewDecoder(w2.Body).Decode(&resp2); err != nil {
|
||||
t.Fatalf("decode second response: %v", err)
|
||||
}
|
||||
if resp2.IssueID != resp.IssueID {
|
||||
t.Fatalf("bootstrap should be idempotent: first=%+v second=%+v", resp, resp2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapOnboardingNoRuntimeUsesChineseGuideForChineseUsers(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(ctx,
|
||||
`DELETE FROM issue WHERE workspace_id = $1 AND title = $2`,
|
||||
testWorkspaceID, noRuntimeIssueTitle,
|
||||
)
|
||||
testPool.Exec(ctx,
|
||||
`UPDATE "user" SET onboarded_at = NULL, starter_content_state = NULL, language = NULL WHERE id = $1`,
|
||||
testUserID,
|
||||
)
|
||||
})
|
||||
testPool.Exec(ctx,
|
||||
`DELETE FROM issue WHERE workspace_id = $1 AND title = $2`,
|
||||
testWorkspaceID, noRuntimeIssueTitle,
|
||||
)
|
||||
testPool.Exec(ctx,
|
||||
`UPDATE "user" SET onboarded_at = NULL, starter_content_state = NULL, language = 'zh-Hans' WHERE id = $1`,
|
||||
testUserID,
|
||||
)
|
||||
|
||||
body := map[string]string{
|
||||
"workspace_id": testWorkspaceID,
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
testHandler.BootstrapOnboardingNoRuntime(w, newRequest(http.MethodPost, "/api/me/onboarding/no-runtime-bootstrap", body))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("BootstrapOnboardingNoRuntime: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp bootstrapOnboardingNoRuntimeResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
|
||||
var description string
|
||||
if err := testPool.QueryRow(ctx, `
|
||||
SELECT description
|
||||
FROM issue
|
||||
WHERE id = $1
|
||||
`, resp.IssueID).Scan(&description); err != nil {
|
||||
t.Fatalf("lookup no-runtime onboarding issue: %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"先体验项目管理功能",
|
||||
"https://multica.ai/docs/install-agent-runtime",
|
||||
"中文用户建议先装 Kimi CLI",
|
||||
"kimi --version",
|
||||
} {
|
||||
if !strings.Contains(description, want) {
|
||||
t.Fatalf("Chinese issue description missing %q: %q", want, description)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user