mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-02 01:45:52 +02:00
* feat(onboarding): Multica Helper as general workspace assistant + blocking modal
Reshape Multica Helper from an onboarding-only guide into the workspace's
general-purpose AI assistant. The agent's permanent identity (injected as
`## Agent Identity` into every task's CLAUDE.md / AGENTS.md / GEMINI.md
via execenv.InjectRuntimeConfig) is rewritten to three sections that don't
overlap with what the brief already provides:
- Who I am (built-in workspace assistant, not onboarding-only)
- What Multica is + docs/source/issues URLs as knowledge sources
- What I can do (CLI = manifest, `multica --help` is the source of truth)
- Tone (concise, like a colleague, match user's language)
Bootstrap moves out of the in-flow Step 4. Runtime step now exits the
onboarding shell with no bootstrap call; a blocking OnboardingHelperModal
mounts inside the workspace layout (web + desktop) and gates purely on
`me.onboarded_at == null`. The user picks one of three starter prompts
(intro / assign / second_agent) and the modal calls
BootstrapOnboardingRuntime with a new optional `starter_prompt` field that
becomes the seeded onboarding issue's description.
Side effects required to make `onboarded_at == null` an honest signal:
- CreateWorkspace no longer marks onboarded (was atomic with CreateMember).
The "member exists ⟹ onboarded_at != null" invariant is intentionally
broken; guards (useDashboardGuard / desktop App.tsx) already tolerate
this — comments updated to reflect the new contract.
- AcceptInvitation still marks (invitee skips the modal in someone
else's workspace). Code comment added warning future removers.
- resolvePostAuthDestination flips to workspace-presence-first: a user
with a workspace lands in it regardless of `onboarded_at`, so the
modal can pick up an interrupted setup on relogin.
Other backend changes:
- `onboardingAssistantDescription` rewritten ("Built-in workspace assistant…")
- `onboardingAssistantInstructions` rewritten to the 3-section identity
- `bootstrapOnboardingRuntimeRequest.StarterPrompt` (optional, 2 KiB rune
cap, empty-falls-back-to onboardingIssueDescription)
Frontend changes:
- Delete `packages/views/onboarding/steps/step-teammate.tsx` (no longer a
persisted step)
- `ONBOARDING_STEP_ORDER` and `OnboardingStep` type drop `"teammate"`
- `handleRuntimeNext` exits via `onComplete(workspace, undefined)` — no
bootstrap, `onboarded_at` stays NULL so the modal fires
- Runtime step next-button copy → "Start exploring" / "开始探索"
- New `packages/views/workspace/onboarding-helper-modal.tsx`:
Base UI Dialog, dismissible=false, three localized cards, mutation
invalidates agents + issues queries then navigates to the seeded issue
- Mounted in both `apps/web/app/[workspaceSlug]/layout.tsx` and
`apps/desktop/src/renderer/src/components/workspace-route-layout.tsx`
Tests:
- Backend: TestBootstrapOnboardingRuntime_{With,No}StarterPrompt and
TestCreateWorkspace_DoesNotMarkOnboarded
- Frontend: onboarding-helper-modal.test.tsx covers all four gating
conditions, three-card behavior, mutation pending state, and the
"no close button" invariant
Compatibility:
- Already-onboarded users: zero impact (modal can't fire)
- Invitees: AcceptInvitation still marks → modal can't fire
- Skip-runtime path: BootstrapOnboardingNoRuntime still marks → modal can't fire
- Old desktop / web clients: legacy teammate-step path keeps working
(bootstrap accepts missing starter_prompt) — the new modal only fires
on the new frontend bundle
- Avatar SVG kept (asterisk variant) — no migration of existing Helper
agents, only newly-created Helpers pick up the new instructions/description
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(desktop): suppress OnboardingHelperModal while a WindowOverlay is open
On desktop, App.tsx auto-creates a tab pointing at the user's first
workspace as soon as workspaces.length flips from 0 → 1 (during onboarding
Step 2). The new tab mounts WorkspaceRouteLayout under the overlay,
which mounts OnboardingHelperModal. The modal's Portal renders to
document.body — appearing AFTER the WindowOverlay in DOM order, so its
z-50 wins and the modal floats in front of the still-active onboarding
Step 3 (runtime).
Suppress the modal whenever any WindowOverlay is active. When the overlay
closes (onComplete fires after the user finishes onboarding), the modal
re-evaluates `me.onboarded_at == null` and pops on its own.
Web is unaffected (onboarding flow lives at /onboarding, not under
/[workspaceSlug]/, so WorkspaceRouteLayout never mounts during the
onboarding flow).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(onboarding): add v2 refactor plan
Captures the design + 8-step implementation order for collapsing the
onboarding state machine: single mark-onboarded entry point, persisted
Step 3 user choice, dumb Modal, single install-runtime seed call site.
Includes old-user compatibility analysis (4 existing gates) and per-PR
risk/rollback.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(db): persist Step 3 runtime choice on user record (MUL-onboarding-v2)
Adds onboarding_runtime_id UUID NULL + onboarding_runtime_skipped BOOLEAN
columns to "user" and the CHECK constraint enforcing the 3-state machine
(unset / picked-runtime / explicit-skip; the fourth combination is
forbidden). ON DELETE SET NULL on the FK so a deleted runtime degrades
to "unset" rather than dangling.
PatchUserOnboarding gains the two narg fields plus CASE expressions that
collapse the runtime/skipped pair atomically — a follow-up PATCH that
flips one side now clears the other in the same statement, instead of
preserving it via per-field COALESCE and tripping the CHECK constraint.
Backwards compatible for existing users: both new fields default to
(NULL, false), which is the "unset" leaf of the state machine, and four
upstream gates on me.onboarded_at != null already short-circuit the
new fields' readers for everyone who's already onboarded.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(server): collapse onboarding side effects to service layer
Introduces OnboardingService.MarkComplete and
WorkspaceContentService.{Ensure,Seed}InstallRuntimeIssue as the single
authorities for the two onboarding side effects that used to be
duplicated across four handlers:
- MarkUserOnboarded + claim starter_content_state +
optional install-runtime fallback seed: was inline in
BootstrapOnboardingRuntime, BootstrapOnboardingNoRuntime,
AcceptInvitation, and CompleteOnboarding.
- install-runtime issue seeding: was inline in CreateWorkspace and
AcceptInvitation as a "no runtime yet" fallback.
After this refactor:
- MarkUserOnboarded is called from exactly one place (the service).
- install-runtime issue is seeded from exactly one place (the service).
- CreateWorkspace deliberately does not seed — the new
/ensure-onboarding-content endpoint (also added here) lets the
workspace-entry init component request the seed on first mount, so
workspaces created but never opened don't accumulate stale issues.
- The PatchOnboarding handler now accepts the new runtime_id /
runtime_skipped fields and rejects (uuid, skipped=true) up front.
- UserResponse exposes the two new persisted fields so the frontend
can read them off `me` without an extra round-trip.
Handler-side tests added: TestPatchOnboarding_RuntimeChoiceSwitch (the
explicit cross-request switch path that the original COALESCE design
would have 500'd on) + TestPatchOnboarding_PreserveUntouched.
Old handler-local file no_runtime_issue.go is deleted; its content
moved to service/workspace_content.go with the helpers exported.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(core): API + types for persisted onboarding runtime choice
User type / Zod schema gain onboarding_runtime_id (string | null) and
onboarding_runtime_skipped (boolean); EMPTY_USER + test fixture updated
to match. api.patchOnboarding accepts the new optional fields and the
new api.ensureOnboardingContent endpoint is wired so the workspace
shell can request the fallback seed.
Two new store helpers — recordOnboardingRuntimeChoice(runtimeId) and
recordOnboardingRuntimeSkipped() — replace the prior pattern of
Step 3 calling bootstrap directly. They PATCH the user's choice, sync
the auth store, and return. Mutually exclusive on the server side via
the CHECK constraint; the client just ships one intent at a time.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(workspace): WorkspaceOnboardingInit single decision point + dumb Modal
Replaces OnboardingHelperModal's self-gating render path with a 4-branch
dispatcher that runs once on workspace-shell mount:
branch 0 me.onboarded_at != null → ensure install-runtime issue
fallback, render nothing
branch 1 me.onboarding_runtime_skipped → SkipBootstrapping component:
loading veil → bootstrap →
navigate. On failure shows
a Retry UI instead of
silently freezing the veil
branch 2 me.onboarding_runtime_id → render Modal with the
runtime id from `me` (no
internal list query)
branch 3 (none of the above) → useEffect navigate back to
/onboarding so the user
walks Step 3 again
The Modal itself is now a dumb component — receives `workspace` and
`runtimeId` as props, no internal gates, no runtimeListOptions query.
Tests rewritten to cover the props-driven render + pick-card paths;
the prior gating tests move into the new
workspace-onboarding-init.test.tsx alongside the M2 retry-on-failure
behaviour.
Mounted in both apps/web/app/[workspaceSlug]/layout.tsx and the desktop
workspace-route-layout. Desktop keeps its `!overlayActive` suppression
guard so the init doesn't portal-jump in front of an active
WindowOverlay.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(onboarding): Step 3 records user choice instead of calling bootstrap
handleRuntimeNext now PATCHes the user's pick (recordOnboardingRuntime
{Choice,Skipped}) and navigates straight into the workspace shell. The
workspace-entry WorkspaceOnboardingInit reads the persisted choice off
`me` and runs the appropriate branch — Step 3 is pure intent capture
with zero side effects on its own.
PATCH must succeed before navigation: if it fails the user stays on
Step 3 with a toast, because navigating with no persisted intent would
land them in WorkspaceOnboardingInit's branch 3 "no decision yet" rescue
and trigger a redirect loop back to /onboarding.
The prior asymmetry (Connect deferred bootstrap to the workspace, Skip
ran bootstrap inline) is gone — both paths defer to the workspace
shell now.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(onboarding): v3 — thin server, frontend-orchestrated welcome
Collapse v2's persisted runtime-choice fields + 4-branch dispatcher +
OnboardingService/WorkspaceContentService stack down to a single rule:
`onboarded_at` is the only state field, layout hard-gates on it, and the
welcome experience after Step 3 is owned entirely by the frontend.
V3 flow
- Step 3 button: await POST /api/me/onboarding/complete (mark only) +
park a transient signal in `useWelcomeStore` + navigate
- Workspace layout: hard gate `onboarded_at == null` -> /onboarding
- `<WelcomeAfterOnboarding />` reads the welcome-store signal:
- runtime path: find-or-create Multica Helper via generic createAgent
with bilingual instructions from `templates/helper-instructions.ts`,
blocking modal with 3 starter cards, pick -> createIssue + navigate
- skip path: provision install-runtime (in_progress) -> agent-guide
(todo, body embeds install-runtime mention chip) -> follow-up comment
on install-runtime mentioning agent-guide; then pop celebration
modal with 🎉 emoji pop animation, 2 read-only preview cards, single
[Got it] CTA that navigates to install-runtime
Server cleanup
- Drop OnboardingService, WorkspaceContentService, v2 runtime-choice
columns/CHECK on user, EnsureOnboardingContent endpoint
- CompleteOnboarding/AcceptInvitation call qtx.MarkUserOnboarded
directly (no service indirection)
- BootstrapOnboardingRuntime / BootstrapOnboardingNoRuntime kept as a
deprecation shim in onboarding_shim.go for desktop < v3 during the
rollout window — handlers inlined to qtx.* calls, no service layer
Localization
- Persisted strings (issue titles/bodies, Helper instructions/
description, comment prefix) live as TS const `{en, zh}` maps in
`packages/views/onboarding/templates/` — i18n bundle staleness can no
longer write raw key paths into DB
- UI-rendered strings (modal copy, status chips, buttons) stay in
`packages/views/locales/{en,zh-Hans}/onboarding.json`
- Language picked from live `i18n.language` (not `me.language`, which is
null for new users until they pick a preference)
Race protection
- Module-level promise dedupe (`findOrCreateHelper`, `seedIssueDeduped`,
`postCommentDeduped`) so React StrictMode double-mount can't fire two
parallel API calls that the server would then 409
Cross-references between the two skip-path issues render via Multica's
mention-chip protocol `[<identifier>](mention://issue/<uuid>)` so they
match the styled IssueChip pills used elsewhere.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(onboarding): welcome-after-onboarding modal redesign + cross-user safety
Welcome modal polish (the post-Step-3 surface this branch already
introduced):
Runtime path
- Helper avatar replaces the bouncy 🎉 hero; tone-down animation to
fade. New copy: "Hi, welcome to Multica / I'm your first Agent
assistant" + capability hint sentence so users discover assignment +
chat from the first screen.
- Cards changed from "click = submit" to multi-select with the existing
border-primary + ring selection pattern used by compact-runtime-row;
bottom CTA "Assign N tasks to me →" appears only with N>0.
- New starter cards: intro / tour / welcome_page (the last one tells
Helper to paste an HTML welcome page into the issue comment — works
on any runtime regardless of fs access).
- Success state added between createIssue and navigation: 🎉 +
"All set!" + "Sit tight ☕ — your {agentName} is on it" + inbox/chat
hints, single [Got it] button.
- Title/prompt for starter cards now live in TS const
HELPER_STARTER_PROMPTS (persisted to DB — must not depend on i18n
bundle being loaded); subtitle stays in onboarding.json.
Skip path
- Body restructured into three independent ```md blocks (Name /
Description / Instructions) so each picks up the markdown renderer's
per-block copy button — no manual extraction.
- ZH body now embeds the ZH Helper Description + Instructions (was
Chinese-around-English-block).
- Follow-up comment uses Multica's mention-chip protocol
[identifier](mention://issue/uuid) so it renders as the styled
IssueChip pill.
- Issue titles bilingual with "Step 1 / Step 2" prefix.
Cross-user / cross-workspace safety (code review feedback)
- web onLogout + desktop handleDaemonLogout now call
useWelcomeStore.reset() so user B logging into the same browser
doesn't inherit user A's signal.
- WelcomeAfterOnboarding gates on
currentWorkspace.id === signal.workspaceId — prevents firing the
modal in workspace B when the signal was parked for workspace A
(desktop multi-tab, back/forward, deep-link).
- Module-level promise dedupes (pendingHelperSetup,
pendingIssueSeed, pendingCommentSeed) for the three API calls so
React 18+ StrictMode dev double-mount can't race-create duplicates.
Other small fixes carried in this commit
- Helper instructions / agent description / starter card titles all
read i18n.language (not me.language, which is null for new users
who haven't picked a UI language preference yet).
- Reverted welcome-emoji-pop animation to a small fade for the runtime
avatar (kept the bouncy variant for the skip 🎉 hero where the
celebration is the whole point).
- Removed the duplicate 🎉 from the skip modal title (kept the hero
one only).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(views): i18n hardcoded "Close" in welcome FullScreenError
CI lint (i18next/no-literal-string) blocked on a literal "Close" string
inside `FullScreenError` — surfaced as a nit in the original code
review but missed in the merge. Add `error_close` to onboarding.json
(EN: "Close" / ZH: "关闭") and thread it through as a `closeLabel`
prop, matching the existing `retryLabel` plumbing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
519 lines
19 KiB
Go
519 lines
19 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sort"
|
|
"testing"
|
|
)
|
|
|
|
func TestCreateWorkspace_RejectsReservedSlug(t *testing.T) {
|
|
// Drive the test off the actual reservedSlugs map so the test can never
|
|
// drift from the source of truth. New entries are covered automatically.
|
|
reserved := make([]string, 0, len(reservedSlugs))
|
|
for slug := range reservedSlugs {
|
|
reserved = append(reserved, slug)
|
|
}
|
|
sort.Strings(reserved) // deterministic test order
|
|
|
|
for _, slug := range reserved {
|
|
t.Run(slug, func(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/workspaces", map[string]any{
|
|
"name": fmt.Sprintf("Test %s", slug),
|
|
"slug": slug,
|
|
})
|
|
testHandler.CreateWorkspace(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("slug %q: expected 400, got %d: %s", slug, w.Code, w.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestCreateWorkspace_DoesNotMarkOnboarded guards the onboarding
|
|
// contract: creating a workspace MUST leave user.onboarded_at NULL so
|
|
// the route guard in apps/web/app/[workspaceSlug]/layout.tsx (and the
|
|
// desktop App.tsx overlay decision) can redirect the un-onboarded user
|
|
// back to /onboarding to finish Step 3. The previous behavior atomically
|
|
// set onboarded_at inside CreateWorkspace; this test makes the new
|
|
// invariant explicit and regression-protected.
|
|
//
|
|
// CompleteOnboarding (Step 3 exit) and AcceptInvitation are the only
|
|
// remaining handlers that flip onboarded_at.
|
|
func TestCreateWorkspace_DoesNotMarkOnboarded(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
const slug = "handler-tests-onboarded-null"
|
|
_, _ = testPool.Exec(ctx, `DELETE FROM workspace WHERE slug = $1`, slug)
|
|
// Ensure the test user starts un-onboarded so the assertion is meaningful.
|
|
_, _ = testPool.Exec(ctx, `UPDATE "user" SET onboarded_at = NULL WHERE id = $1`, testUserID)
|
|
|
|
t.Cleanup(func() {
|
|
_, _ = testPool.Exec(context.Background(), `DELETE FROM workspace WHERE slug = $1`, slug)
|
|
_, _ = testPool.Exec(context.Background(), `UPDATE "user" SET onboarded_at = NULL WHERE id = $1`, testUserID)
|
|
})
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/workspaces", map[string]any{
|
|
"name": "Onboarding Invariant Probe",
|
|
"slug": slug,
|
|
})
|
|
testHandler.CreateWorkspace(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateWorkspace: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var onboardedAt *string
|
|
if err := testPool.QueryRow(ctx, `SELECT onboarded_at FROM "user" WHERE id = $1`, testUserID).Scan(&onboardedAt); err != nil {
|
|
t.Fatalf("lookup user: %v", err)
|
|
}
|
|
if onboardedAt != nil {
|
|
t.Fatalf("CreateWorkspace marked user as onboarded; expected NULL, got %q. The workspace layout hard gate relies on this staying NULL until Step 3 CompleteOnboarding fires.", *onboardedAt)
|
|
}
|
|
}
|
|
|
|
// TestDeleteWorkspace_RequiresOwner exercises the in-handler authorization
|
|
// added to DeleteWorkspace by calling the handler directly (bypassing the
|
|
// router-level RequireWorkspaceRoleFromURL middleware). Without the handler
|
|
// check, a non-owner member request would reach DeleteWorkspace and erase the
|
|
// workspace; with it, the handler must return 403 and leave the workspace
|
|
// intact.
|
|
func TestDeleteWorkspace_RequiresOwner(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
const slug = "handler-tests-delete-403"
|
|
_, _ = testPool.Exec(ctx, `DELETE FROM workspace WHERE slug = $1`, slug)
|
|
|
|
var wsID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO workspace (name, slug, description)
|
|
VALUES ($1, $2, $3)
|
|
RETURNING id
|
|
`, "Handler Test Delete 403", slug, "DeleteWorkspace handler permission test").Scan(&wsID); err != nil {
|
|
t.Fatalf("create workspace: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_, _ = testPool.Exec(context.Background(), `DELETE FROM workspace WHERE id = $1`, wsID)
|
|
})
|
|
|
|
if _, err := testPool.Exec(ctx, `
|
|
INSERT INTO member (workspace_id, user_id, role)
|
|
VALUES ($1, $2, 'admin')
|
|
`, wsID, testUserID); err != nil {
|
|
t.Fatalf("create admin member: %v", err)
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("DELETE", "/api/workspaces/"+wsID, nil)
|
|
req = withURLParam(req, "id", wsID)
|
|
testHandler.DeleteWorkspace(w, req)
|
|
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 from DeleteWorkspace handler for admin (non-owner), got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var exists bool
|
|
if err := testPool.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM workspace WHERE id = $1)`, wsID).Scan(&exists); err != nil {
|
|
t.Fatalf("verify workspace: %v", err)
|
|
}
|
|
if !exists {
|
|
t.Fatal("workspace was deleted despite non-owner request — handler-level check did not fire")
|
|
}
|
|
}
|
|
|
|
// TestDeleteWorkspace_OwnerSucceeds is the positive counterpart: an owner
|
|
// calling DeleteWorkspace directly must succeed (204) and the workspace must
|
|
// be gone. This guards the handler check against being too strict.
|
|
func TestDeleteWorkspace_OwnerSucceeds(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
const slug = "handler-tests-delete-ok"
|
|
_, _ = testPool.Exec(ctx, `DELETE FROM workspace WHERE slug = $1`, slug)
|
|
|
|
var wsID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO workspace (name, slug, description)
|
|
VALUES ($1, $2, $3)
|
|
RETURNING id
|
|
`, "Handler Test Delete OK", slug, "DeleteWorkspace handler owner test").Scan(&wsID); err != nil {
|
|
t.Fatalf("create workspace: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_, _ = testPool.Exec(context.Background(), `DELETE FROM workspace WHERE id = $1`, wsID)
|
|
})
|
|
|
|
if _, err := testPool.Exec(ctx, `
|
|
INSERT INTO member (workspace_id, user_id, role)
|
|
VALUES ($1, $2, 'owner')
|
|
`, wsID, testUserID); err != nil {
|
|
t.Fatalf("create owner member: %v", err)
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("DELETE", "/api/workspaces/"+wsID, nil)
|
|
req = withURLParam(req, "id", wsID)
|
|
testHandler.DeleteWorkspace(w, req)
|
|
|
|
if w.Code != http.StatusNoContent {
|
|
t.Fatalf("expected 204 from DeleteWorkspace handler for owner, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var exists bool
|
|
if err := testPool.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM workspace WHERE id = $1)`, wsID).Scan(&exists); err != nil {
|
|
t.Fatalf("verify workspace: %v", err)
|
|
}
|
|
if exists {
|
|
t.Fatal("workspace still exists after owner DELETE")
|
|
}
|
|
}
|
|
|
|
// revocationFixture is a minimal (workspace, member-to-revoke, runtime,
|
|
// agent, queued-task, daemon-token) bundle used to drive the revocation
|
|
// tests. The "requester" is always testUserID (owner of the workspace) so
|
|
// `newRequest` passes the existing fixtures' auth context unchanged.
|
|
type revocationFixture struct {
|
|
WorkspaceID string
|
|
TargetUserID string
|
|
MemberID string
|
|
RuntimeID string
|
|
AgentID string
|
|
TaskID string
|
|
DaemonID string
|
|
TokenHash string
|
|
}
|
|
|
|
func setupRevocationFixture(t *testing.T, slug, daemonID string) revocationFixture {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
|
|
_, _ = testPool.Exec(ctx, `DELETE FROM workspace WHERE slug = $1`, slug)
|
|
|
|
var wsID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO workspace (name, slug, description, issue_prefix)
|
|
VALUES ($1, $2, $3, $4)
|
|
RETURNING id
|
|
`, "Revocation "+slug, slug, "revocation test", "REV").Scan(&wsID); err != nil {
|
|
t.Fatalf("create workspace: %v", err)
|
|
}
|
|
|
|
// Requester (= testUserID) is always an owner so DeleteMember authorization
|
|
// passes. Two owners total so LeaveWorkspace doesn't trip the "must keep
|
|
// at least one owner" guard.
|
|
if _, err := testPool.Exec(ctx, `
|
|
INSERT INTO member (workspace_id, user_id, role) VALUES ($1, $2, 'owner')
|
|
`, wsID, testUserID); err != nil {
|
|
t.Fatalf("create requester member: %v", err)
|
|
}
|
|
|
|
targetEmail := fmt.Sprintf("revocation-%s@multica.ai", slug)
|
|
var targetUserID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO "user" (name, email) VALUES ($1, $2) RETURNING id
|
|
`, "Revocation Target "+slug, targetEmail).Scan(&targetUserID); err != nil {
|
|
t.Fatalf("create target user: %v", err)
|
|
}
|
|
|
|
// Cleanup ordering: workspace first (cascade clears agent_runtime,
|
|
// agent, member, daemon_token), then user (whose deletion would
|
|
// otherwise be blocked by agent.owner_id / agent_runtime.owner_id FKs).
|
|
t.Cleanup(func() {
|
|
_, _ = testPool.Exec(context.Background(), `DELETE FROM workspace WHERE id = $1`, wsID)
|
|
_, _ = testPool.Exec(context.Background(), `DELETE FROM "user" WHERE id = $1`, targetUserID)
|
|
})
|
|
|
|
var memberID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO member (workspace_id, user_id, role) VALUES ($1, $2, 'owner') RETURNING id
|
|
`, wsID, targetUserID).Scan(&memberID); err != nil {
|
|
t.Fatalf("create target member: %v", err)
|
|
}
|
|
|
|
var runtimeID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO agent_runtime (
|
|
workspace_id, daemon_id, name, runtime_mode, provider, status,
|
|
device_info, metadata, owner_id, last_seen_at
|
|
)
|
|
VALUES ($1, $2, 'Target Runtime', 'local', 'multica_daemon', 'online', '', '{}'::jsonb, $3, now())
|
|
RETURNING id
|
|
`, wsID, daemonID, targetUserID).Scan(&runtimeID); err != nil {
|
|
t.Fatalf("insert runtime: %v", err)
|
|
}
|
|
|
|
var agentID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO agent (
|
|
workspace_id, name, description, runtime_mode, runtime_config,
|
|
runtime_id, visibility, max_concurrent_tasks, owner_id
|
|
)
|
|
VALUES ($1, 'Target Agent', '', 'local', '{}'::jsonb, $2, 'workspace', 1, $3)
|
|
RETURNING id
|
|
`, wsID, runtimeID, targetUserID).Scan(&agentID); err != nil {
|
|
t.Fatalf("insert agent: %v", err)
|
|
}
|
|
|
|
var taskID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO agent_task_queue (agent_id, runtime_id, status, priority)
|
|
VALUES ($1, $2, 'queued', 0)
|
|
RETURNING id
|
|
`, agentID, runtimeID).Scan(&taskID); err != nil {
|
|
t.Fatalf("insert task: %v", err)
|
|
}
|
|
|
|
// daemon_token row — paired with the runtime's daemon_id so the
|
|
// revocation should sweep its hash up via DeleteDaemonTokensByWorkspaceAndDaemons.
|
|
rawToken := "mdt_test_" + slug
|
|
sum := sha256.Sum256([]byte(rawToken))
|
|
tokenHash := hex.EncodeToString(sum[:])
|
|
if _, err := testPool.Exec(ctx, `
|
|
INSERT INTO daemon_token (token_hash, workspace_id, daemon_id, expires_at)
|
|
VALUES ($1, $2, $3, now() + interval '1 day')
|
|
`, tokenHash, wsID, daemonID); err != nil {
|
|
t.Fatalf("insert daemon_token: %v", err)
|
|
}
|
|
|
|
return revocationFixture{
|
|
WorkspaceID: wsID,
|
|
TargetUserID: targetUserID,
|
|
MemberID: memberID,
|
|
RuntimeID: runtimeID,
|
|
AgentID: agentID,
|
|
TaskID: taskID,
|
|
DaemonID: daemonID,
|
|
TokenHash: tokenHash,
|
|
}
|
|
}
|
|
|
|
func assertRevoked(t *testing.T, fx revocationFixture) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
|
|
var memberExists bool
|
|
if err := testPool.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM member WHERE id = $1)`, fx.MemberID).Scan(&memberExists); err != nil {
|
|
t.Fatalf("query member: %v", err)
|
|
}
|
|
if memberExists {
|
|
t.Fatal("member row was not deleted")
|
|
}
|
|
|
|
var runtimeStatus string
|
|
if err := testPool.QueryRow(ctx, `SELECT status FROM agent_runtime WHERE id = $1`, fx.RuntimeID).Scan(&runtimeStatus); err != nil {
|
|
t.Fatalf("query runtime: %v", err)
|
|
}
|
|
if runtimeStatus != "offline" {
|
|
t.Fatalf("expected runtime offline, got %q", runtimeStatus)
|
|
}
|
|
|
|
var archivedAt *string
|
|
if err := testPool.QueryRow(ctx, `SELECT archived_at::text FROM agent WHERE id = $1`, fx.AgentID).Scan(&archivedAt); err != nil {
|
|
t.Fatalf("query agent: %v", err)
|
|
}
|
|
if archivedAt == nil {
|
|
t.Fatal("agent was not archived")
|
|
}
|
|
|
|
var taskStatus string
|
|
if err := testPool.QueryRow(ctx, `SELECT status FROM agent_task_queue WHERE id = $1`, fx.TaskID).Scan(&taskStatus); err != nil {
|
|
t.Fatalf("query task: %v", err)
|
|
}
|
|
if taskStatus != "cancelled" {
|
|
t.Fatalf("expected task cancelled, got %q", taskStatus)
|
|
}
|
|
|
|
var tokenExists bool
|
|
if err := testPool.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM daemon_token WHERE token_hash = $1)`, fx.TokenHash).Scan(&tokenExists); err != nil {
|
|
t.Fatalf("query daemon_token: %v", err)
|
|
}
|
|
if tokenExists {
|
|
t.Fatal("daemon_token row was not deleted")
|
|
}
|
|
}
|
|
|
|
// TestDeleteMember_RevokesTargetRuntimes verifies that when an admin removes
|
|
// another member from a workspace, every runtime owned by the removed member
|
|
// has its agents archived, its in-flight tasks cancelled, its row flipped
|
|
// offline, and its daemon_token rows deleted — all atomically with the member
|
|
// row deletion.
|
|
func TestDeleteMember_RevokesTargetRuntimes(t *testing.T) {
|
|
fx := setupRevocationFixture(t, "handler-tests-revoke-kick", "daemon-revoke-kick")
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("DELETE", "/api/workspaces/"+fx.WorkspaceID+"/members/"+fx.MemberID, nil)
|
|
req.Header.Set("X-Workspace-ID", fx.WorkspaceID)
|
|
req = withURLParams(req, "id", fx.WorkspaceID, "memberId", fx.MemberID)
|
|
testHandler.DeleteMember(w, req)
|
|
|
|
if w.Code != http.StatusNoContent {
|
|
t.Fatalf("DeleteMember: expected 204, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
assertRevoked(t, fx)
|
|
}
|
|
|
|
// TestLeaveWorkspace_RevokesOwnRuntimes is the self-removal counterpart: when
|
|
// a member leaves a workspace voluntarily, their own runtimes are revoked
|
|
// with the same atomic write set as DeleteMember.
|
|
func TestLeaveWorkspace_RevokesOwnRuntimes(t *testing.T) {
|
|
fx := setupRevocationFixture(t, "handler-tests-revoke-leave", "daemon-revoke-leave")
|
|
|
|
// Re-target the request from the leaving member's perspective: the
|
|
// leaver is the request actor, not the workspace owner.
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("DELETE", "/api/workspaces/"+fx.WorkspaceID+"/leave", nil)
|
|
req.Header.Set("X-User-ID", fx.TargetUserID)
|
|
req.Header.Set("X-Workspace-ID", fx.WorkspaceID)
|
|
req = withURLParam(req, "id", fx.WorkspaceID)
|
|
testHandler.LeaveWorkspace(w, req)
|
|
|
|
if w.Code != http.StatusNoContent {
|
|
t.Fatalf("LeaveWorkspace: expected 204, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
assertRevoked(t, fx)
|
|
}
|
|
|
|
// TestDeleteMember_CancelsTasksFromAgentReassignment covers a subtle
|
|
// case: an agent's runtime_id can be changed via UpdateAgent, but
|
|
// agent_task_queue.runtime_id keeps the value from when the task was
|
|
// queued. So after a leaving member is removed, an agent currently bound
|
|
// to their runtime gets archived — but tasks that agent queued under a
|
|
// PRIOR runtime (still owned by another active member) keep their old
|
|
// runtime_id and would not be caught by a runtime-only sweep. Because
|
|
// ClaimAgentTask does not gate on agent.archived_at, those orphaned
|
|
// queued tasks would remain claimable.
|
|
func TestDeleteMember_CancelsTasksFromAgentReassignment(t *testing.T) {
|
|
fx := setupRevocationFixture(t, "handler-tests-revoke-reassign", "daemon-revoke-reassign")
|
|
ctx := context.Background()
|
|
|
|
// Create a SECOND runtime in the workspace owned by the requester
|
|
// (not the leaving member). The agent originally lived here.
|
|
var otherRuntimeID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO agent_runtime (
|
|
workspace_id, daemon_id, name, runtime_mode, provider, status,
|
|
device_info, metadata, owner_id, last_seen_at
|
|
)
|
|
VALUES ($1, $2, 'Other Runtime', 'local', 'multica_daemon', 'online', '', '{}'::jsonb, $3, now())
|
|
RETURNING id
|
|
`, fx.WorkspaceID, "daemon-revoke-reassign-other", testUserID).Scan(&otherRuntimeID); err != nil {
|
|
t.Fatalf("insert other runtime: %v", err)
|
|
}
|
|
|
|
// Queue a task on the agent while it was still pinned to the OTHER
|
|
// runtime (simulating a task created before the agent was reassigned
|
|
// to the leaving member's runtime).
|
|
var orphanTaskID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO agent_task_queue (agent_id, runtime_id, status, priority)
|
|
VALUES ($1, $2, 'queued', 0)
|
|
RETURNING id
|
|
`, fx.AgentID, otherRuntimeID).Scan(&orphanTaskID); err != nil {
|
|
t.Fatalf("insert orphan task: %v", err)
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("DELETE", "/api/workspaces/"+fx.WorkspaceID+"/members/"+fx.MemberID, nil)
|
|
req.Header.Set("X-Workspace-ID", fx.WorkspaceID)
|
|
req = withURLParams(req, "id", fx.WorkspaceID, "memberId", fx.MemberID)
|
|
testHandler.DeleteMember(w, req)
|
|
|
|
if w.Code != http.StatusNoContent {
|
|
t.Fatalf("DeleteMember: expected 204, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
assertRevoked(t, fx)
|
|
|
|
// The orphan task — same agent, different runtime — must also be
|
|
// cancelled. Without the by-agent leg in CancelAgentTasksByRuntimeOrAgent
|
|
// this stays 'queued' and would be picked up by the other runtime.
|
|
var orphanStatus string
|
|
if err := testPool.QueryRow(ctx, `SELECT status FROM agent_task_queue WHERE id = $1`, orphanTaskID).Scan(&orphanStatus); err != nil {
|
|
t.Fatalf("query orphan task: %v", err)
|
|
}
|
|
if orphanStatus != "cancelled" {
|
|
t.Fatalf("expected orphan task cancelled (archived agent leftover on other runtime), got %q", orphanStatus)
|
|
}
|
|
|
|
// And the OTHER runtime — owned by an active member — must still be
|
|
// online: revocation is scoped to the leaving member's owned runtimes.
|
|
var otherStatus string
|
|
if err := testPool.QueryRow(ctx, `SELECT status FROM agent_runtime WHERE id = $1`, otherRuntimeID).Scan(&otherStatus); err != nil {
|
|
t.Fatalf("query other runtime: %v", err)
|
|
}
|
|
if otherStatus != "online" {
|
|
t.Fatalf("expected other-member runtime to stay online, got %q", otherStatus)
|
|
}
|
|
}
|
|
|
|
// TestDeleteMember_NoRuntimes_DeletesMember covers the empty-revocation
|
|
// path: a member with no owned runtimes should still have their member row
|
|
// deleted by the same atomic transaction, with no spurious archive/cancel
|
|
// writes.
|
|
func TestDeleteMember_NoRuntimes_DeletesMember(t *testing.T) {
|
|
ctx := context.Background()
|
|
const slug = "handler-tests-revoke-no-runtimes"
|
|
_, _ = testPool.Exec(ctx, `DELETE FROM workspace WHERE slug = $1`, slug)
|
|
|
|
var wsID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO workspace (name, slug, description, issue_prefix)
|
|
VALUES ($1, $2, $3, $4)
|
|
RETURNING id
|
|
`, "Revocation no runtimes", slug, "revocation no-runtimes test", "REV").Scan(&wsID); err != nil {
|
|
t.Fatalf("create workspace: %v", err)
|
|
}
|
|
|
|
if _, err := testPool.Exec(ctx, `
|
|
INSERT INTO member (workspace_id, user_id, role) VALUES ($1, $2, 'owner')
|
|
`, wsID, testUserID); err != nil {
|
|
t.Fatalf("create requester member: %v", err)
|
|
}
|
|
|
|
var targetUserID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO "user" (name, email) VALUES ($1, $2) RETURNING id
|
|
`, "Revocation No Runtimes Target", "revocation-no-runtimes@multica.ai").Scan(&targetUserID); err != nil {
|
|
t.Fatalf("create target user: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_, _ = testPool.Exec(context.Background(), `DELETE FROM workspace WHERE id = $1`, wsID)
|
|
_, _ = testPool.Exec(context.Background(), `DELETE FROM "user" WHERE id = $1`, targetUserID)
|
|
})
|
|
|
|
var memberID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO member (workspace_id, user_id, role) VALUES ($1, $2, 'admin') RETURNING id
|
|
`, wsID, targetUserID).Scan(&memberID); err != nil {
|
|
t.Fatalf("create target member: %v", err)
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("DELETE", "/api/workspaces/"+wsID+"/members/"+memberID, nil)
|
|
req.Header.Set("X-Workspace-ID", wsID)
|
|
req = withURLParams(req, "id", wsID, "memberId", memberID)
|
|
testHandler.DeleteMember(w, req)
|
|
|
|
if w.Code != http.StatusNoContent {
|
|
t.Fatalf("DeleteMember: expected 204, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var memberExists bool
|
|
if err := testPool.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM member WHERE id = $1)`, memberID).Scan(&memberExists); err != nil {
|
|
t.Fatalf("query member: %v", err)
|
|
}
|
|
if memberExists {
|
|
t.Fatal("member row was not deleted")
|
|
}
|
|
}
|