* test(migrate): add concurrent migration race test using real Postgres (MUL-2956)
Follow-up to MUL-2923 / #3658, which added a Postgres advisory lock to
serialize the migration loop across concurrent runners (multi-replica
backend startup, scale-up, manual `migrate up` overlap). That PR shipped
without a test because cmd/migrate/ had no harness; this commit adds it.
Refactor: extract runMigrations(ctx, pool, runOptions) from main(), with
the lock key, the bookkeeping table, and the file list now injectable.
main() behavior is unchanged. Identifier interpolation goes through
pgx.Identifier{}.Sanitize so callers can pass "schema.schema_migrations"
safely.
Tests (cmd/migrate/migrate_concurrent_test.go) — every case isolates
itself in a unique throwaway schema and a unique lock key, so they
never touch the real schema_migrations table or block real production
runners that share the database. Skip cleanly when DATABASE_URL is
unreachable, matching the pattern already used in
internal/handler/handler_test.go and internal/metrics/business_sampler_pgsleep_test.go.
- TestRunMigrationsConcurrentPending: 16 goroutines apply 5
deliberately non-idempotent migrations (bare CREATE TABLE +
ALTER TABLE ADD COLUMN). Without the lock, concurrent CREATE TABLE
races trip "duplicate key value violates unique constraint
pg_type_typname_nsp_index" — proving the lock is doing its job.
- TestRunMigrationsConcurrentAlreadyApplied: 16 goroutines hit the
EXISTS no-op path against a pre-populated bookkeeping table; the
state must be unchanged.
- TestRunMigrationsAdvisoryLockSerializes: an external connection
holds the same advisory lock; we assert that zero of the 16
runners complete during a 1 s observation window, then release
the side lock and let them all finish. Catches the original
MUL-2923 bug where the lock got attached to a random pooled
connection.
- TestRunMigrationsConcurrentMixedPoolStress: same pending case but
with a deliberately small pool (runners/2), forcing pgxpool.Acquire
contention to overlap with pg_advisory_lock contention.
Verified locally: `go test -race -count=10 ./cmd/migrate/` passes in
~15 s. Mutation test (lock acquire/release replaced with `SELECT 1`)
confirms the pending and lock-serializes tests both fail loudly,
catching the regression they were written to detect.
go.mod tidy promotes golang.org/x/sync to a direct dependency
(now imported by the test for errgroup) and incidentally fixes a
stale `// indirect` annotation on prometheus/client_model, which is
already imported directly by internal/metrics/testutil.go.
Co-authored-by: multica-agent <github@multica.ai>
* test(migrate): gofmt + address review nits (MUL-2956)
- gofmt -w cmd/migrate/migrate_concurrent_test.go: fixture struct field
alignment.
- quoteQualifiedIdentifier: actually reject identifiers with more than
one dot (the previous version split on the first dot only and would
silently sanitize "a.b.c" into "a"."b.c", contradicting the comment).
Inline the splitter via strings.Split now that we explicitly check the
component count.
- Soften the test's lock-key comment from "never collide" to the
accurate probabilistic statement (~1 in 2^62 collision odds with the
production constant).
go test -race -count=10 ./cmd/migrate/ still passes (~15 s).
Co-authored-by: multica-agent <github@multica.ai>
* test(migrate): direction whitelist + tidy go.mod (MUL-2956)
Address two follow-ups from review:
- runMigrations now whitelist-checks opts.Direction up-front and
returns an error for anything that is not "up" or "down". The
previous shape relied on `opts.Direction == "up"` and an else branch,
so a typo or empty string would silently fall through to the
rollback path. Add TestRunMigrationsRejectsInvalidDirection covering
the empty string, "UP"/"DOWN" case mismatches, "rollback", and a
whitespace-padded value; the check fires before any pool work, so
the test runs without Postgres.
- go mod tidy: promotes google.golang.org/protobuf to a direct
dependency (it is imported directly elsewhere in the module and was
stale-marked indirect).
go test -race -count=10 ./cmd/migrate/ green (~15.7 s, 50/50).
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: wei-heshang <wei-heshang@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Three independent line-based frontmatter parsers only handled
single-line `description: value`, so a YAML block scalar
(`description: |`) collapsed to the literal "|" and the rest of the
description was dropped before it ever reached the database.
Replace all three with real YAML decoders that understand block
scalars, folded scalars and quoted values:
- server/internal/skill: shared ParseFrontmatter via gopkg.in/yaml.v3,
used by both the handler import path and daemon local-skill discovery
- packages/core/skills: shared parseFrontmatter via the yaml package
- file-viewer renders multi-line frontmatter values (whitespace-pre-wrap)
Both parsers fall back to empty values on malformed YAML, preserving the
previous non-fatal behaviour.
Remediates two pgx security advisories in a single bump:
- CVE-2026-33816 (fixed in 5.9.0) — pgproto3 memory-safety DoS from
malformed messages sent by a malicious server.
- GHSA-j88v-2chj-qfwx / CVE-2026-41889 (fixed in 5.9.2) — SQL injection
via placeholder confusion with dollar-quoted literals under
QueryExecModeSimpleProtocol. Not reachable in this codebase (no
simple-protocol callers), but pinned to clear future scanner runs.
No source changes needed: pgx 5.9.x adds no breaking APIs over 5.8.x
(adds PG protocol 3.2 support, SCRAM-SHA-256-PLUS, OAuth, plus
pgtype/pgconn bug fixes). Minimum Go bumped to 1.25 in 5.9.0; repo
already on 1.26.1.
MUL-2597
Co-authored-by: multica-agent <github@multica.ai>
* fix(execenv): default-disable Codex native multi-agent in per-task config
Recent Codex app-server releases enable features.multi_agent by default,
exposing spawn_agent / wait / close_agent tools that let a parent thread
spawn nested subagents. The daemon currently models only the parent thread,
so the parent's turn/completed is treated as task completion even when
spawned children are still running — leading to premature task completion
and dropped child output.
Disable features.multi_agent by default in the per-task CODEX_HOME/config.toml
so Multica's task lifecycle is the only orchestration layer in play. Strip
both the dotted-key form (features.multi_agent) at TOML root and the
multi_agent key inside a [features] table; siblings and unrelated tables
are preserved. Honor MULTICA_CODEX_MULTI_AGENT=1 as an opt-out for users
who explicitly want Codex native subagents inside a Multica task.
The user's global ~/.codex/config.toml is never modified — only the daemon's
isolated per-task copy.
Also widen managedBlockRe to consume `\n*` rather than `\n?` so reruns
don't accumulate blank lines when both the sandbox and multi-agent managed
blocks coexist.
* fix(execenv): inject managed multi_agent inside existing [features] table
Per PR review (codex_multi_agent.go:77-83 vs :112-115): when the user's
config.toml already has a top-level `[features]` table, writing
`features.multi_agent = false` at the TOML root implicitly redefines the
same `features` table. The strict TOML parser used by Codex (`toml-rs`)
rejects that with `table 'features' already exists`, so Codex would fail
to load the per-task config and refuse to start the thread. Verified the
strict-parser failure with pelletier/go-toml/v2; the previous
BurntSushi/toml-based regression test was permissive enough to miss it.
Detect a root-level `[features]` header and place the managed block
inside that table (`multi_agent = false` with marker comments). When no
such header exists, keep the existing root-level dotted-key form. The
managed-block regex matches both layouts so reruns and layout
transitions stay idempotent. A `[features.experimental]` sub-table
without a bare `[features]` header still uses the root dotted-key form,
which is spec-valid (no explicit redefinition).
Tests now use pelletier/go-toml/v2 to actually parse the output and
assert features.multi_agent decodes to false; the regression case from
the PR review is covered explicitly.
* fix(execenv): recognize feature table header variants
---------
Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
* feat(realtime): phase 0 — extract Broadcaster interface + add metrics
Phase 0 of the WebSocket horizontal-scaling plan tracked in MUL-1138.
This change is intentionally behavior-preserving: it sets up the seams
needed for later phases (subscribe/unsubscribe protocol, scope-level
fanout, Redis Streams relay) without altering any wire protocol or
producer call sites.
What changed
- New realtime.Broadcaster interface covering the three fanout methods
producers already use on *Hub (BroadcastToWorkspace, SendToUser,
Broadcast). *Hub continues to satisfy it; a future Redis-backed
implementation can be dropped in without touching listeners.
- registerListeners now depends on realtime.Broadcaster instead of
*realtime.Hub, isolating the bus → realtime fanout layer behind an
interface.
- New realtime.Metrics singleton with atomic counters: connects,
disconnects, active connections, slow-client evictions, total
messages sent/dropped, and per-event-type send counters. Wired into
Hub register/unregister/broadcast paths and into every listener.
- New GET /health/realtime endpoint returning a JSON snapshot of the
metrics so we can observe baseline fanout pressure before phase 1.
Why phase 0 first
GPT-Boy's only-Redis plan and CC-Girl's review both call out the same
prerequisite: get a Broadcaster seam and visibility in place before
introducing scope-level subscriptions or a Redis relay. Doing this as
a standalone step keeps each later PR focused and trivially revertable.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* feat(realtime): only-Redis fanout — scopes, subscribe protocol, Redis Streams relay (MUL-1138)
Implements the final-version plan agreed in MUL-1138 on top of phase 0:
* Hub: 4 scope types (workspace/user/task/chat), per-client subscription
set, subscribe/unsubscribe WS frames, ScopeAuthorizer hook for
task/chat scope auth, first/last-subscriber callbacks for the relay,
workspace+user auto-subscribe on connect.
* RedisRelay: Broadcaster impl that XADDs every event into
ws:scope:{type}:{id}:stream and XREADGROUPs only the scopes for which
this node has live subscribers. Per-node consumer group, heartbeat,
stale-consumer sweeper, MAXLEN cap, lag/disconnect metrics.
* Listeners: route task:* events to ScopeTask, chat:* events to
ScopeChat; workspace remains the default for everything else.
* events.Event: optional TaskID / ChatSessionID hints so the listener
layer can pick the right scope without re-parsing payloads.
* Handler: publishTask / publishChat helpers; chat + task message
publishers updated to use them.
* main.go: when REDIS_URL is set, wrap the hub with NewRedisRelay and
pass the relay (instead of the hub) to registerListeners. A
db-backed ScopeAuthorizer enforces that task/chat subscribes belong
to the caller's workspace.
* Metrics: per-scope subscribe/deny counters, redis connect state, node
id, lag/dropped counters surfaced via /health/realtime.
Behavior in single-node mode (REDIS_URL unset) is unchanged.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(realtime): address PR #1429 review must-fix items (MUL-1138)
- listeners: keep task/chat events on workspace fanout until the WS
client supports scope-subscribe + reconnect-replay. Routing them
through BroadcastToScope today (without any client subscriber) would
silently drop every chat / task message and break the live timeline,
chat unread badges, and pending-task UI. The server-side scope infra
(Hub subscribe/unsubscribe, ScopeAuthorizer, Redis Streams relay)
stays in place so flipping the switch in the client follow-up PR is
a one-line change.
- scope_authorizer: ScopeChat now enforces CreatorID == userID, mirroring
the HTTP layer (handler/chat.go: GetChatSession / SendChatMessage /
MarkChatSessionRead). Without this, any workspace member who learned a
session_id could subscribe to chat:message / chat:done /
chat:session_read for a peer's private chat. The same creator-only
check is applied to ScopeTask when the task is a chat task
(task.ChatSessionID set). Issue tasks remain workspace-scoped.
- Refactor scope authorizer to depend on a narrow scopeAuthQuerier
interface so its decisions can be unit-tested without a live DB.
- Add tests:
* listeners_scope_test.go pins the workspace-fanout fallback for
task:message / task:progress / chat:message / chat:done /
chat:session_read.
* scope_authorizer_test.go covers chat creator-only access, chat-task
creator-only access, and issue-task workspace-only access (creator
allowed, peer denied, cross-workspace denied, missing session
denied, empty userID denied).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: CC-Girl <cc-girl@multica.ai>
The bluemonday HTML sanitizer applied to comment content (added in #679)
treats Markdown source as HTML, entity-encoding syntactically meaningful
characters and normalizing whitespace. This corrupts user input:
- "> quote" -> "> quote" (blockquote lost, see #1303)
- '"foo"' -> '"foo"' (literal entities visible)
- "\n\n2." -> " 2." (ordered list items merged into prose)
Comment content is stored as Markdown source. XSS is already handled at
two layers:
- Render: rehype-sanitize in packages/ui/markdown and
packages/views/editor/readonly-content (mention:// allowlist,
data-href restricted to http(s), class restricted to
code/div/span/pre).
- Edit: @tiptap/markdown is configured with html:false, so Markdown
source containing raw HTML tags is treated as plain text.
Removing the server-side sanitizer therefore does not lower the security
boundary, and restores faithful Markdown round-tripping.
The PR #1342 workaround in the editor serializer can be dropped once
this lands.
Co-authored-by: devv-eve <eve@devv.ai>
Co-authored-by: Eve <eve@multica.ai>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The bluemonday HTML sanitizer applied to comment content (added in #679)
treats Markdown source as HTML, entity-encoding syntactically meaningful
characters and normalizing whitespace. This corrupts user input:
- "> quote" -> "> quote" (blockquote lost, see #1303)
- '"foo"' -> '"foo"' (literal entities visible)
- "\n\n2." -> " 2." (ordered list items merged into prose)
Comment content is stored as Markdown source. XSS is already handled at
two layers:
- Render: rehype-sanitize in packages/ui/markdown and
packages/views/editor/readonly-content (mention:// allowlist,
data-href restricted to http(s), class restricted to
code/div/span/pre).
- Edit: @tiptap/markdown is configured with html:false, so Markdown
source containing raw HTML tags is treated as plain text.
Removing the server-side sanitizer therefore does not lower the security
boundary, and restores faithful Markdown round-tripping.
The PR #1342 workaround in the editor serializer can be dropped once
this lands.
Co-authored-by: Eve <eve@multica.ai>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* feat(autopilot): add scheduled/triggered automation for AI agents
Introduce the Autopilot feature — recurring automations that assign work
to AI agents on a schedule or manual trigger. Supports two execution
modes: create_issue (creates an issue for the agent to work on) and
run_only (directly enqueues an agent task without issue pollution).
Backend: migration (3 tables + 2 columns), sqlc queries, AutopilotService
with concurrency policies (skip/queue/replace), HTTP CRUD + trigger
endpoints, background cron scheduler (30s tick), event listeners for
issue→run and task→run status sync.
Frontend: types, API client methods, TanStack Query hooks with optimistic
mutations, realtime cache invalidation, list page with create dialog,
detail page with trigger management and run history, sidebar nav + routes
for both web and desktop apps.
* feat(autopilot): improve UX — trigger config, edit dialog, template gallery
- Replace raw cron input with friendly frequency tabs (Hourly/Daily/Weekdays/Weekly/Custom), time picker, and timezone dropdown defaulting to user's local timezone
- Fix Select components showing UUIDs instead of names (Base UI render function pattern)
- Add Edit button on detail page opening a unified edit dialog
- Remove project/concurrency/issue-title-template from create/edit (simplify for users)
- Add trigger configuration inline during autopilot creation
- Add template gallery on empty state (6 step-by-step workflow templates)
- Rename "Description" to "Prompt" throughout UI
- Inject autopilot run timestamp into issue description for agent date awareness
- Treat issue status "in_review" as run completion (fixes skip on next trigger)
- Make migration idempotent with IF NOT EXISTS clauses
* fix: sanitize markdown rendering in comments and shared renderers
Add rehype-sanitize to both ReadonlyContent and Markdown components so
that raw HTML parsed by rehype-raw is sanitized against a strict
allowlist before reaching the DOM. On the backend, add a bluemonday
sanitization pass when creating and updating comments to strip
dangerous tags as defense-in-depth.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: add mention:// protocol to sanitize allowlist and validate file card URLs
- Add mention:// to rehype-sanitize protocols.href in both ReadonlyContent
and Markdown so @mention links survive sanitization
- Validate data-href on file cards to only allow http(s) URLs, blocking
javascript: and data: schemes in both frontend click handler and backend
bluemonday policy
- Narrow class attribute allowlist to specific elements (code, div, span, pre)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Use google/uuid NewV7() for attachment ID and S3 file key instead of
random hex, so the S3 object name matches the attachment record ID
- Add LinkAttachmentsToIssue query to associate orphaned attachments
with a newly created issue
- Pass attachment_ids in CreateIssue request so uploads during issue
creation (before the issue exists) get linked after commit
- Collect and pass attachment IDs in comment-input and reply-input
so comment creation properly links uploaded files
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add POST /api/upload-file endpoint that uploads files to S3 and returns
CDN URLs protected by CloudFront signed cookies (same pattern as Linear).
Infrastructure:
- Two private S3 buckets (static.multica.ai, static-staging.multica.ai)
- Two CloudFront distributions with OAC and Trusted Key Groups
- ACM wildcard cert in us-east-1, DNS records in Route 53
- RSA signing key stored in AWS Secrets Manager
Backend:
- S3 storage service with CloudFront CDN domain support
- CloudFront signed cookie generation (RSA-SHA1)
- Private key loaded from Secrets Manager (env var fallback for local dev)
- Cookies set on login (VerifyCode) with 72h expiry matching JWT
- Upload handler: multipart form → S3 → CloudFront URL response
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(auth): add email verification login flow with 401 auto-redirect
Replace the old OAuth-based login with email verification codes:
- Backend: send-code / verify-code endpoints, verification_codes table (migration 009), rate limiting, Resend email service
- Frontend: two-step login UI (email → 6-digit OTP), auth store with sendCode/verifyCode
- SDK: ApiClient gains onUnauthorized callback; 401 responses auto-clear token and redirect to /login
- Fix login button staying disabled due to global isLoading state
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(auth): add brute-force protection, redirect loop guard, and expired code cleanup
- VerifyCode: increment attempts on wrong code, reject after 5 failed tries (migration 010)
- onUnauthorized: skip redirect if already on /login to prevent infinite loops
- SendCode: best-effort cleanup of expired verification codes older than 1 hour
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(auth): add master verification code for non-production environments
Allow code "888888" to bypass email verification in non-production
environments to simplify development and testing workflows.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(auth): add personal access tokens for CLI and API authentication
Add full-stack PAT support: users create tokens in Settings, CLI authenticates
via `multica auth login`. Server stores SHA-256 hashes only. Auth middleware
extended to accept both JWTs and PATs (distinguished by `mul_` prefix).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace raw fmt/log calls with structured slog logger (Go) and
console-based logger (TypeScript). Add request logging middleware.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Extract daemon logic from cmd/daemon/ into internal/daemon/ package and
create a new unified CLI entry point at cmd/multica/ using cobra. The CLI
supports `daemon` as a long-running subcommand plus ctrl subcommands for
agent/runtime management, config, status, and version.
Server, migrate, and seed binaries remain unchanged.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add HTTP handlers for issues, comments, agents, workspaces, inbox, members, and activity
- Implement JWT authentication middleware with Bearer token validation
- Add sqlc queries for all entities (CRUD operations)
- Extract router into reusable NewRouter() for testability
- Expand SDK with full API client methods (CRUD for all resources)
- Add updateWorkspace to SDK, add Member type to shared types
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>