Provision default workspaces and harden daemon pairing

This commit is contained in:
Jiayuan Zhang 2026-03-24 15:19:27 +08:00
parent 2e5e24f194
commit 4c6eb81789
11 changed files with 492 additions and 5 deletions

View file

@ -0,0 +1,3 @@
export function GET(request: Request) {
return Response.redirect(new URL("/favicon.svg", request.url), 308);
}

View file

@ -8,6 +8,10 @@ import "./globals.css";
export const metadata: Metadata = {
title: "Multica",
description: "AI-native task management",
icons: {
icon: [{ url: "/favicon.svg", type: "image/svg+xml" }],
shortcut: ["/favicon.svg"],
},
};
export default function RootLayout({

View file

@ -0,0 +1,101 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
const {
mockGetDaemonPairingSession,
mockApproveDaemonPairingSession,
mockWorkspace,
mockAuthValue,
} = vi.hoisted(() => ({
mockGetDaemonPairingSession: vi.fn(),
mockApproveDaemonPairingSession: vi.fn(),
mockWorkspace: {
id: "05ce77f1-7c45-4735-b1f7-619347f7f76c",
name: "Jiayuan's Workspace",
slug: "jiayuan-05ce77f1",
description: null,
settings: {},
created_at: "2026-03-24T00:00:00Z",
updated_at: "2026-03-24T00:00:00Z",
},
mockAuthValue: {
user: {
id: "user-1",
name: "Jiayuan",
email: "jiayuan@example.com",
avatar_url: null,
created_at: "2026-03-24T00:00:00Z",
updated_at: "2026-03-24T00:00:00Z",
},
workspaces: [] as Array<{
id: string;
name: string;
slug: string;
description: null;
settings: Record<string, never>;
created_at: string;
updated_at: string;
}>,
workspace: null as null | {
id: string;
name: string;
slug: string;
description: null;
settings: Record<string, never>;
created_at: string;
updated_at: string;
},
isLoading: false,
},
}));
mockAuthValue.workspaces = [mockWorkspace];
mockAuthValue.workspace = mockWorkspace;
vi.mock("next/navigation", () => ({
useSearchParams: () => new URLSearchParams("token=test-token"),
}));
vi.mock("../../../lib/api", () => ({
api: {
getDaemonPairingSession: mockGetDaemonPairingSession,
approveDaemonPairingSession: mockApproveDaemonPairingSession,
},
}));
vi.mock("../../../lib/auth-context", () => ({
useAuth: () => mockAuthValue,
}));
import LocalDaemonPairPage from "./page";
describe("LocalDaemonPairPage", () => {
beforeEach(() => {
vi.clearAllMocks();
mockGetDaemonPairingSession.mockResolvedValue({
token: "test-token",
daemon_id: "local-daemon",
device_name: "Jiayuans-MacBook-Pro.local",
runtime_name: "Local Codex",
runtime_type: "codex",
runtime_version: "codex-cli 0.116.0",
workspace_id: mockWorkspace.id,
status: "pending",
approved_at: null,
claimed_at: null,
expires_at: "2026-03-24T07:20:00Z",
link_url: null,
});
});
it("shows the selected workspace name instead of the raw id", async () => {
render(<LocalDaemonPairPage />);
await waitFor(() => {
expect(mockGetDaemonPairingSession).toHaveBeenCalledWith("test-token");
});
expect(await screen.findByText("Jiayuan's Workspace")).toBeInTheDocument();
expect(screen.queryByText(mockWorkspace.id)).not.toBeInTheDocument();
});
});

View file

@ -9,7 +9,6 @@ import { Label } from "@multica/ui/components/ui/label";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@multica/ui/components/ui/select";
@ -39,6 +38,10 @@ function LocalDaemonPairPageContent() {
const next = `/pair/local?token=${encodeURIComponent(token)}`;
return `/login?next=${encodeURIComponent(next)}`;
}, [token]);
const selectedWorkspace = useMemo(
() => workspaces.find((item) => item.id === selectedWorkspaceId) ?? null,
[selectedWorkspaceId, workspaces],
);
useEffect(() => {
if (!token) {
@ -135,7 +138,9 @@ function LocalDaemonPairPageContent() {
<Label className="mb-2">Workspace</Label>
<Select value={selectedWorkspaceId} onValueChange={(v) => setSelectedWorkspaceId(v ?? "")}>
<SelectTrigger className="w-full">
<SelectValue />
<span className="flex flex-1 text-left">
{selectedWorkspace?.name ?? "Select workspace"}
</span>
</SelectTrigger>
<SelectContent>
{workspaces.map((item) => (

View file

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" role="img" aria-label="Multica">
<rect width="100" height="100" rx="20" fill="#ffffff"/>
<polygon
fill="#111827"
points="45,62.1 45,100 55,100 55,62.1 81.8,88.9 88.9,81.8 62.1,55 100,55 100,45 62.1,45 88.9,18.2 81.8,11.1 55,37.9 55,0 45,0 45,37.9 18.2,11.1 11.1,18.2 37.9,45 0,45 0,55 37.9,55 11.1,81.8 18.2,88.9"
/>
</svg>

After

Width:  |  Height:  |  Size: 400 B

View file

@ -31,6 +31,7 @@ type config struct {
ServerBaseURL string
ConfigPath string
WorkspaceID string
WorkspaceFromDisk bool
DaemonID string
DeviceName string
RuntimeName string
@ -53,6 +54,17 @@ type daemonClient struct {
client *http.Client
}
type requestError struct {
Method string
Path string
StatusCode int
Body string
}
func (e *requestError) Error() string {
return fmt.Sprintf("%s %s returned %d: %s", e.Method, e.Path, e.StatusCode, e.Body)
}
type daemonRuntime struct {
ID string `json:"id"`
Name string `json:"name"`
@ -140,8 +152,10 @@ func loadConfig() (config, error) {
return config{}, err
}
workspaceID := strings.TrimSpace(os.Getenv("MULTICA_WORKSPACE_ID"))
workspaceFromDisk := false
if workspaceID == "" {
workspaceID = persisted.WorkspaceID
workspaceFromDisk = workspaceID != ""
}
codexPath := envOrDefault("MULTICA_CODEX_PATH", defaultCodexPath)
@ -183,6 +197,7 @@ func loadConfig() (config, error) {
ServerBaseURL: serverBaseURL,
ConfigPath: configPath,
WorkspaceID: workspaceID,
WorkspaceFromDisk: workspaceFromDisk,
DaemonID: envOrDefault("MULTICA_DAEMON_ID", host),
DeviceName: envOrDefault("MULTICA_DAEMON_DEVICE_NAME", host),
RuntimeName: envOrDefault("MULTICA_CODEX_RUNTIME_NAME", defaultRuntimeName),
@ -216,7 +231,7 @@ func (d *daemon) run(ctx context.Context) error {
d.logger.Printf("pairing completed for workspace=%s", workspaceID)
}
runtime, err := d.registerRuntime(ctx)
runtime, err := d.registerRuntimeWithRecovery(ctx)
if err != nil {
return err
}
@ -258,6 +273,37 @@ func (d *daemon) registerRuntime(ctx context.Context) (daemonRuntime, error) {
return resp.Runtimes[0], nil
}
func (d *daemon) registerRuntimeWithRecovery(ctx context.Context) (daemonRuntime, error) {
runtime, err := d.registerRuntime(ctx)
if err == nil {
return runtime, nil
}
if !d.cfg.WorkspaceFromDisk || !isWorkspaceNotFoundError(err) {
return daemonRuntime{}, err
}
d.logger.Printf(
"persisted workspace=%s is no longer valid; clearing %s and starting a new pairing flow",
d.cfg.WorkspaceID,
d.cfg.ConfigPath,
)
if err := clearPersistedDaemonConfig(d.cfg.ConfigPath); err != nil {
return daemonRuntime{}, err
}
d.cfg.WorkspaceID = ""
d.cfg.WorkspaceFromDisk = false
workspaceID, err := d.ensurePaired(ctx)
if err != nil {
return daemonRuntime{}, fmt.Errorf("repair stale workspace binding: %w", err)
}
d.cfg.WorkspaceID = workspaceID
d.logger.Printf("pairing completed for workspace=%s", workspaceID)
return d.registerRuntime(ctx)
}
func (d *daemon) ensurePaired(ctx context.Context) (string, error) {
version, err := detectCodexVersion(ctx, d.cfg.CodexPath)
if err != nil {
@ -641,6 +687,24 @@ func savePersistedDaemonConfig(path string, cfg daemonPersistedConfig) error {
return nil
}
func clearPersistedDaemonConfig(path string) error {
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("remove daemon config: %w", err)
}
return nil
}
func isWorkspaceNotFoundError(err error) bool {
var reqErr *requestError
if !errors.As(err, &reqErr) {
return false
}
if reqErr.StatusCode != http.StatusNotFound {
return false
}
return strings.Contains(strings.ToLower(reqErr.Body), "workspace not found")
}
func normalizeServerBaseURL(raw string) (string, error) {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
@ -778,7 +842,12 @@ func (c *daemonClient) postJSON(ctx context.Context, path string, reqBody any, r
if resp.StatusCode >= 400 {
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return fmt.Errorf("%s %s returned %d: %s", http.MethodPost, path, resp.StatusCode, strings.TrimSpace(string(data)))
return &requestError{
Method: http.MethodPost,
Path: path,
StatusCode: resp.StatusCode,
Body: strings.TrimSpace(string(data)),
}
}
if respBody == nil {
io.Copy(io.Discard, resp.Body)
@ -801,7 +870,12 @@ func (c *daemonClient) getJSON(ctx context.Context, path string, respBody any) e
if resp.StatusCode >= 400 {
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return fmt.Errorf("%s %s returned %d: %s", http.MethodGet, path, resp.StatusCode, strings.TrimSpace(string(data)))
return &requestError{
Method: http.MethodGet,
Path: path,
StatusCode: resp.StatusCode,
Body: strings.TrimSpace(string(data)),
}
}
if respBody == nil {
io.Copy(io.Discard, resp.Body)

View file

@ -1,6 +1,7 @@
package main
import (
"net/http"
"os"
"path/filepath"
"strings"
@ -61,3 +62,21 @@ func TestBuildCodexPromptIncludesIssueAndSkills(t *testing.T) {
}
}
}
func TestIsWorkspaceNotFoundError(t *testing.T) {
t.Parallel()
err := &requestError{
Method: http.MethodPost,
Path: "/api/daemon/register",
StatusCode: http.StatusNotFound,
Body: `{"error":"workspace not found"}`,
}
if !isWorkspaceNotFoundError(err) {
t.Fatal("expected workspace not found error to be recognized")
}
if isWorkspaceNotFoundError(&requestError{StatusCode: http.StatusInternalServerError, Body: `{"error":"workspace not found"}`}) {
t.Fatal("did not expect 500 to be treated as workspace not found")
}
}

View file

@ -22,6 +22,7 @@ import (
var (
testServer *httptest.Server
testPool *pgxpool.Pool
testToken string
testUserID string
testWorkspaceID string
@ -48,6 +49,7 @@ func TestMain(m *testing.M) {
os.Exit(0)
}
testPool = pool
testUserID, testWorkspaceID, err = setupIntegrationTestFixture(ctx, pool)
if err != nil {
fmt.Printf("Failed to set up integration test fixture: %v\n", err)
@ -268,6 +270,86 @@ func TestLoginAndGetMe(t *testing.T) {
}
}
func TestLoginCreatesWorkspaceForNewUser(t *testing.T) {
const email = "new-integration-login@multica.ai"
ctx := context.Background()
t.Cleanup(func() {
var userID string
err := testPool.QueryRow(ctx, `SELECT id FROM "user" WHERE email = $1`, email).Scan(&userID)
if err == nil {
rows, queryErr := testPool.Query(ctx, `
SELECT w.id
FROM workspace w
JOIN member m ON m.workspace_id = w.id
WHERE m.user_id = $1
`, userID)
if queryErr == nil {
defer rows.Close()
for rows.Next() {
var workspaceID string
if scanErr := rows.Scan(&workspaceID); scanErr == nil {
_, _ = testPool.Exec(ctx, `DELETE FROM workspace WHERE id = $1`, workspaceID)
}
}
}
}
_, _ = testPool.Exec(ctx, `DELETE FROM "user" WHERE email = $1`, email)
})
_, _ = testPool.Exec(ctx, `DELETE FROM "user" WHERE email = $1`, email)
body, _ := json.Marshal(map[string]string{
"email": email,
"name": "Jiayuan",
})
resp, err := http.Post(testServer.URL+"/auth/login", "application/json", bytes.NewReader(body))
if err != nil {
t.Fatalf("login failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
var loginResp struct {
Token string `json:"token"`
}
readJSON(t, resp, &loginResp)
if loginResp.Token == "" {
t.Fatal("expected non-empty token")
}
req, _ := http.NewRequest("GET", testServer.URL+"/api/workspaces", nil)
req.Header.Set("Authorization", "Bearer "+loginResp.Token)
workspacesResp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("listWorkspaces failed: %v", err)
}
defer workspacesResp.Body.Close()
if workspacesResp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", workspacesResp.StatusCode)
}
var workspaces []struct {
Name string `json:"name"`
Slug string `json:"slug"`
}
readJSON(t, workspacesResp, &workspaces)
if len(workspaces) != 1 {
t.Fatalf("expected 1 workspace, got %d", len(workspaces))
}
if workspaces[0].Name != "Jiayuan's Workspace" {
t.Fatalf("expected default workspace name %q, got %q", "Jiayuan's Workspace", workspaces[0].Name)
}
if workspaces[0].Slug == "" {
t.Fatal("expected non-empty workspace slug")
}
}
func TestProtectedRoutesRequireAuth(t *testing.T) {
paths := []string{"/api/me", "/api/issues", "/api/agents", "/api/inbox", "/api/workspaces"}

View file

@ -1,6 +1,7 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"strings"
@ -42,6 +43,111 @@ type LoginResponse struct {
User UserResponse `json:"user"`
}
func defaultWorkspaceName(user db.User) string {
name := strings.TrimSpace(user.Name)
if name == "" {
email := strings.TrimSpace(user.Email)
if at := strings.Index(email, "@"); at > 0 {
name = email[:at]
}
}
if name == "" {
name = "Personal"
}
return name + "'s Workspace"
}
func slugifyWorkspacePart(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
var b strings.Builder
lastWasDash := false
for _, r := range value {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
b.WriteRune(r)
lastWasDash = false
case b.Len() > 0 && !lastWasDash:
b.WriteByte('-')
lastWasDash = true
}
}
return strings.Trim(b.String(), "-")
}
func defaultWorkspaceSlug(user db.User) string {
candidates := []string{
slugifyWorkspacePart(user.Name),
slugifyWorkspacePart(strings.Split(strings.TrimSpace(user.Email), "@")[0]),
"workspace",
}
base := "workspace"
for _, candidate := range candidates {
if candidate != "" {
base = candidate
break
}
}
userID := uuidToString(user.ID)
if len(userID) >= 8 {
return base + "-" + userID[:8]
}
return base
}
func (h *Handler) ensureUserWorkspace(ctx context.Context, user db.User) error {
workspaces, err := h.Queries.ListWorkspaces(ctx, user.ID)
if err != nil {
return err
}
if len(workspaces) > 0 {
return nil
}
tx, err := h.TxStarter.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
qtx := h.Queries.WithTx(tx)
workspaces, err = qtx.ListWorkspaces(ctx, user.ID)
if err != nil {
return err
}
if len(workspaces) > 0 {
return nil
}
workspace, err := qtx.CreateWorkspace(ctx, db.CreateWorkspaceParams{
Name: defaultWorkspaceName(user),
Slug: defaultWorkspaceSlug(user),
Description: pgtype.Text{},
})
if err != nil {
if isUniqueViolation(err) {
workspaces, lookupErr := h.Queries.ListWorkspaces(ctx, user.ID)
if lookupErr == nil && len(workspaces) > 0 {
return nil
}
}
return err
}
if _, err := qtx.CreateMember(ctx, db.CreateMemberParams{
WorkspaceID: workspace.ID,
UserID: user.ID,
Role: "owner",
}); err != nil {
return err
}
return tx.Commit(ctx)
}
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
var req LoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@ -89,6 +195,11 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
}
}
if err := h.ensureUserWorkspace(r.Context(), user); err != nil {
writeError(w, http.StatusInternalServerError, "failed to provision workspace")
return
}
// Generate JWT
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub": uuidToString(user.ID),

View file

@ -33,6 +33,10 @@ func (h *Handler) DaemonRegister(w http.ResponseWriter, r *http.Request) {
return
}
req.WorkspaceID = strings.TrimSpace(req.WorkspaceID)
req.DaemonID = strings.TrimSpace(req.DaemonID)
req.DeviceName = strings.TrimSpace(req.DeviceName)
if req.DaemonID == "" {
writeError(w, http.StatusBadRequest, "daemon_id is required")
return
@ -45,6 +49,10 @@ func (h *Handler) DaemonRegister(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "at least one runtime is required")
return
}
if _, err := h.Queries.GetWorkspace(r.Context(), parseUUID(req.WorkspaceID)); err != nil {
writeError(w, http.StatusNotFound, "workspace not found")
return
}
resp := make([]AgentRuntimeResponse, 0, len(req.Runtimes))
for _, runtime := range req.Runtimes {

View file

@ -8,6 +8,7 @@ import (
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"github.com/go-chi/chi/v5"
@ -17,6 +18,7 @@ import (
)
var testHandler *Handler
var testPool *pgxpool.Pool
var testUserID string
var testWorkspaceID string
@ -43,6 +45,7 @@ func TestMain(m *testing.M) {
hub := realtime.NewHub()
go hub.Run()
testHandler = New(queries, pool, hub)
testPool = pool
testUserID, testWorkspaceID, err = setupHandlerTestFixture(ctx, pool)
if err != nil {
@ -371,3 +374,73 @@ func TestAuthLogin(t *testing.T) {
t.Fatalf("Login: expected email 'test-handler@multica.ai', got '%s'", resp.User.Email)
}
}
func TestAuthLoginCreatesWorkspaceForNewUser(t *testing.T) {
const email = "new-handler-login@multica.ai"
ctx := context.Background()
t.Cleanup(func() {
user, err := testHandler.Queries.GetUserByEmail(ctx, email)
if err == nil {
workspaces, listErr := testHandler.Queries.ListWorkspaces(ctx, user.ID)
if listErr == nil {
for _, workspace := range workspaces {
_ = testHandler.Queries.DeleteWorkspace(ctx, workspace.ID)
}
}
}
_, _ = testPool.Exec(ctx, `DELETE FROM "user" WHERE email = $1`, email)
})
_, _ = testPool.Exec(ctx, `DELETE FROM "user" WHERE email = $1`, email)
w := httptest.NewRecorder()
body := map[string]string{"email": email, "name": "Workspace Owner"}
var buf bytes.Buffer
json.NewEncoder(&buf).Encode(body)
req := httptest.NewRequest("POST", "/auth/login", &buf)
req.Header.Set("Content-Type", "application/json")
testHandler.Login(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Login: expected 200, got %d: %s", w.Code, w.Body.String())
}
user, err := testHandler.Queries.GetUserByEmail(ctx, email)
if err != nil {
t.Fatalf("GetUserByEmail: %v", err)
}
workspaces, err := testHandler.Queries.ListWorkspaces(ctx, user.ID)
if err != nil {
t.Fatalf("ListWorkspaces: %v", err)
}
if len(workspaces) != 1 {
t.Fatalf("ListWorkspaces: expected 1 workspace, got %d", len(workspaces))
}
if !strings.Contains(workspaces[0].Name, "Workspace") {
t.Fatalf("expected auto-created workspace name, got %q", workspaces[0].Name)
}
if workspaces[0].Slug == "" {
t.Fatal("expected auto-created workspace slug")
}
}
func TestDaemonRegisterMissingWorkspaceReturns404(t *testing.T) {
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/api/daemon/register", bytes.NewBufferString(`{
"workspace_id":"00000000-0000-0000-0000-000000000001",
"daemon_id":"local-daemon",
"device_name":"test-machine",
"runtimes":[{"name":"Local Codex","type":"codex","version":"1.0.0","status":"online"}]
}`))
req.Header.Set("Content-Type", "application/json")
testHandler.DaemonRegister(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("DaemonRegister: expected 404, got %d: %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "workspace not found") {
t.Fatalf("DaemonRegister: expected workspace not found error, got %s", w.Body.String())
}
}