Commit Graph
129 Commits
Author SHA1 Message Date
2083970b51 MUL-8374: fix: keep private chat content to the session owner (realtime + REST) (#9677)
* MUL-8374: fix(realtime): deliver private chat content only to the session owner

Chat events and chat-task transcript (task:message) frames were fanned out
to every connection in the workspace. Route every chat:* event, and
task:message for chat tasks, to the chat session creator's user scope
instead. Producers stamp the owner on the bus event (from the same
chat_session row they already read); an event without an owner is dropped
rather than widened. Chat-task lifecycle events keep workspace fanout for
agent presence.

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(server): keep chat-task content private on REST task reads

The transcript endpoint now 404s chat tasks for anyone but the chat
session's creator, and the agent run list and workspace task snapshot keep
foreign chat-task rows but blank their result and error.

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(realtime): scope private chat delivery to the owner's workspace connections

User-scope delivery reached the chat creator's connections to every
workspace. Those are authorized against their own workspace only, so a
creator removed from the chat's workspace kept receiving its transcript
and reply through a socket to another workspace. Chat content now goes to
a workspace_member scope each connection joins at connect time; it is
re-authorized against the chat's workspace on every write.

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-10-29 19:13:45 +08:00
7ebaf00e7c MUL-8136: feat(server): restrict workspace creation to listed emails (self-host) (#9411)
* feat(server): allow workspace creation only for listed emails (self-host)

Add WORKSPACE_CREATION_ALLOWED_EMAILS. When set, POST /api/workspaces
returns 403 for callers whose email is not listed. Empty keeps the
current behavior; DISABLE_WORKSPACE_CREATION=true still blocks everyone.
No instance-admin role, no domain allowlist, no UI hiding.

Refs #9400, MUL-8136

Co-authored-by: multica-agent <[email protected]>

* docs(self-host): clarify which signup vars /api/config exposes

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: multica-eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-10-22 08:49:07 +08:00
0fe2ebf02e MUL-8045: feat(runtimes): Qoder Cloud Agent as a managed cloud runtime (#9402)
* feat(runtimes): add Qoder Cloud Agent managed remote execution

* fix(qoder): satisfy migration and localization CI contracts

* fix(qoder): bound execution and scope managed credentials

* fix(qoder): avoid upstream migration number collisions

* fix(qoder): gate issue creation and correlate tool results

* fix(qoder): synchronize guarded issue lifecycle transitions

* test(agent): publish Hermes holder identity atomically

* feat(agents): runtime-first create flow and cloud runtimes section for Qoder Cloud Agent

- Three-step create-agent flow (runtime -> configure -> done) with local,
  remote and cloud sections; Qoder connect form inline in the runtime step
- Runtimes page regrouped into remote and cloud sections with Qoder card
- Generalize Qoder storage to cloud_agent_connection (migrations 586/587)
- Locales for en, zh-Hans, ja, ko, fr; desktop and web routes for done step
- Fix status-picker fixture owner_id and diagnostics route coverage after main merge

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(runtimes): unify runtimes page into machines and cloud services

- One Add menu replaces Add remote runtime, the duplicated Add a computer
  and the web-only Cloud Runtime header button
- Group by machines / cloud services instead of local / remote / cloud, so
  web and desktop share one structure; this device is pinned with a badge
- Cloud services use the machine row anatomy in a single ListGrid table;
  unconnected Qoder is a row with Connect, coming-soon providers are rows
- Empty groups show the next step inline; search and online/offline filter
- Rename computer to machine across runtimes copy in all locales

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* test(runtimes): cover unified runtimes page; pin status and action tracks

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* refactor(runtimes): trim redundant descriptive copy on runtimes page

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(runtimes): keep row actions visible at narrow widths

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* refactor(runtimes): align Qoder add-menu item with other cloud services

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(runtimes): close the Qoder dialog after a first-time connection

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(agents): pick the runtime from one grouped list

The runtime step showed four kinds of cards, a comparison panel and
per-section descriptions. It is now one list in the runtimes-page layout:
machines (this one open, others folded to a row) and cloud services. An
unconnected Qoder opens the runtimes-page connect dialog in place and is
selected once it comes online.

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(agents): show owners as 'you' and bare version numbers in the runtime list

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(qoder): disconnect action and dedicated Qoder runtime page

Disconnect removes the encrypted connection and the managed runtime, unbinding
its agents after the admin confirms them. The Qoder row now opens its own page
(status, agents, usage, configuration, pause/resume, disconnect) instead of the
machine layout; generic runtime delete and visibility refuse a managed runtime.

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(agents): land on the done page after creating from step 2 on desktop

Desktop's location is the tab URL, which moves to /agents/new/done/<id>
while the configure page is still mounted. Its missing ?runtime= tripped
the skipped-step-1 redirect and replaced the tab back to /agents/new, so
Create looked like it did nothing. Only redirect on the manual route.

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(agents): show only settings a Qoder agent can run with

The Qoder bridge fails every task whose agent carries a model, thinking or
speed override, env, custom args, custom skills, MCP config or connected
apps, and it runs issues only. The agent detail page still offered all of
those editors, so one save could break every later run.

For a qoder_cloud agent the page now keeps Instructions under Capabilities,
General / Access / QCA Agent under Settings, drops the model rows and the
header model chip, shows the QCA agent in the summary, and locks the
runtime. Other agents no longer see the Qoder runtime in their picker,
since moving onto it needs a QCA agent the server would otherwise reject.

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(agents): hide conversation starters for Qoder agents

Starters only appear in a chat, and the Qoder bridge runs issues only.
The create flow already leaves them out for this runtime.

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(runtimes): show the Qoder connection as cards with per-section editing

The Qoder runtime page rendered the whole connection as one always-editable
form. It now summarizes the connection, repositories and limits as read-only
cards; each section opens its own dialog and saves only that section.

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(qoder): reuse a follow-up session only with the same repository and credential

Resolve repository credentials before deciding to continue a prior session,
and stamp each session with a fingerprint of its repositories, branches and
tokens. A follow-up continues only when the fingerprint still matches, so a
changed repository, branch or rotated/revoked token never rides into an old
session.

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(qoder): keep a paused connection paused when its settings are saved

Saving repositories, limits or the connection no longer re-enables a paused
runtime. Resume goes through a new POST /qoder/resume.

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(agents): drop settings a Qoder agent cannot run when creating one

A draft started on another runtime could carry hidden skills, model
overrides and starters into a Qoder agent that then failed every task. The
create request now drops them for Qoder, and the API rejects skills and
execution overrides on Qoder agents at save time.

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* chore(db): regenerate sqlc models for cloud_agent_connection

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(runtimes): never list the Qoder runtime under Machines

When the server could not run Qoder (no MULTICA_VCS_SECRET_KEY /
MULTICA_QODER_STATE_DIR), the managed Qoder runtime fell back into the
Machines group as if it were a computer.

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* refactor(qoder): drop MULTICA_QODER_STATE_DIR

The managed bridge keeps its run journal encrypted in cloud_agent_connection
and a database advisory lock already elects one supervisor, so the state
directory only held a redundant file lock and acted as a feature switch.
Qoder is now available whenever MULTICA_VCS_SECRET_KEY is set. The
standalone qoder-runtime command still uses its on-disk state_dir.

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(qoder): keep the branch in the session fingerprint after a journal restore

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(agents): validate Qoder agents' effective settings on every write

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: 安辰 <[email protected]>
Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-10-22 01:33:38 +08:00
1e01173fb4 MUL-8060 fix(daemon): trust the backend's SSL_CERT_DIR CAs when syncing repos (#9312)
* fix(daemon): report the checkout's own repo sync error

ensureRepoReady read the sync failure from a per-workspace slot that any
concurrent background sync overwrote, so a later successful sync of another
repo erased the error and checkout reported only "repo is configured but
not synced". syncWorkspaceReposContext now returns its error to the caller
and the shared slot is removed.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* feat(server): share SSL_CERT_DIR CAs with daemons for git

Daemons clone with the system git, which never sees the backend's
SSL_CERT_DIR, so a repository on a host with an internal CA could not
sync even after the operator made the backend trust that CA.

The new gitca package mirrors crypto/x509's SSL_CERT_DIR loading, skips
the system directories, and keeps only certificates crypto/x509 trusts.
The register and workspace repos responses carry them as ca_certs. The
daemon writes its system CA bundle plus those certificates to a bundle
file, adds http.sslCAInfo to its git environment, and records it in the
cache and isolated checkout config so agents' fetch and push use it too.
An empty value removes the bundle; Windows daemons skip it.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* docs: document private CA trust for daemons

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix(daemon): keep operator CA trust and existing checkouts working

Review of the private CA support found two regressions for repositories
that already worked:

- Removing the last backend CA deleted the bundle while existing linked
  worktrees and isolated checkouts still pointed at it, so their HTTPS
  fetch and push failed. The bundle is now rewritten to the machine's own
  trust instead, and each repository drops the reference the next time
  the cache configures it.
- The bundle started from the system bundle and was injected through the
  environment, overriding an operator's general http.sslCAInfo and any
  repository-local value. It now starts from the file a general
  http.sslCAInfo names (system bundle otherwise), the environment no
  longer carries it, clone passes it with -c, and repository config is
  only set where unset and only unset where it is ours. URL-scoped
  settings, repository-local values, and GIT_SSL_CAINFO keep precedence.

Isolated checkouts record the setting before their first checkout, which
can lazily fetch from origin, and clones before the refspec migration
fetch.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

---------

Co-authored-by: Iliya Anastasov <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-21 14:25:27 +08:00
756cdd915c MUL-7794 fix(wakeups): surface recoverable PR condition diagnostics [ZIC-302] (#9066)
* fix(wakeups): surface recoverable PR condition diagnostics

Co-authored-by: multica-agent <[email protected]>

* fix(wakeups): preserve condition diagnostic ownership

Co-authored-by: multica-agent <[email protected]>

* test(wakeups): isolate receipt cleanup limit

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: multica-agent <[email protected]>
Co-authored-by: Yushen <[email protected]>
2026-10-12 13:15:46 +08:00
苗大 3bacc41f84 MUL-7573 fix(lark): clear the Typing reaction without trusting in-process state (#8656)
* fix(lark): persist and reconcile typing cleanup across replicas

- preserve terminal trigger targets and per-input cleanup ownership
- reconcile late Adds and source deletion without delaying replies
- recover interrupted ledger indexes and cover lifecycle boundaries

Refs: #8655

* fix(lark): restrict typing cleanup to the current application

- match both app operator type and the current App ID
- preserve foreign-app, human and unknown-owner reactions
- cover ownership filtering in fast and durable cleanup paths

Refs: #8655

* fix(lark): bound typing recovery and credential retention

- recover taskless indicators after lost debounce timers
- cap workspace ledger admission and redact terminal credentials
- isolate maintenance budgets and test retention and quota boundaries

Refs: #8655

* docs(lark): document typing cleanup recovery and rollout limits

- explain ownership, retention, quota and observable terminal outcomes
- document migration upgrades and safe application rollback

Refs: #8655

* fix(deps): require patched Go 1.26.9 and x/net 0.60

- Require the patched Go standard library to clear reachable vulnerabilities.
- Upgrade x/net to the matching HTTP/2 security release.
- Align local development prerequisites with the minimum Go version.
2026-10-08 20:20:15 -07:00
1a09c65b1a feat(llm): support MULTICA_LLM_DISABLE_THINKING env (MUL-7162) (#8657)
* feat(llm): support MULTICA_LLM_DISABLE_THINKING env

Co-authored-by: multica-agent <[email protected]>

* docs(llm): scope reasoning_effort note to quick actions

The disable-thinking docs claimed GPT-5.6-family models already get reasoning_effort=none from the server and do not need the new switch. That is only true for quick actions (GenerateJSON); chat auto-titling goes through GenerateText, which sets no reasoning field. Narrow the wording in .env.example and all four language docs so the translations stay in sync.

Co-authored-by: multica-agent <[email protected]>

* docs(llm): scope the disable-thinking note to accepting upstreams

The previous wording called the switch "the only way" to turn off auto-titling
reasoning, but on a standard OpenAI endpoint the switch adds chat_template_kwargs
to the title request and the upstream rejects it, so titles fail instead of
skipping thinking. Drop the "only way" claim and name the feature the way the
list below does (follow-up questions).

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: zhudejun1 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
Co-authored-by: Multica Agent <[email protected]>
2026-09-24 13:07:25 +08:00
93ae552ba9 feat: add messages to active Claude Code and Codex runs (MUL-7565) (#8682)
* feat: add live run supplements (MUL-7565)

Co-authored-by: multica-agent <[email protected]>

* fix: address MUL-7565 review feedback

Co-authored-by: multica-agent <[email protected]>

* fix: resolve MUL-7565 supplement panel and receipt review

Co-authored-by: multica-agent <[email protected]>

* refactor: remove MUL-7565 supplement ordinals

Co-authored-by: multica-agent <[email protected]>

* fix: close supplement composer after run remount

* fix: exclude task supplements from completion reconciliation

* fix: build a persistent binary for local daemon launches

* feat: support Claude task supplements through SDK hooks

* refactor: simplify task supplement delivery

* test: remove redundant task supplement coverage

* fix: remove redundant supplement delivery notices

* refactor: extract local daemon launcher fix to its own PR

* fix: preserve ordinary comment size compatibility

* fix: accept empty task start acknowledgements

* fix: bound task start response decoding and retries

---------

Co-authored-by: Forge-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
Co-authored-by: yushen <[email protected]>
2026-09-23 16:10:07 +08:00
f0c9146c4b MUL-7437: fix(api): classify disabled capabilities as non-retryable (#8487)
* fix(plugin): return 403 when API is disabled (MUL-7437)

Co-authored-by: multica-agent <[email protected]>

* fix(api): classify disabled capabilities as non-retryable (MUL-7437)

Co-authored-by: multica-agent <[email protected]>

* fix(api): address disabled capability review feedback (MUL-7437)

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-16 17:27:05 +08:00
Bohan JiangandCatCatUncle 3ac53a68a6 MUL-7108 fix(autopilot): judge every autopilot write as the human it acts for (#8107)
Autopilot write endpoints judged the request's authenticated user, which under
a mat_ task token is the RUNTIME OWNER. Any member could therefore @ an agent
running on someone else's machine and spend that machine owner's autopilot
rights — pause a rule, re-point a webhook, rotate a token, or grant themselves
a collaborator row.

Every write on this surface now resolves the human it acts FOR (a member acts
for themselves, an agent for its run's originator), through one seam, and
stamps that same human into created_by / published_by / granted_by and the
rule versions. The gate and the row can no longer name two different people.

Two bypasses of that gate are closed with it:

- The webhook credential was broadcast to the whole workspace on
  autopilot:updated, handing every member the token GetAutopilot had just
  redacted for them. All five trigger fan-outs now carry a redacted copy; the
  HTTP caller still gets the live value.
- Agent identity itself was forgeable: Auth stripped only X-Actor-Source, so a
  member could replay an observable (agent_id, task_id) pair — both returned by
  GET /api/issues/{id}/task-runs — and be resolved as that agent. Auth and
  DaemonAuth now strip both headers, leaving the mat_ branch as the only writer
  of agent identity.

That last part is MUL-3428, reported and fixed by @CatCatUncle in #4313; it is
carried here because this change cannot be correct on top of a forgeable actor.

Behavior tightening, no migration: the only path it breaks is a member changing
an autopilot they hold no grant on. A run with no originator can no longer
write here.

Co-authored-by: CatCatUncle <[email protected]>
2026-09-07 16:46:07 +08:00
2aad9842a9 MUL-6891, MUL-7048: fix(agents): preserve presence for hidden private runtimes (#7830)
* fix(agents): preserve presence for hidden private runtimes

Co-authored-by: multica-agent <[email protected]>

* test: trim hidden-runtime regression fixtures

Co-authored-by: multica-agent <[email protected]>

* fix(agents): use coarse hidden runtime availability

Co-authored-by: multica-agent <[email protected]>

* fix(agents): refresh unstable availability

Co-authored-by: multica-agent <[email protected]>

* fix(agents): omit visible runtime availability

Co-authored-by: multica-agent <[email protected]>

* fix(agents): poll projected availability on all clients

Co-authored-by: multica-agent <[email protected]>

* fix(runtimes): stop availability polling once every projection is offline

Polling now runs at 30s only while at least one agent's projected
runtime_availability is online or unstable. A stable offline projection
stops polling; recovery is already covered by daemon:register and
reconnect invalidating the agents query.

Co-authored-by: multica-agent <[email protected]>

* fix(agents): publish heartbeat recovery refreshes

* fix(agents): keep projected availability polling

Co-authored-by: multica-agent <[email protected]>

* fix(agents): bound recovery and projection polling

Co-authored-by: multica-agent <[email protected]>

* fix(agents): stop polling stable offline projections

Co-authored-by: multica-agent <[email protected]>

* fix(agents): align projected presence signals

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: multica-agent <[email protected]>
Co-authored-by: worker-opencode <[email protected]>
Co-authored-by: Sol-Boy <[email protected]>
2026-09-04 15:39:38 +08:00
6a65922ef7 MUL-7016: Add read replica infrastructure (#7979)
* feat(server): add read replica infrastructure (MUL-7016)

Co-authored-by: multica-agent <[email protected]>

* fix(server): make replica fallback passive (MUL-7016)

Co-authored-by: multica-agent <[email protected]>

* fix(server): address replica routing nits (MUL-7016)

Co-authored-by: multica-agent <[email protected]>

* refactor(server): tighten replica routing API (MUL-7016)

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-03 18:54:53 +08:00
731af7ccd6 MUL-7002: push runtime deletion invalidation to active daemons (#7969)
* fix(runtime): invalidate deleted active runtimes

Co-authored-by: multica-agent <[email protected]>

* fix(runtime): separate invalidation delivery metrics

Co-authored-by: multica-agent <[email protected]>

* fix(runtime): complete invalidation metric separation

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-03 14:50:14 +08:00
Soda 0431218061 MUL-6833 fix(web): localize auth callback and error pages (#7752)
Localize the web auth callback, 404 page, and root error boundary, plus the shared section-level ErrorBoundary.

Also fixes several pre-existing auth-path bugs surfaced during review:
- access_denied was unreachable (the !code branch preceded the error-param check)
- UserLocaleSync's reload could replay a consumed OAuth code and show 'Login Failed' after a successful sign-in
- Google token-exchange and userinfo provider failures were misreported as user-actionable causes
- checkSignupAllowed reported 'registration is disabled' for an allowlist miss
2026-09-03 14:13:47 +08:00
Janez ŠtuparandJanez Stupar bfe32f5e39 fix(runtime): wake daemon for local skill requests (#7713)
Co-authored-by: Janez Stupar <[email protected]>
2026-08-31 15:31:29 +08:00
LinYushen 5fa65bd125 feat(billing): enforce Cloud issue count limits (#7619)
* feat(billing): enforce Cloud issue count limits

* fix(billing): address issue limit review feedback

* test(billing): exercise validation as manager

* fix(billing): resolve second review feedback

* fix(core): preserve entitlement limit discrimination
2026-08-27 14:23:09 +08:00
LinYushen 3b1018e9c9 Revert "MUL-6016: feat(task): retry exact Codex capacity failures (#6747)" (#7586)
This reverts commit 8fda4f22d5.
2026-08-26 18:05:17 +08:00
8fda4f22d5 MUL-6016: feat(task): retry exact Codex capacity failures (#6747)
* docs: record Codex capacity retry design

* docs: add Codex capacity retry implementation plan

* fix(taskfailure): recognize exact Codex capacity error

* feat(task): retry exact Codex capacity failures

* feat(server): configure Codex capacity retries

* docs: expose Codex capacity retry setting

* fix(task): align capacity retry comments

---------

Co-authored-by: cuijs <[email protected]>
Co-authored-by: Claude <[email protected]>
2026-08-26 12:50:20 +08:00
LinYushen baf1bbf340 fix(billing): make Cloud seat enforcement automatic (#7542)
Replace the separate Fleet/capacity URLs and seat-capacity switches with the single managed MULTICA_CLOUD_URL connection. Strict seat admission turns on whenever Multica is Cloud-connected and fails closed when the capacity machine token is absent or invalid; the recovery worker starts only for a valid executor so live intents keep their retry budget during credential outages.

Split invitation throttling into a non-consuming precheck and post-reservation consumption. Persistent capacity_full and capacity_overcommitted rejections charge only the actor budget; transient failures charge none. Preserve every Cloud/proxy HTTP 429 as retryable with both Retry-After forms, defer outbox rows without spending attempts, and use Cloud's rate-limit scope so a workspace 429 delays only that tenant while global or unscoped 429s stop the current batch.

BREAKING: delete/unset MULTICA_FLEET_URL, MULTICA_CLOUD_FLEET_URL, MULTICA_CLOUD_FLEET_TIMEOUT, MULTICA_SUBSCRIPTION_CAPACITY_ENABLED, MULTICA_SUBSCRIPTION_CAPACITY_URL and MULTICA_SUBSCRIPTION_CAPACITY_WORKER_ENABLED before rollout. The startup guard rejects any non-empty value, so MULTICA_SUBSCRIPTION_CAPACITY_ENABLED=false still blocks boot. Configure MULTICA_CLOUD_URL, MULTICA_CLOUD_TIMEOUT and MULTICA_SUBSCRIPTION_CAPACITY_SERVICE_TOKEN instead; entitlements keep their independent MULTICA_ENTITLEMENT_POLICY_* configuration.

Companion: multica-ai/multica-cloud#57
2026-08-26 12:42:42 +08:00
LinYushen 60a55ec57b feat(billing): purchase a seat from the invite flow (#7538)
* feat(billing): purchase seats from full invite flow

* fix(billing): harden full-capacity invite flow

* fix(billing): explain overcommit with cloud seat occupancy

Cloud refuses a reserve on used + reserved, but the overcommit toast filled
members_over_capacity_description with the product member count. The most
common trigger is a first ledger snapshot whose members alone still fit
inside the purchased seats, so the message read "3 members but only 3
purchased seats" and did not explain the rejection.

Add occupancy_over_capacity_description, which names occupied seats,
purchased seats, members and pending invitations, and feed it from the
Cloud summary. members_over_capacity_description stays as-is: the billing
tab banner is genuinely member-count driven.
2026-08-25 17:19:46 +08:00
da7131ab59 MUL-6625: enforce prepaid member seat capacity (#7491)
* MUL-6625: enforce prepaid member seat capacity

Co-authored-by: multica-agent <[email protected]>

* fix(seat-capacity): address review findings

Co-authored-by: multica-agent <[email protected]>

* fix(seat-capacity): close retry and worker races

Co-authored-by: multica-agent <[email protected]>

* fix(seat-capacity): bound lock waits during rollback

Co-authored-by: multica-agent <[email protected]>

* fix(seat-capacity): pin the cross-repo confirm settlement budget

Multica Cloud must wait longer than this worker's whole retry budget before it
treats a member row as one its ledger never recorded; adopting a member whose
confirm is still retrying counts one person as two seats, and an over-estimate
is what asks a customer to buy seats they do not owe.

That budget was only implicit here — 10 attempts of exponential backoff plus a
tick and a lock wait each — and Cloud's horizon had been set from a single
retry. Extract the backoff into retryBackoff, expose SettlementBudget, and
assert the worst case (29m15s) stays inside Cloud's 45m horizon, so raising
maxAttempts or the backoff cap fails review here instead of widening the window
in which Cloud cannot quantify a ledger.

Co-authored-by: multica-agent <[email protected]>

* Revert "fix(seat-capacity): pin the cross-repo confirm settlement budget"

This reverts commit d0ec36f647.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-25 12:51:44 +08:00
YYClaw 8c563b49be MUL-6555 feat(issues): preserve source context for sub-issues (#7461)
* feat(issues): preserve source context for sub-issues

* fix(issues): address source context review findings

* fix(migrations): guard source context rollback preflight
2026-08-25 12:00:49 +08:00
bedad9e222 MUL-6485: isolate hosted plugin surfaces (#7329)
* feat(plugins): host plugin artifacts, bind installs to immutable versions

A plugin used to be a URL. The manifest was frozen at install, but the surface
script was fetched from the author's server every time a panel opened, with no
integrity check — so an administrator consented to a manifest while the browser
ran whatever that host served that day, inside the scopes already granted. The
author's uptime was our uptime, every panel open leaked the reader's IP and
"who read which issue" to the author, and publishing at all required a public
HTTPS domain, which is why the repo's own deploy-sentinel example never ran.

The author now uploads an artifact bundle and Multica stores it:

- plugin_package / plugin_package_version / plugin_package_file. A version is
  insert-only, and the (package_id, version) unique index is what makes
  immutability a database rule rather than a convention.
- An installation names one version. Publishing another changes nothing for a
  workspace until an administrator upgrades, which is a second consent.
- The bundle is validated once, at publish: the manifest parses, every file it
  declares is present and loadable, and a surface entry with a top-level import
  is refused with the line named. That failure used to surface months later in
  a reader's browser.
- The host inlines the surface script into the document it generates, so the
  CSP names no remote script origin at all. `connect-src 'none'` now means it:
  a surface with no net: scope cannot reach anywhere, including its author.
- Install-by-URL is gone rather than kept alongside. Two paths would make "is
  this code frozen?" depend on how the plugin was installed.
- MULTICA_PLUGIN_DIR stays the development channel, but publishes an ordinary
  immutable version instead of being a second render path; re-publishing an
  unchanged version lands as `+dev.N`.

Hook endpoints and MCP servers remain on the author's own infrastructure.

Migration 376 drops existing installations: they name a source URL that is no
longer a concept, and the code they would run was never published here.
plugins_v1 has never left its feature flag and holds no production data — the
same call migration 344 made.

Closes MUL-6469

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): serialize publish/install/delete, correct the isolation claim

Review of #7321 found five problems. Four are fixed here; the fifth is real,
needs its own design pass, and is now tracked and described honestly in the code
instead of claimed away.

Migration prefix collision. main took 376 for agent_task_durable_work_dir while
this branch was open, so `migration prefix 376 is reused` failed backend-tests.
Rebased and renumbered the six migrations to 377-382.

TOCTOU between delete and install/publish. Relationships are application-owned
by repository policy, so nothing made "the version this installation names still
exists" true across statements: `delete counts zero installs` -> `install reads
the version` -> `delete commits` -> `install commits` left an installation whose
panel 404s forever, and a publish racing a delete left a version whose package
row was gone. Publish, install and delete now hold a (workspace, plugin key)
advisory lock for their whole transaction, and delete counts installations
inside it. Both interleavings have regression tests that reproduce the bug with
the lock removed.

Failed publishes moved package state. upsertPackage ran before the version
transaction, so a republish that lost the unique index had already renamed the
package, and a first publish that failed afterwards left a plugin with zero
versions. It now runs inside the same transaction.

The top-level import check was wrong in BOTH directions. It read line prefixes,
so it missed `  import x` and `/* c */ import x` — and it refused a valid file
whose template literal contained the word at a line start, which is ordinary in
a surface that renders code samples. A refusal blocks a publish with no way
around it, so that false positive was the worse half. Replaced with a scanner
that skips comments and string/template literals; where it is imprecise it is
imprecise safely (`/` always reads as division), so a mistake can only cost a
detection, never invent one.

Sandbox self-navigation is NOT fixed. `sandbox="allow-scripts"` permits `_self`
navigation by design, no shipped CSP directive covers it, and contentWindow is
unchanged across it — so a hostile artifact can still reach its author once and
hand the replacement document the bridge port. Closing it needs an embedder
frame-src policy or a handshake a navigated document cannot complete; both are
decisions of their own. What lands here is damage control, labelled as such: the
generated document beacons on pagehide from a listener the plugin cannot detach,
and the embedder drops the bridge and unmounts the frame. The "a surface cannot
reach its author" wording is removed from the SDK README and the tests.

Closes MUL-6469
Refs MUL-6485

Co-authored-by: multica-agent <[email protected]>

* feat(plugins): isolate hosted surfaces (MUL-6485)

Co-authored-by: multica-agent <[email protected]>

* feat(plugins): host plugin artifacts, bind installs to immutable versions

A plugin used to be a URL. The manifest was frozen at install, but the surface
script was fetched from the author's server every time a panel opened, with no
integrity check — so an administrator consented to a manifest while the browser
ran whatever that host served that day, inside the scopes already granted. The
author's uptime was our uptime, every panel open leaked the reader's IP and
"who read which issue" to the author, and publishing at all required a public
HTTPS domain, which is why the repo's own deploy-sentinel example never ran.

The author now uploads an artifact bundle and Multica stores it:

- plugin_package / plugin_package_version / plugin_package_file. A version is
  insert-only, and the (package_id, version) unique index is what makes
  immutability a database rule rather than a convention.
- An installation names one version. Publishing another changes nothing for a
  workspace until an administrator upgrades, which is a second consent.
- The bundle is validated once, at publish: the manifest parses, every file it
  declares is present and loadable, and a surface entry with a top-level import
  is refused with the line named. That failure used to surface months later in
  a reader's browser.
- The host inlines the surface script into the document it generates, so the
  CSP names no remote script origin at all. `connect-src 'none'` now means it:
  a surface with no net: scope cannot reach anywhere, including its author.
- Install-by-URL is gone rather than kept alongside. Two paths would make "is
  this code frozen?" depend on how the plugin was installed.
- MULTICA_PLUGIN_DIR stays the development channel, but publishes an ordinary
  immutable version instead of being a second render path; re-publishing an
  unchanged version lands as `+dev.N`.

Hook endpoints and MCP servers remain on the author's own infrastructure.

Migration 376 drops existing installations: they name a source URL that is no
longer a concept, and the code they would run was never published here.
plugins_v1 has never left its feature flag and holds no production data — the
same call migration 344 made.

Closes MUL-6469

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): serialize publish/install/delete, correct the isolation claim

Review of #7321 found five problems. Four are fixed here; the fifth is real,
needs its own design pass, and is now tracked and described honestly in the code
instead of claimed away.

Migration prefix collision. main took 376 for agent_task_durable_work_dir while
this branch was open, so `migration prefix 376 is reused` failed backend-tests.
Rebased and renumbered the six migrations to 377-382.

TOCTOU between delete and install/publish. Relationships are application-owned
by repository policy, so nothing made "the version this installation names still
exists" true across statements: `delete counts zero installs` -> `install reads
the version` -> `delete commits` -> `install commits` left an installation whose
panel 404s forever, and a publish racing a delete left a version whose package
row was gone. Publish, install and delete now hold a (workspace, plugin key)
advisory lock for their whole transaction, and delete counts installations
inside it. Both interleavings have regression tests that reproduce the bug with
the lock removed.

Failed publishes moved package state. upsertPackage ran before the version
transaction, so a republish that lost the unique index had already renamed the
package, and a first publish that failed afterwards left a plugin with zero
versions. It now runs inside the same transaction.

The top-level import check was wrong in BOTH directions. It read line prefixes,
so it missed `  import x` and `/* c */ import x` — and it refused a valid file
whose template literal contained the word at a line start, which is ordinary in
a surface that renders code samples. A refusal blocks a publish with no way
around it, so that false positive was the worse half. Replaced with a scanner
that skips comments and string/template literals; where it is imprecise it is
imprecise safely (`/` always reads as division), so a mistake can only cost a
detection, never invent one.

Sandbox self-navigation is NOT fixed. `sandbox="allow-scripts"` permits `_self`
navigation by design, no shipped CSP directive covers it, and contentWindow is
unchanged across it — so a hostile artifact can still reach its author once and
hand the replacement document the bridge port. Closing it needs an embedder
frame-src policy or a handshake a navigated document cannot complete; both are
decisions of their own. What lands here is damage control, labelled as such: the
generated document beacons on pagehide from a listener the plugin cannot detach,
and the embedder drops the bridge and unmounts the frame. The "a surface cannot
reach its author" wording is removed from the SDK README and the tests.

Closes MUL-6469
Refs MUL-6485

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): harden surface validation and error reporting

* fix(migrations): renumber plugin package migrations

* fix(plugins): validate classic surfaces and reset failures

* fix(plugins): refresh issue-scoped surface launches

Co-authored-by: multica-agent <[email protected]>

* feat(plugins): isolate hosted surfaces (MUL-6485)

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): refresh issue-scoped surface launches

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): make surface termination event-driven

Co-authored-by: multica-agent <[email protected]>

* feat(plugins): isolate hosted surfaces (MUL-6485)

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): refresh issue-scoped surface launches

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): make surface termination event-driven

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-23 13:47:06 +08:00
1dc135598e MUL-6343: Enforce recently created issue windows (#7305)
* MUL-6343: enforce recently created issue windows

Co-authored-by: multica-agent <[email protected]>

* fix(issues): address creation window review

Co-authored-by: multica-agent <[email protected]>

* fix(issues): bound table window ID cache

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-21 12:55:57 +08:00
67c6aa05c4 MUL-6166: feat(telegram): add Telegram channel integration (#6944)
* feat(telegram): add Telegram channel integration

Co-authored-by: multica-agent <[email protected]>

* chore(telegram): rebase onto main and renumber migrations to 366-367

349/350 collided with main's agent_task_queue_chat_terminal_resume_index
and agent_task_queue_chat_retired_session_index. Renumber the Telegram
origin pair to the next free prefixes, update the cross-references inside
the SQL comments, and point telegram_origin_migration_test.go at the new
filenames. Contributor-facing behavior is unchanged.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: multica-agent <[email protected]>
Co-authored-by: Bohan-J <[email protected]>
2026-08-19 23:04:26 +08:00
5d5fe64672 MUL-6343: Add semantic issue activity timestamps (#7193)
* feat: add semantic issue activity timestamps

Co-authored-by: multica-agent <[email protected]>

* fix(migrations): register issue activity index cleanup

Co-authored-by: multica-agent <[email protected]>

* fix(issues): address last activity review

Co-authored-by: multica-agent <[email protected]>

* fix(issues): close last activity review gaps

Co-authored-by: multica-agent <[email protected]>

* fix(issues): harden activity rollout safeguards

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-19 16:08:40 +08:00
24d8c521a6 MUL-6364: make the LLM client retry budget explicit, wired, and validated (#7201)
* feat(llm): make the LLM client retry budget explicit, wired, and validated

Config.MaxRetries was a bare int that nothing outside tests ever set. The
production wiring in handler.New never passed it and no MULTICA_LLM_* env var
fed it, so the field was configurable in name only and every deployment ran on
the SDK default.

Its semantics were also inverted. Because the zero value and an explicit 0 are
the same int, "0" fell into the unset branch and silently produced 2 retries,
while a negative — clamped to 0 to dodge the panic in option.WithMaxRetries —
was the only value that actually disabled retries.

- MaxRetries becomes *int so unset, 0 (disabled) and N (exact) stay distinct.
  New always passes the budget to the SDK explicitly, including the default, so
  the enforced policy and the reported one cannot drift apart.
- Add MULTICA_LLM_MAX_RETRIES as the single configuration source. Non-numeric,
  negative and above-ceiling values fail the boot instead of being corrected
  into something that looks configured. The ceiling of 5 is a latency budget:
  backoff reaches ~21s at 6 retries while the internal callers of this layer
  time out after 8s and 20s.
- Report the effective policy at startup via Client.RetryBudget, whose fields
  are counts and a fixed enum so the line cannot carry a key or gateway URL.
- Document the four states in .env.example and the environment-variable docs,
  and record what the budget does not cover: the GenerateJSON parameter
  negotiation and a stream that breaks after ChatStream returns.

Tests pin every state by upstream request count, so the unset-versus-zero
collapse cannot come back unnoticed.

Refs MUL-6364, closes #7154

Co-authored-by: multica-agent <[email protected]>

* fix(llm): reject invalid retry budgets at the client boundary and wire self-host

Review found the retry budget still reachable in two invalid shapes, and never
reachable at all on the main self-hosted deployment path.

- docker-compose.selfhost.yml declares the backend environment as an explicit
  allowlist and mapped none of the MULTICA_LLM_* variables, so every value
  .env.example and the docs told a self-hoster to set was dropped before the
  container: the LLM layer read as unconfigured no matter what they wrote. Map
  all four, and assert propagation in scripts/selfhost-config.test.sh, plus a
  drift guard so a future MULTICA_LLM_* documented without a mapping fails the
  script instead of silently going nowhere.
- Config.MaxRetries took a *int, so a negative still reached llm.New, which
  warned and used the default instead. Warning is not silence, but substituting
  a different budget is still the implicit correction #7154 asked to remove.
  It now takes *RetryOverride, whose field is unexported and whose only
  constructor rejects negatives — the invalid state is unrepresentable, so New
  has no correction branch left to take.
- The budget is a ceiling, not a quota: only retryable failures consume it, and
  a success or the caller's deadline can end a call sooner. Say "at most N"
  rather than "exactly N" in the field docs, .env.example and all four
  environment-variable docs.

The negative-value test now asserts rejection instead of pinning the fallback
as contract.

Refs MUL-6364, closes #7154

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-19 15:00:56 +08:00
YYClaw 4b8873df3a feat(issues): add revision-aware concurrency guards (#7038)
* feat(issues): add revision-aware concurrency guards

* fix(concurrency): decouple text edits from aggregate revisions
2026-08-19 14:06:59 +08:00
e3a40b0b8d feat(MUL-6243): custom issue status UI — picker, board, filters, settings (#7103)
* refactor(views): widen IssueStatus to open key, resolve category at render sites

Board columns, swimlane columns and hidden-column panels are CATEGORIES,
not status keys, so adding a custom status never adds a column. Retyped
those to IssueStatusCategory and made every presentation lookup
(STATUS_CONFIG, i18n labels, status icons) resolve the key to its
category first. Status filters stay in concrete keys and map back to
columns via the catalog.

MUL-6243

Co-authored-by: multica-agent <[email protected]>

* feat(views): catalog-driven status picker and per-status labels

The picker now offers the workspace catalog's active statuses grouped by
category, instead of the 7 hardcoded built-ins, and falls back to the
built-ins until the catalog lands so a cold render is unchanged.

Built-in names always resolve through i18n, never through the catalog's
server-seeded English name — otherwise every non-English workspace would
see 'In Progress' where it used to see '进行中'. Custom names come from
the catalog. That rule lives in one place: useStatusLabel.

Applied it to every surface that shows an issue's OWN status (detail
activity feed, table group headers and CSV export, hover card, inbox),
which previously showed the category label and so could not tell two
statuses in the same category apart.

MUL-6243

Co-authored-by: multica-agent <[email protected]>

* feat(views): show custom statuses on cards and in filters

Board columns and list sections are categories, so two issues in the
same column can sit on different statuses with nothing to tell them
apart. CustomStatusChip is that signal, and it renders nothing for a
built-in — a workspace with no custom statuses looks unchanged.

The status filter now lists the workspace catalog instead of the 7
hardcoded built-ins. Without this an issue moved onto a custom status
could not be narrowed to at all. Picker and filter share one
useStatusOptions hook so the two lists cannot drift apart.

MUL-6243

Co-authored-by: multica-agent <[email protected]>

* feat(settings): issue status catalog management with drag reorder

Owner/admin CRUD for custom statuses, organised by CATEGORY rather than
as one flat list. Category is not decoration here — it is the behavior a
status inherits, and picking it is the one moment an admin can, since it
is immutable afterwards. Built-ins are shown but locked: each is its
category's canonical definition.

Drag reorder writes intra-category positions starting at 1, because the
built-in is seeded at 0 and PATCH on it is a 403, so it always heads its
category. The optimistic cache write re-SORTS rather than only writing
positions, or the drag would visually undo itself until the refetch.

custom_issue_statuses is now published to the frontend, so the tab can
hide creation when the deployment has the rollout gate off instead of
offering a button that 403s.

MUL-6243

Co-authored-by: multica-agent <[email protected]>

* fix(issues): close the category contract end to end

Elon's review found the board only half-converted: the UI targeted
categories while the server still paged by concrete status key, so a
card on a custom status was dropped twice — it never came back from the
server, and the row filter would have discarded it if it had.

1. New `status_category` group kind (and compound secondary axis).
   Grouping rewrites the column with a static CASE built from the
   catalog; paging expands the category to concrete keys and stays on
   `status = ANY(...)`, so (workspace_id, status) is still usable. A
   workspace with no custom statuses gets exactly `i.status` back.
   Row filter, facet totals and swimlane cells all fold by category now.

2. Column visibility split from the status filter. Hiding a column used
   to write the other six built-in keys into statusFilters, so hiding
   Backlog silently dropped every CUSTOM status in other categories.
   Display state is now its own field. The saved-view baseline also
   stopped deleting custom status filters on reopen.

3. The board/list right-click menu reads the shared catalog options, so
   every entry point that can set a status offers the same set.

4. Remaining presentation consumers resolve a category instead of
   guessing: gantt bars were keyed by IssueStatus and went colorless on
   any custom key; hover card, sidebar, search, tab strip, inbox row,
   create toast and the activity feed all resolve explicitly now.

5. Reorder is one atomic statement behind a new endpoint, and the client
   submits ACTIVE rows only. The per-row PATCH loop half-committed when
   an archived row rejected mid-sequence.

Also: one catalog observer per card instead of three, and the stale
tasks/todo.md draft is gone.

MUL-6243

Co-authored-by: multica-agent <[email protected]>

* fix(issues): atomic reorder, category behavior sweep, rollout-safe grouping

Second review round.

1. Reorder is now one transaction holding the catalog's SHARED lock. The
   old shape validated ids, THEN ran the UPDATE — an archive committing
   in that window left the UPDATE silently skipping the archived row
   while the earlier rows were rewritten, and still returned 200. The
   authoritative active set is read under the lock and the payload must
   match it EXACTLY: a partial order would assign positions from the
   array index and collide with the rows it left out. Affected rows are
   checked against the payload before commit.

2. Swept the Web behavior consumers that still compared raw keys, so a
   custom category actually inherits behavior: gantt hide-completed,
   batch backlog parking, done/cancelled strikethrough, the mark-done
   button, sub-issue progress, search cancelled-demotion and mention
   demotion/dimming. issueBehavesAs is the one predicate they share, and
   it fails toward showing more and asking more. Two glyph sites (gantt
   row, swimlane lane) were still painting custom in_review as Todo.

3. Rollout safety. status_category is a contract this PR introduces, and
   the default-off creation flag does not guard the request path, so a
   new Web build would have 400'd against any un-upgraded pod — for every
   workspace. The client now sends it only once the catalog confirms the
   workspace HAS a custom status, which can only be true if the fleet
   already serves this version. Everyone else keeps the exact request
   they made before.

   That also uncovered a real defect: plain status grouping 500'd on a
   workspace holding custom statuses, because the descriptor rejected any
   non-built-in value and failed the whole response.

4. One catalog read per resolve instead of three, and the common path
   reads none at all. Pinned with observable scan-count tests rather than
   an assertion that the SQL string matches.

5. A custom-key filter no longer routes on a guess. categoryOf answers
   todo for anything unloaded, so a saved qa filter paged the todo column
   while the query still restricted status=qa — empty on every cold load,
   permanent if the catalog fetch failed. Unresolved custom keys now
   contribute no column until the catalog is authoritative; facet counts
   drop unknown keys rather than inflating todo.

MUL-6243

Co-authored-by: multica-agent <[email protected]>

* fix(issues): swimlane category axis, catalog tri-state, tests with teeth

Third review round.

1. Swimlane was only half-converted. The compound spec sent
   secondary=status_category unconditionally — the same rolling-deploy
   hole the board/list branches had — and the client row matcher then
   compared the raw key, so a qa row the server correctly folded into the
   in_review cell was thrown away again. Both now follow the same
   hasCustomStatuses switch, and the matcher knows which axis it is on.

2. The cold-load fix did not actually make the surface wait. Returning an
   empty column set is indistinguishable from "narrow to nothing", so the
   surface fetched nothing and rendered an empty board with no spinner;
   on catalog failure that was permanent, with no retry. Resolution is
   now pending / resolved / error: pending and error suspend fetching AND
   hold loading, error surfaces isStatusCatalogError. Fetching is
   suspended rather than left to fall back to all columns, which would
   briefly show the UNFILTERED board to someone opening a saved qa view.
   The group contract also joins the branch cursor identity, so a status
   cursor cannot ride into a status_category request.

3. Two tests were false-green and are replaced:
   - The reorder race archived BEFORE calling reorder, so the active-set
     check caught it even with the lock deleted. It now reproduces the
     real interleaving (reorder parked on the lock, archive commits, then
     reorder proceeds) and fails without the shared lock — with a 200,
     which is the actual hole.
   - The catalog read count read pg_stat_user_tables, whose counters flush
     asynchronously, and asserted delta <= 1 — vacuous at zero. It now
     counts through an injected querier and asserts exactly 1 read and 0
     category-expansion reads.

Also: plain status grouping 500'd on a workspace holding custom statuses
(the descriptor rejected non-built-in values and failed the whole
response) — that is the path the Table view and every client fallback
use. And the chat mention items dropped status_category, so a custom done
status in Current/Recent rendered as an active Todo.

MUL-6243

Co-authored-by: multica-agent <[email protected]>

* fix(issues): give the status catalog failure a real user path

Fourth review round. One group, both halves of it.

A failed status catalog suspends row fetching, because a custom status
filter cannot be routed to a column without it. That left isLoading and
isEmpty both false, so IssueSurface fell straight through to the content
branch and rendered an empty view with no explanation and no way out —
the controller's isStatusCatalogError had no consumer.

The catalog now carries its own `retry` (React Query's refetch) rather
than just a failure flag, the controller re-exposes it, and IssueSurface
renders an explicit error state ahead of loading/empty/content. Component
test covers the full loop: catalog rejects, error appears with no row
requests issued, clicking retry succeeds, fetching resumes.

The blocking condition is also narrowed. A BACKGROUND refetch can fail
while the last successful catalog is still cached, and error-before-loaded
meant that perfectly serviceable snapshot got a retry screen in front of
it. Only a failure with no data behind it blocks now; otherwise the last
successful catalog keeps resolving.

That rule lives in status-behavior.test.ts, where it can be asserted
directly. The component test asserts the wiring — it was originally
written to claim the rule too, but reverting the narrowing did not fail
it, so the claim moved to the layer that actually holds it.

MUL-6243

Co-authored-by: multica-agent <[email protected]>

* fix(issues): accept custom status keys in table filters.statuses

Filtering a board, list or table by a custom status 400'd the whole
request: `filters.statuses` carries the exact keys the user picked, and
the table query still validated that list against the 7 built-ins. Every
surface errored out instead of narrowing — the one thing the filter work
in this PR exists to do.

The predicate is a parameterized `status = ANY(...)`, so an unknown key
simply matches nothing; the check is now non-empty and length-bounded.

Found while writing the post-deploy verification steps, which is where
the gap became obvious: "filter by QA" was step one and it 400s.

MUL-6243

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-18 18:52:11 +08:00
2832f003ae [MUL-6269] Use Redis for channel WebSocket leases (#7055)
* feat: move channel websocket leases to Redis

Co-authored-by: multica-agent <[email protected]>

* fix: address Redis lease review feedback

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-17 14:55:48 +08:00
0a1357498a MUL-6247: rate limit workspace invitations (#7030)
* feat(workspaces): rate limit invitations (MUL-6247)

Co-authored-by: multica-agent <[email protected]>

* fix(workspaces): preserve invitation rate budgets

Co-authored-by: multica-agent <[email protected]>

* fix(workspaces): avoid partial limiter failures

Co-authored-by: multica-agent <[email protected]>

* fix(workspaces): relax recipient invitation limit

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-17 12:51:39 +08:00
4d495056e8 [MUL-6139] Expand Plugin skills and hosted MCP connections (#6920)
* feat(plugins): add remote MCP runtime

Co-authored-by: multica-agent <[email protected]>

* chore(plugins): add private Exa example

Co-authored-by: multica-agent <[email protected]>

* docs(plugins): remove private plugin guide

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): stabilize remote MCP tool digests

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): preserve remote MCP notification status

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): make Exa example runnable end to end

Co-authored-by: multica-agent <[email protected]>

* fix(codex): record MCP tool calls in agent log

Co-authored-by: multica-agent <[email protected]>

* chore(plugins): add community skills example

Co-authored-by: multica-agent <[email protected]>

* feat(plugins): support multi-file skill bundles

Co-authored-by: multica-agent <[email protected]>

* feat(plugins): add Mobbin MCP example

Co-authored-by: multica-agent <[email protected]>

* chore(plugins): bundle full Matt Pocock skill set

Co-authored-by: multica-agent <[email protected]>

* feat(plugins): add remote MCP OAuth connections

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): gate enable on remote MCP readiness

Co-authored-by: multica-agent <[email protected]>

* fix(plugin): authenticate remote mcp credential broker

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): harden remote mcp oauth flow

Co-authored-by: multica-agent <[email protected]>

* refactor(plugins): use a single feature flag

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): clean up remote MCP OAuth state

Co-authored-by: multica-agent <[email protected]>

* chore(plugins): remove Matt Pocock example

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-16 04:54:32 +08:00
d467cc9069 MUL-6075: complete Plugin V1 execution chain (#6853)
* feat(server): add Plugin V1 lifecycle foundation

Co-authored-by: multica-agent <[email protected]>

* feat(server): complete Plugin V1 execution chain

Co-authored-by: multica-agent <[email protected]>

* feat(web): add temporary plugin settings demo

Co-authored-by: multica-agent <[email protected]>

* refactor(plugin): keep foundation free of demo content

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-13 02:29:49 +08:00
5e569fa38a fix(workspace): derive new issue prefixes from the slug, editable at create (#6815)
Every workspace named only in Chinese (or Japanese, Korean, emoji…) was
created with the issue prefix "WS": the server derived it from the
workspace NAME by stripping non-ASCII letters, and a CJK name strips to
nothing. Not a collision risk — a certainty, for every such team, with no
way to change it in the create flow.

New workspaces now derive their default prefix from the slug, which the
same form already forces the user to pick in ASCII, and the onboarding
field is editable instead of a read-only preview. The client preview and
the server derivation are now the same algorithm, so the create screen
stops promising an identifier the user won't get.

Existing workspaces are untouched by design: no backfill, and the
read-time fallback for rows with an empty prefix keeps the old
name-based derivation (now legacyIssuePrefixFromName, frozen and
test-guarded). Identifiers are computed at read time, so moving that
path would have rewritten every historical issue identifier in those
workspaces.

Also validates issue_prefix as ^[A-Z0-9]{1,10}$ on create and update.
Settings guarded this client-side, but the API took anything — CJK text
or a 100-character string went straight into the column.

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-12 15:45:07 +08:00
b4692a192d MUL-6048 fix(github): withhold close intent when a PR identifier is ambiguous across workspaces (#6808)
* fix(github): withhold close intent when a PR identifier is ambiguous across workspaces

Issue prefixes are deliberately not globally unique (#2797) and issue
numbers restart at 1 in every workspace, so once #5183 fanned PR webhooks
out to every workspace bound to an installation, "Closes ABC-100" could
resolve to a genuine — but different — issue in each of them. Each
workspace then acted on it independently, silently advancing an issue in
a workspace that had nothing to do with the PR (#6804).

Resolve the collision before any workspace links: count how many bound
workspaces resolve each closing identifier, and where more than one does,
write the link rows but withhold close_intent, which keeps the
auto-advance gate shut. Links stay visible so a human can see and unlink
the wrong one; only the irreversible half is suppressed. The workspace
that legitimately owns the PR also loses auto-complete for that
identifier — deliberate, since nothing in the event says which one that
is. A warn log names the colliding identifier, because an issue moving to
done for no visible reason is otherwise untraceable back to GitHub.

The pre-pass only runs for closing identifiers on installations with more
than one binding, so the single-workspace case does no extra work.

Co-authored-by: multica-agent <[email protected]>

* fix(github): make the close-intent verdict authoritative and fail closed

Review found the ambiguity scan could fail open, and that its verdict was
not the same state the write path acted on.

Fail open: the scan reused helpers that fold read failures into permissive
defaults — workspaceAutoLinkPRsEnabled returns true when the settings blob
cannot be read or parsed, getIssuePrefix returns "" on a failed load, and
lookupIssueByIdentifier reports any query error as "no such issue". One
transient failure could count two real resolvers as one, and both
workspaces would then advance to done on merge — the exact outcome the
change exists to prevent.

Not authoritative: the scan produced a denylist, then mirrorPullRequest-
ForWorkspace independently re-read the toggle, the prefix and the issue and
decided close intent from those reads. Anything changing in between (an
admin enabling auto-link, a prefix edit, a same-numbered issue created)
reopened the hole.

Replace the denylist with closeIntentPolicy, an allowlist recording which
single workspace was proven to own each closing identifier. Absence denies,
so the zero value returned by every indeterminate read permits nothing, and
the mirror pass can only narrow the verdict, never grant what the scan
withheld. Recording the owner rather than a bare "allowed" also closes the
scan-to-write window: a workspace that grows a same-numbered issue in
between is not the recorded owner and still cannot act.

Surface the reads the scan depends on instead of swallowing them:
autoLinkPRsEnabledForWorkspace now returns a parse error, and the scan
distinguishes pgx.ErrNoRows (a proven miss) from an error it cannot
interpret. Existing callers keep the permissive default. Extract
issuePrefixForWorkspace and issueNumberForPrefix so the scan derives both
from the row it already holds, which also drops a redundant workspace read
per binding.

Tests: a table test pinning that the policy denies by default, an
integration test that an unreadable bound workspace withholds close intent
(fails with the old fail-open behavior), and one that a proven unique
resolver among several bindings still auto-completes, so the fix cannot
over-suppress.

Co-authored-by: multica-agent <[email protected]>

* docs(handler): split the merged getIssuePrefix / issuePrefixForWorkspace doc comments

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-12 15:24:59 +08:00
Xichang(Seacen) Zhao b904e6b71c MUL-6029: docs(daemon): correct two comments #6604 made false, and enforce the Slack/Lark brief policy (#6771)
Three follow-ups to #6604: two comments the merged diff made false, and one policy the tests stated but did not enforce.

- `handler.go` said the brief turns `chat_channel_delivers_files` into upload-or-describe copy. After #6604 the per-turn prompt does, and the brief states no verdict at all — it is the prompt cache prefix, and a per-turn verdict there renders two briefs (MUL-5377).
- `outbound_media.go` cited `TestDeliverAttachments_AdmissionBoundsTheLookupStage` for a sentence about turns carrying no file; that test's turns all carry one. The paragraph now states both ways admission is held without a pending slot and cites the one the test drives.
- `TestBriefSurfaceDeliveryPolicy`'s `chat_slack` and `chat_feishu` rows denied two phrases where the WeCom rows deny four, so a denial worded around "does NOT apply" passed. Both rows now carry the same four.

Review follow-up (tests only): `TestBriefChannelDeliveryCopyIsPlatformNeutral` holds the same policy structurally — with the display name substituted out, every channel-backed brief must be byte-identical, so a per-platform position fails whatever wording it uses. Slack/Lark `mustHave` reaches parity with WeCom, `mustNot` failures quote the real match instead of a section it may not be in, and the admission test asserts `pending == 0` rather than implying it.

No behaviour change: two comments and test coverage.
2026-08-12 01:26:03 +08:00
Xichang(Seacen) Zhao 90eadd940a MUL-5905: feat(wecom): send the files an agent produced into the chat (#6604)
* feat(wecom): send the files an agent produced into the chat

An agent that produced a file and answered in WeCom delivered only the
sentence. `multica attachment upload` had already run, the object was in
storage, and CompleteTask had bound the row to the assistant message — but
nothing downstream of that bind knew how to reach a WeCom conversation, so
the file stopped there. The user read "here is the forecast" and got no
forecast, with no error anywhere to explain it.

Worse when the agent said nothing at all: an empty completion with a file
bound to it returned early, so the artifact was thrown away silently.

This adds the last hop. media_upload.go carries a file up the long
connection — WeCom has no REST endpoint for this, so it goes as
aibot_upload_media_init / _chunk / _finish over the same socket, and all
three read their verdict through the request() layer #6590 generalized.
outbound_media.go is the caller: after the answer is out, it reads the bound
attachment out of object storage, decides what to call it, and pushes it in
as its own message.

Three decisions worth naming.

The answer goes first and the file never blocks it. An upload is megabytes
and round trips and it can fail; delivery runs after the words are out, on
its own goroutine and a budget of its own, and its worst outcome is one
extra line saying a file did not make it.

A lost ack and a refusal are opposite failures. A chunk whose verdict never
came is offered once more — the protocol makes re-sending idempotent, and
losing one slice of a hundred must not cost the file. A chunk the server
refused is not, because the refusal is its answer and will be its answer
again. The push of the finished media follows the same rule in reverse: a
timeout is reported as-is rather than retried, because the same media_id
sent twice puts the file on the user's screen twice with nothing to undo.

WeCom validates bytes against the msgtype, so what to call a file is a
decision. A kind past its ceiling is demoted to a plain file rather than
sent and refused — a card the user can open beats a photo the server
rejected.

WithAttachments is what turns this on, and router.go passes it only when the
deployment configured object storage. Without it the adapter behaves exactly
as it did before.

* fix(daemon): stop telling a WeCom agent its conversation is text-only

The per-turn chat prompt and the runtime brief both decided file delivery by
asking "is there a channel at all". Any non-empty ChatChannelType got the
text-only paragraph: `attachment upload` does not apply here, describe the
file in words, never write its local path. That was true of every IM channel
when it was written, and it stopped being true for WeCom, whose adapter now
reads the bound attachment out of object storage and sends it into the room.

So an agent on WeCom was instructed not to use the one command that would
have delivered its file. The capability shipped and the agent was told it
did not exist.

The delivery question is per-adapter, not "is there a channel", and
ChannelCarriesFiles is where that answer now lives. Both call sites branch
three ways instead of two.

The new copy is deliberately built from main's existing web-chat branch
rather than written fresh. Both open on the same clause — "To include a file
or image you produced in your reply, run `multica attachment upload
<local-path>`" — and diverge exactly where the platforms diverge: web chat
says the file "appears as an attachment card below it" and offers a markdown
snippet to place it inline; WeCom says Multica "sends it into the WeCom
conversation as a separate message right after your text" and that "there is
no way to place it inline". That last clause is the one worth having. An
agent told only that files work here writes "see the chart below" and then
nothing appears below, because the long connection has no msg_item and a
picture cannot be embedded in the reply. Slack and Lark keep their existing
text-only wording untouched.

Two comments are rewritten rather than carried over, because they asserted
the binary the same commit falsifies — "keys off 'is there a channel at
all'" in prompt.go, and "any non-empty channel type means the reply leaves
Multica for an external IM platform, where `attachment upload` has nothing
to bind to" in the brief. A comment that contradicts the code beneath it is
worse than none.

ChannelCarriesFiles names every channel type this package knows rather than
leaving Slack and Lark to the default, so a fourth adapter surfaces here as
a decision to make. An unrecognized type answers false: the agent is told to
describe its file in words, and the worst case is a deliverable that could
have been sent was not. False the other way would have it promise a delivery
nothing performs.

* fix(wecom): stop claiming more certainty than the system has

Three review blockers, one shape: a promise made in one place that another
place cannot keep.

Capability came from the channel type. The daemon answered "this conversation
takes files" from `chat_channel_type == "wecom"`, but the last hop only exists
where the server wired object storage — so a WeCom deployment without storage
had its agents told to send files nothing would carry, and a new daemon on an
older server inferred a capability that build has no code for. The server now
answers it per turn on the claim (`chat_channel_delivers_files`), declared by
the same `if store != nil` branch that gives the adapter the storage, and
absent/false everywhere else.

Upload limits came from the framing. 50 MB is what 100 chunks of 512 KB can
describe; WeCom's own OpenClaw plugin puts the hard file ceiling at 20 MiB, so
that is now the cap, enforced against the attachment's recorded size before any
byte is read. Concurrency was a fixed 4 regardless of size; WeCom's own SDK
steps it down — all at once up to 4 chunks, 3 up to 10, 2 past that, because
their backend answers a burst of chunks on a large upload with a system error.

Failure handling had two words for three outcomes. A send whose ack never came
was reported as a definite failure although the file may well be in the chat,
and the local failures — a full pending queue, a slot wait that expired, a
lookup that errored — were reported to nobody at all. There are now three
states: delivered says nothing, definitely_failed keeps the definite wording,
and unknown gets wording that holds either way and is never resent, since a
duplicate file cannot be taken back. The attachment lookup moved ahead of the
rationing so a shed delivery knows there was a file to apologise for, and a turn
with nothing attached still says nothing.

* fix(wecom): bound the attachment lookup, and stop calling an attempted write a failure

Two gates, not one better place for one gate. The pending counter is claimed
after the lookup so a shed can name a real file, which is also why it cannot
bound the lookup: at the point it would have to be claimed, whether the turn
carries a file is exactly what is not yet known.

admitAttachmentDelivery is claimed before the spawn and bounds the goroutines
and the queries they run. Its cap is twice the pending cap, so a backlog meets
the honest post-lookup shed first and only reaches the silent one when the
system is already well past it. A refusal there is logged and not spoken —
telling a user their file was dropped on a turn that may have carried none is
the false alarm the post-lookup gate exists to prevent.

Separately, a WebSocket write error stopped being read as proof of
non-delivery. wsSender.write already distinguished the stages for tracing; it
now carries that out in the error. Past WriteMessage the frame may be at the
peer — a half-closed connection reports "broken pipe" to the writer for bytes
the reader has — so those classify as unknown and are never resent. What fails
before the write is provably local and stays definite.

Tests drive the real path rather than the counters: one blocks the lookup,
submits past the cap, and counts the goroutines and queries that actually
happen; one hands the frame to a connection that records it and then fails.
Both fail without the change. fakeObjectStore takes a lock, because it is no
longer only ever driven inline.

* docs(wecom): cite the size limits to WeCom's docs, not a third-party constant

The upload ceilings were sourced to Tencent's OpenClaw plugin. That file
carries the right numbers but cites nothing itself, so it corroborates rather
than establishes them. WeCom documents all four on aibot_upload_media_init's
body.total_size row, and repeats them on the classic media upload API. Point
at those.

* fix(wecom): drop the two media helpers #6605 brought in ahead of this branch

This branch carried its own cleanShort filename and extension helpers because
nothing else in the package had them when it was written. #6605 has since
merged, and it brings both — so the package now declares each twice and does
not compile against current main. GitHub still reported the branch mergeable
and CI was green, because both were measured against a main that predated that
merge by six hours.

Upstream's copies are the ones to keep: its cleanMediaFilename also strips
control characters out of a name, and its mediaExtension drops a charset
parameter before matching. Neither of those was in the copies here.

* fix(daemon): keep the file-delivery verdict out of the cached brief

chat_channel_delivers_files arrives on every claim and answers a deployment
question: does this server carry a file the last hop into the room. Upgrading a
server so it starts sending the field, or turning object storage on or off,
flips it under a chat session that is already running — and the brief branched
on it, so one resumed session rendered two different briefs. The brief is the
prompt-cache prefix (MUL-5377), which makes that a miss on every turn after the
flip. The two rows in the delivery test asserting different briefs for the two
verdicts were the exposure written down: they are what one session sees in
sequence.

The brief's channel-backed chat copy now names the platform and says the answer
is stated per turn. It holds on either verdict and contradicts neither. The
verdict has a home already — the per-turn chat prompt, whose branch was correct
and is untouched — and that message is emitted on every turn, so nothing is
lost by the brief declining to answer.

Slack and Lark take the same copy. Scoping it to WeCom would put a channel-type
decision back into the brief, which is what ChannelCarriesFiles exists to
prevent, and would hardcode a denial a server-side change can falsify.

Coverage moves rather than goes. TestBriefByteIdenticalAcrossRunsForEveryKind
gains a WeCom kind and a variant that flips the field; both fail without this
change, and the existing Slack kind fails on the variant too. The delivery
test's two WeCom rows assert the inverse property now — one brief for both
verdicts — and the upload/describe split they used to hold is pinned by
TestBuildChatPromptTwoLayerChannelPolicy on the per-turn prompt, which has
covered both branches all along.

* docs(wecom): correct what the admitted cap implies, and note that delivery cannot answer back

The comment on the two counters said reaching the admitted cap means the
pending cap is full and as many turns again are still in their lookup. It does
not. Admission is held for a goroutine's whole life, including turns whose
lookup finds no file and which therefore never claim a pending slot —
TestDeliverAttachments_AdmissionBoundsTheLookupStage fills admission with
pending at zero. The two-counter split and the ordering it buys are unchanged:
a backlog of file-carrying turns still meets the honest post-lookup shed first.

The package doc gains the fact that an agent cannot learn its delivery was
refused. `multica attachment upload` returns once the object is in storage and
bound to the reply, while the send into the room runs on EventChatDone, after
the run has ended, so everything the adapter knows about a failed delivery goes
to the person in the chat and to nobody else.
2026-08-11 19:01:34 +08:00
0db0055964 fix(wecom): stop blaming an admin's credentials when the fault is ours (#6589)
* fix(wecom): stop blaming an admin's credentials when the fault is ours

RegisterWecomBYO's default branch answered 400 with "check the Bot ID and
secret from the WeCom admin console". Every failure that is not one of the
three ownership conflicts lands there — a Postgres blip, a bad encryption key,
a bug of ours — and all of them tell the admin their credentials are wrong.

That is not a cosmetic mistake. It sends them to the WeCom console to rotate a
secret that was fine, and a rotated WeCom long-connection secret cannot be
recovered: the console shows it once. A thirty-second database failover costs
them a working bot.

Split by whose fault it is. validateInstallationParams now wraps
ErrInvalidInstallationParams, so a missing field is identifiable as the
caller's to fix and keeps its 400. Everything else falls to a 500 that says so
plainly and states that the credentials were not changed, logged at Error with
the workspace and agent rather than Warn.

Each branch also carries a stable code next to the English sentence, through a
new writeErrorCode helper. The settings tab reads it (errorCode, alongside the
existing dispatchReasonCode) and renders its own localized string, so a
zh-Hans admin stops being toasted English. The sentence remains the fallback
for a server that sends no code.

Rebased onto main, which brought #6594's credential probe into the same
switch. The two are complementary and both are kept whole: #6594 decides
whether WeCom refused the pair (ErrCredentialsRejected) or could not be
reached at all (ErrCredentialsUnverifiable); this change decides what the
admin is told about each. Both now carry their own stable code —
wecom_credentials_rejected (400) and wecom_credentials_unverifiable (503) —
rather than folding into wecom_install_rejected, which means "you left a field
out" and must keep meaning only that: the three call for three different
actions from the admin.

The full matrix is now one named function, writeWecomInstallError, instead of
a switch inline in the handler:

  bot already owned (same ws / archived agent / other ws) → 409
  required field missing                                  → 400
  WeCom refused the credentials                           → 400
  WeCom unreachable, credentials unverified               → 503
  anything else (DB, encryption key, a bug)               → 500

Seven new keys in en/ja/ko/zh-Hans, key sets verified identical across all
four at 45 keys each.

Tests. At the handler level, a table-driven test pins HTTP status + error +
code for all seven outcomes, plus a wrapped sentinel and a second test that
every outcome's code is distinct — the layer where a bad merge lands and where
nothing was asserting before, since the wecom-package test only proves
sentinel matching and the frontend test only mocks the code. Dropping either
credential arm fails the matrix test; giving two outcomes one code fails the
distinctness test even though both are 400 and the status assertions still
pass. In the wecom package, every missing field is identifiable as a caller
error and names itself, complete params pass, and an infrastructure error does
NOT match the sentinel. Four frontend tests cover the localized render, both
new codes, and the no-code fallback.

* test(wecom): pin each install outcome's own fallback sentence

The matrix test's header said it pinned status + error + code, but the
table carried no expected sentence and the assertion was only that one
was non-empty. That leaves the case it was written to catch: a resolution
that keeps every status and code correct and hands one outcome its
neighbour's sentence still passes, and a client too old to know the codes
then shows the admin the wrong next action — "check your credentials" for
an outage, or "wait and retry" for a pair WeCom actually refused.

The sentence is a contract with those clients as much as the status is,
so the table now carries it and the test compares it exactly. Verified by
crossing the rejected/unverifiable sentences in writeWecomInstallError:
before, green; now, three rows fail with the status and code still right.

Co-authored-by: multica-agent <[email protected]>

* docs(wecom): un-orphan the validateInstallationParams comment, drop the stale 500 claim

Two problems in the same block. ErrInvalidInstallationParams was declared
between validateInstallationParams's doc comment and the function, so
godoc read "validateInstallationParams is a lightweight pre-write check"
as documentation of the error value and left the function undocumented.

And the text said everything that is not one of these belongs on a 500,
which stopped being true when #6594 added ErrCredentialsRejected (400) and
ErrCredentialsUnverifiable (503). Left as-is it invites the next reader to
collapse exactly the distinctions this change spent its effort drawing.

Comment split back onto what each one documents, and the classification
now names all three outcomes and points at writeWecomInstallError, which
holds the matrix. No behaviour change.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-08 19:20:38 +08:00
Xichang(Seacen) Zhao 02d06229db fix(wecom): prove control of the bot before the install reclaim destroys somebody’s row (MUL-5891) (#6594)
* fix(wecom): prove control of the bot before the install reclaim destroys somebody's row

idx_channel_installation_type_appid is UNIQUE on
(channel_type, config->>'app_id') with NO workspace in it, so a bot id's
routing slot is global. ReclaimDeadChannelInstallationByAppID then hard-deletes
the row holding that slot along with every channel_user_binding,
channel_chat_session_binding and pending binding token beneath it.

Nothing verified that the caller has anything to do with that bot. Bot ids are
not secret — every member using the bot can see one — so an admin of ANY
workspace could type a known bot id with a junk secret and destroy a revoked
owner's installation and all of its bindings.

The reclaim query's own comment states the premise this restores: "workspace B,
which proves control by holding the same app credentials, rebinds." That
sentence describes a check that did not exist.

Upsert now probes first: one connection, one subscribe, no read loop, no
registration, nothing else in the process learns it happened. A rejection
(ErrCredentialsRejected) is the one answer entitled to blame the admin's
credentials. Everything else is ErrCredentialsUnverifiable and answers 503 —
reporting an unreachable WeCom as a wrong secret sends the admin to rotate one
that was fine, and a rotated WeCom secret cannot be recovered.

A nil probe keeps the old behaviour, so a deployment that cannot reach WeCom
at install time can still install. DingTalk already validates credentials at
install; WeCom was the outlier.

Four tests. Removing the probe fails two of them with the cost stated:
"Upsert reached the write path with an unproven credential — the reclaim would
have destroyed the current holder's installation and its bindings".

* fix(wecom): decide who owns the bot before the probe touches WeCom

Review follow-up on the install control check.

The probe is not a read. It subscribes, and WeCom allows exactly one live
subscriber per bot, so subscribing displaces whoever is connected. Firing it at
the top of Upsert meant a request that was about to be REFUSED still knocked
the rightful owner offline first: their supervisor reconnects, the caller
repeats the request, and an install nobody was permitted to make becomes a
denial of service against a live bot. The active / archived / other-workspace
conflicts were only detected further down, at the unique index.

Upsert now runs the whole sequence in one transaction, in order: take a
transaction-level advisory lock on (channel_type, bot_id), read the current
owner inside it, refuse every live owner other than the caller's own row, and
only then probe, reclaim and write. A refused install reaches WeCom zero times
and leaves the owner's row byte-for-byte where it was. The lock closes the
TOCTOU window a pre-read outside the transaction would leave open: a racing
install parks on the lock instead of reading a stale "nobody owns this".

Two supporting changes:

Only the codes WeCom documents as a refusal of the pair (40001 invalid secret,
40013 invalid corpid) are reported to the admin as a wrong credential.
Everything else non-zero — 45009/45033 throttling, platform failures, codes
added since — fails closed as unverifiable, with the raw code and message
logged. WeCom guarantees nothing about non-zero codes except that they are not
success, and telling an admin to rotate a long-connection secret that was fine
destroys it: it cannot be recovered. Codes only, never errmsg text.

Proof of control no longer has an off switch. NewInstallationService defaults
to the real handshake probe and returns ErrProbeRequired on an explicit nil,
and Upsert refuses to run without one, so no wiring mistake can reopen the
destruction path. Tests inject a fake explicitly.

New sqlc queries: LockChannelInstallationAppIDSlot (the serialization boundary)
and GetChannelInstallationSlotOwnerByAppID (LEFT JOINs so an orphan row
survives the read and stays distinguishable from a live owner).

Rebased onto main.
2026-08-08 11:07:07 +08:00
Xichang(Seacen) Zhao eb59e24010 fix(wecom): cap the request bodies both WeCom JSON endpoints read (#6586) 2026-08-08 02:01:43 +08:00
leroy-chenandseacen 040bb97b0f feat(wecom): WeCom (企业微信) smart-bot integration via the Channel engine (MUL-5226) (#5833)
Adds the WeCom (企业微信) smart-bot integration built on the unified Channel engine — inbound chat, /issue, inbox notifications, install/revoke UI and an explicit user-binding flow.

Co-authored-by: seacen <[email protected]>

Follow-up: #6547
2026-08-07 11:02:13 +08:00
YYClaw c3577cb04b feat(dingtalk): add DingTalk bot integration (MUL-3958) (#4829)
Adds a DingTalk (钉钉) bot integration on the bring-your-own-app model: a
workspace admin creates their own Stream-mode robot and pastes its AppKey /
AppSecret, so no public webhook or OAuth redirect is required. Each agent gets
its own bot identity, so several agents can be distinct, separately
@-mentionable contacts in one DingTalk organization.

Supports DMs, @-mentions in groups, inbound images, /issue quick-create, and
/new. Built on the shared channel engine (ForceFresh/BareFresh, MediaResolver /
MediaRef and the intent ledger) rather than a private implementation.

Off unless MULTICA_DINGTALK_SECRET_KEY is set. Docs in en/zh/ja/ko.

Closes #4791.

Community-maintained: @yyclaw is the code owner for
server/internal/integrations/dingtalk/.
2026-08-06 15:36:02 +08:00
f13969b996 refactor(chat): generate quick actions server-side via the LLM layer (MUL-5573) (#6214)
* refactor(chat): generate quick actions server-side via the LLM layer (MUL-5573)

Follow-up suggestions were produced by a second, full provider CLI invocation
per chat turn: the daemon resumed the just-finished session and ran a
suggestion-only pass. That pass inherited the main turn's exec options, so its
20s budget had to cover process spawn, every MCP handshake, session replay, and
model reasoning at the agent's own thinking level — typically 8-15s of visible
skeleton, and every turn paid two provider cold starts.

Generate them here instead, through the same pkg/llm layer that backs chat
auto-titling. Suggestions need no tools, workdir, or agent identity — only the
tail of the conversation — so a bounded 8s call on the deployment's small model
replaces the whole resumed turn.

Quality changes that came with the move:

  - The prompt now states the frame explicitly ("you write FOR THE USER"). The
    old pass ran inside the agent's session and inherited the runtime brief's
    identity, which drifted suggestions toward agent-operations actions.
  - Previously-offered labels are replayed as ALREADY SUGGESTED. The old
    architecture had the opposite effect: on providers that append on resume,
    each pass saw its predecessor's JSON and anchored on it.
  - A failed generation broadcasts failed=true. Before, a timeout delivered an
    empty array — indistinguishable from "nothing worth suggesting", so every
    slow pass read as a quality problem.
  - The in-band footer is still stripped from replies but its actions are now
    discarded, so a pre-upgrade session is not pinned to the retired
    suggestions with the replacing pass suppressed.

The refresh path no longer enqueues an agent task: it validates the target and
calls the same generator, which also drops the not-resumable refusal — a session
whose runtime was rebound can now be refreshed. Client contract is unchanged
(chat:done pending flag, chat:quick_actions supplement); the only frontend
change is the pending window, resized from 30s to 12s to match the new budget.

Also removes the daemon's TMPDIR-after-cleanup hazard by construction: the old
pass started after runTask's defers had already deleted the temp dir it was
still pointed at.

Co-authored-by: multica-agent <[email protected]>

* refactor(chat): drop the quick-actions opt-out setting (MUL-5573)

Suggestions are always on. The Settings → Chat toggle is removed along with
the whole per-turn opt-out path it fed: the persisted client preference, the
quick_actions_enabled send field, the quick_actions_disabled task stamp, and
the eligibility gate that read it.

The toggle predates server-side generation, when it could only hide chips a
provider pass had already paid for. Now that generation is a bounded call the
server decides on, an off switch buys nothing a user would miss, and it was
the last piece of UI implying the feature might be unavailable.

agent_task_queue.quick_actions_disabled is no longer written (dropped from
CreateChatTask's INSERT; the column keeps its false default). Left in place
alongside regenerate_quick_actions_for for a later drop migration — removing
columns an already-running binary still inserts would break mid-deploy.

Co-authored-by: multica-agent <[email protected]>

* fix(chat): address quick-actions review findings (MUL-5573)

Four defects from review of the server-side generation change.

1. Automatic failures were reported as refresh failures. The generator
   broadcast failed=true on any LLM error, but the client turns every
   failed=true into a "couldn't refresh" toast — so an automatic timeout
   popped a toast for an action the user never took. This also contradicted
   ChatQuickActionsPayload.Failed, which documents false for the automatic
   pass. The caller now passes its origin; only an explicit refresh reports.

2. Generation context was not bound to the target turn. The pass re-read the
   session's newest messages while always writing to the task it was handed,
   so a turn landing between the completion callback and the detached read
   supplied the context for a reply it did not belong to. Worse, a user
   typing a follow-up in the second after a reply left the window ending on
   a user row, which the old code treated as "nothing to build on" — that
   turn silently never got pills. The window is now anchored on the target
   assistant message and queried strictly before it.

3. No concurrency or idempotency bound on generation. Refresh stopped
   creating a task, so the busy check could not see a pass already running:
   two refreshes both returned 202, spent two upstream calls, and raced to
   write one row. Nothing bounded generation process-wide either. Adds a
   per-session in-flight guard (refresh now 409s on a duplicate) and a
   process-wide ceiling; a shed pass still resolves the client placeholder
   so no skeleton hangs on work that never started.

4. A new daemon could not safely talk to an older server. The refresh task
   discriminator was deleted, so a regenerate task from such a server fell
   through to the ordinary chat path: no user message, but the agent would
   answer anyway and the server would persist it as a real reply. The field
   is restored as a refusal marker only — the task completes empty, which is
   the shape the retired pass produced and which that server writes no row
   for. Not a restored execution path.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-07-31 15:59:50 +08:00
577018649e feat(issues): support human-readable issue URLs using issue keys (MUL-5354) (#6117)
* feat(issues): support human-readable issue URLs using issue keys (MUL-5354)

Closes #5987. `/{ws}/issues/MUL-123` now opens the issue, the copy-link
action shares that form, and a UUID URL rewrites itself to it. Existing
UUID links keep working.

Backend already resolved identifiers on `GET /api/issues/{id}`, but it
compared the number only — every prefix with the right number opened the
same issue, so no identifier URL could be canonical. Resolution now
validates the prefix against the workspace's own (case-insensitively,
matching `lookupIssueByIdentifier`), and the number parser bails on
int32 overflow instead of truncating a digits-only UUID group into a
plausible issue number.

On the client the identifier stays a presentation concern: the route
resolves it to the UUID before rendering, because the realtime updaters
patch `issueKeys.detail(wsId, issue.id)` with the UUID from the
websocket payload. A view keyed on the identifier would sit on a cache
entry no realtime event can reach and silently stop updating. Resolution
reuses the request the detail view would have made anyway and seeds the
UUID-keyed entry, so an identifier URL costs no extra round trip. The
desktop tab title/status glyph hops through the same resolution for the
same reason.

The URL rewrite lives in the new route wrapper rather than IssueDetail:
the inbox renders IssueDetail in a side panel, where replacing the URL
would navigate the user out of the inbox.

No migration — `issue (workspace_id, number)` is already unique/indexed.

Co-authored-by: multica-agent <[email protected]>

* refactor(issues): make the single-request guarantee for identifier URLs explicit

Review flagged that opening `/{ws}/issues/MUL-123` fires two detail
requests. It does not, under the app's own QueryClient — but the
guarantee was resting on something implicit, so make it structural.

The old shape seeded the UUID-keyed entry from a `useEffect` after
resolution, while the route enabled the UUID query in the same render.
That held only because the seed effect happened to be declared before
the UUID query's own effect, and because `createQueryClient` sets
`staleTime: Infinity` so a seeded entry is never refetched. Neither is
obvious from the code, and a diagnostic run under a bare `new
QueryClient()` (staleTime 0) does show two calls — the second being a
staleness refetch of an already-seeded entry, i.e. a harness artifact.

`useCanonicalIssueId` becomes `useCanonicalIssue`, which owns both the
resolution query and the canonical detail query and hands the resolution
response to the latter as `initialData`. That is applied while the
observer is created, so the canonical query never observes an empty
cache and never starts a fetch of its own — no dependency on effect
ordering, and no cache write that could race a realtime patch
(`initialData` is ignored once the entry holds data).

Callers collapse to one hook each: the route no longer runs its own
detail query, and the desktop page drops its duplicate.

Tests now build the client with `createQueryClient()` rather than a bare
`new QueryClient()`, so request-count assertions measure production
behavior instead of the harness, plus a direct assertion that an
identifier URL costs exactly one request.

Co-authored-by: multica-agent <[email protected]>

* fix(issues): stop the request loop when an identifier names no issue

Opening `/{ws}/issues/ZZZ-134` never reached "not found". It spun an
unbounded request loop and left the UI on the loading skeleton forever.

The route treated a failed resolution as "nothing resolved" and handed
the raw identifier down to IssueDetail. IssueDetail mounted a second
observer on the query that had just failed; `retryOnMount` refetched it,
which flipped the resolve hook back to pending, which unmounted
IssueDetail, which remounted it when the refetch failed — and around
again. Measured with retry disabled to isolate it: 8,192 requests at
300ms, 32,768 at 600ms. Under the app's `retry: 1` the backoff only
paces the loop, it still never converges.

`useCanonicalIssue` now reports a terminal `notFound` read from the
resolution query's own error state, rather than leaving callers to infer
failure from "not resolving and no id" — an inference that cannot
distinguish failed from in-flight. `IssueDetailRoute` renders the
not-found UI itself and never hands an unresolved segment to a view that
would query it again, so no second observer exists to restart the cycle.
Same measurement after the fix: 1 request, settled, "not found" on
screen.

The not-found UI moves out of IssueDetail into a shared `IssueNotFound`
so both render the identical state.

Regression tests at both levels, with retry off so any count above 1 can
only be a remount refetch: the hook settles a failed resolution without
looping, and the real IssueDetailRoute holds at one request across
waits and rerenders. Both fail against the previous code.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-07-30 14:22:13 +08:00
c25a82eee0 perf(agents): fast model discovery on runtime switch (MUL-5444) (#6098)
* perf(agents): make runtime model discovery fast on runtime switch (MUL-5444)

Switching runtime in the agent creation form left the model picker
spinning for ~8-20s. Two costs stacked up:

- the list-models request sat in the store until the daemon's next
  scheduled heartbeat (0-15s, avg 7.5s of pure dead wait), and
- the daemon then enumerated the catalog locally (static for claude,
  but a CLI/ACP round trip up to ~15s for everyone else).

Both are addressed with the two standard techniques for a slow,
low-frequency, read-only operation: push instead of poll, and
stale-while-revalidate.

Push (removes the heartbeat wait):
- new additive `daemon:pending_work` hint, runtime-scoped, delivered
  through the existing daemon WS hub and the Redis relay so the API node
  holding the socket does the delivery.
- the daemon answers a hint with ONE immediate heartbeat and dispatches
  what it claimed. The hint deliberately carries no work, so nothing has
  to be un-claimed when delivery fails and a duplicate hint cannot
  duplicate work - PopPending stays the atomic claim.
- per-runtime coalescing plus a 1s floor keeps a caller-triggered hint
  from becoming a heartbeat amplifier.

Cache (removes the discovery wait on repeat opens):
- server-side per-runtime catalog cache (in-memory single-node, Redis
  multi-node) written on every successful report.
- a snapshot younger than 15min answers the POST immediately as an
  already-completed request; older than 60s it also enqueues a
  background refresh that only warms the cache.
- only supported, non-empty catalogs are cached; a completed-but-empty
  report invalidates instead, while a failed report keeps serving the
  last known good list.

Frontend: staleTime 60s -> 5min and gcTime 30min, so a runtime revisited
in the same session renders from cache and revalidates in the background
instead of showing the spinner again.

Compatibility: every wire change is additive. Old daemons ignore the
unknown hint type and keep using the scheduled heartbeat; new daemons
against an old server simply never receive one. The cached response is
shaped exactly like a completed live discovery apart from the optional
`cached` / `cached_at` markers.

Co-authored-by: multica-agent <[email protected]>

* fix(agents): address review on model discovery SWR (MUL-5444)

Sol-Boy's review on #6098 found the client cache could outlive the
server's own staleness promise, and that the two changed endpoints were
still cast rather than validated.

Must-fix 1 — client freshness now derives from the served answer.
`staleTime` was a flat 5min, so a 14-minute-old snapshot (which the
server returns while queueing its own refresh) was held as fresh for
another 5min: observable staleness became server window + client window,
and the refreshed catalog never reached the tab that triggered the
refresh. `staleTime` is now a function of the query data: a `cached`
answer is stale on arrival (bound stays the server's window alone, and
the next mount/focus picks up the refreshed snapshot), while a live
discovery — which just measured the truth — is trusted for the full 5min
so a cold runtime is never re-enumerated inside one form session.
`gcTime` stays 30min, so a revisited runtime still renders from cache and
revalidates in the background; the pickers gate their spinner on
`isLoading`, which stays false throughout.

Must-fix 2 — both model-discovery responses go through a zod schema.
`POST /api/runtimes/{id}/models` and its poll companion were casting
network JSON to `RuntimeModelListRequest`, which the root CLAUDE.md
API-compatibility rules forbid. Added a lenient schema (`status` stays
`z.string()`, `supported` defaults to true, `.loose()` keeps unknown
fields) plus a fallback record whose `status` is `failed`: a malformed
body now surfaces "discovery failed" with manual entry still usable
instead of a fabricated empty catalog or an endless spinner.
`resolveRuntimeModels` was tightened to match — only an explicit
`completed` is a catalog, so an unrecognised status is an error rather
than a silent empty list, and `supported` can no longer be `undefined`.

Nit — the in-memory catalog cache now deep-copies each entry's
`Thinking` (and its level slice) and `ServiceTiers`, so it delivers the
independent value its comment promises and matches the Redis backend's
JSON round-trip semantics.

Tests: staleTime policy for cached/live/no-data; a QueryObserver test
proving the refreshed catalog reaches the same client with no blank
loading state; unknown-status and omitted-`supported` handling; schema
tests for live, cached, old-backend and nine malformed shapes; client
tests that both endpoints degrade to an explicit failure; nested-field
mutation isolation for the cache.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-07-29 16:03:26 +08:00
60048172a7 fix(lark): ingest inbound images and videos as chat attachments (MUL-4934) (#5580)
* fix: ingest feishu media as chat attachments

* fix: ingest feishu post embedded media

* fix(lark): make inbound media retries safe

* fix lark media resource limit

* fix(lark): move inbound media off ack path

* fix(channel): make inbound media runs durable

* fix(channel): close enqueue-vs-append race on media deferral

EnqueueChatTask read the session-wide media deadline in one statement and
sealed the input batch in a later one. Under READ COMMITTED a media message
committing between the two got sealed into a task the deadline read had
already decided was 'queued', so the daemon could claim it before its
attachment bound — the agent received the bare placeholder, and the later
media-ready promotion was a no-op against a non-deferred task.

After the seal, re-derive the deferral from the sealed batch itself in the
same transaction (DeferChatTaskForSealedPendingMedia): if any sealed message
still carries an unexpired media marker, flip the task to deferred with
fire_at aligned to the latest marker. The existing post-commit promote fence
already covers the opposite direction (marker cleared mid-transaction).

Adds a deterministic regression test that injects the media append between
the deadline read and the seal via a wrapped pgx.Tx.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(channel): keep committed chat task out of enqueue error path

The post-commit media-ready fence returned its error from EnqueueChatTask
even though the deferred task was already durably committed. The router
flush treats any enqueue error as "no task exists": it clears the typing
indicator and logs an enqueue failure while the run still happens at its
fire_at deadline. Log the fence failure instead — the claim-path deferred
promoter re-queues the task regardless.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(channel): cap global media resolution concurrency

Media jobs were serialized per session but unbounded across sessions: a
burst could open arbitrarily many concurrent 45s Lark downloads, and each
unknown-length upload may buffer up to the 100 MiB resource cap in memory.
Gate resolveAndBindMedia behind a global slot semaphore (default 8,
RouterConfig.MediaConcurrency). Per-session ordering is unchanged; on
shutdown a job cancelled while waiting for a slot proceeds straight to the
bounded DB finalize so marker clearing stays prompt. Also document that the
per-message media budget spans queue/slot waits (it must match the
persisted fire_at) and why timed-out uploads cannot leak unbounded orphans.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(chat): keep channel-sealed user messages on task cancel

Sealing the channel input batch stamps task_id onto channel user
messages, which exposed them to the cancel draft-restore path: an
empty-transcript cancel would DeleteUserChatMessageByTask the sealed
Feishu/Slack messages and detach their attachments. Those messages are
the durable record of what the platform sender wrote — the sender has
no Multica composer to restore a draft into.

Gate the restore-delete on ChatSessionHasChannelBinding in both the
synchronous finalize and the deferred finalize (the latter covers
markers left by an older replica during a rolling deploy); a bound
session now settles as "Stopped." instead.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(channel): skip the media pipeline for messages without media

Every inbound message on a Media-enabled platform persisted a 45s
media deadline and queued a resolution job, so a plain text message
could wait behind the global media semaphore (its task deferred while
other sessions download 100 MiB videos) and a crash between append and
clear delayed a pure-text run to the full 45s fallback.

Add MediaResolver.HasMedia — a pure in-memory probe the Router calls
on the ACK path — and only persist the deadline / enqueue the job when
the message actually references platform media. The Feishu resolver
decodes the already-received payload and reports standalone image or
video keys and post-embedded img/media spans.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(chat): gate cancel restore on immutable channel provenance

The previous guard keyed the cancel restore-delete off
ChatSessionHasChannelBinding, but a binding only proves routing exists
right now: archiving a session and rebinding an installation both
delete the binding while preserving chat history, so a still-cancellable
sealed task could again restore-delete the original inbound messages.

Persist provenance on the message instead: migration 203 adds
chat_message.channel_ingested, stamped inside the channel append
transaction and never mutated, and both cancel finalize paths now gate
on TaskHasChannelIngestedMessages over the task's sealed batch. The
binding-existence query is removed. Regression tests cover ingest ->
archive/unbind -> cancel for a queued and a started task.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(channel): reclaim media uploads that never gain an attachment row

Deadline expiry dropped already-resolved refs and a BindMedia failure
was log-only, leaving uploaded objects with no attachment row and no
reclaim path — the dedup mark commits with the message before media
runs, so a redelivery is dropped as a duplicate and never re-resolves
(and thus never overwrites) those keys, and workspace/session deletion
only enumerates the attachment table.

Add MediaResolver.DiscardMedia — a best-effort delete by StorageKey —
and call it from both failure paths in resolveAndBindMedia. The Feishu
resolver forwards to the storage backend's Delete. Tests cover a
partial upload discarded at the deadline, discard on bind failure, and
key-level deletion in the resolver.

Co-Authored-By: Claude Fable 5 <[email protected]>

* docs(server): refresh comments stale after detached media ingestion

Channel tasks now seal a self-owned input batch, media ingestion is no
longer out of scope for the flattener, and MediaRefs are filled by the
detached resolver after append rather than by feishuChannel pre-engine.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(chat): stop keying channel empty-completion silence off chat_input_task_id

Sealing gave channel tasks a self input-owner, which broke
writeChatCompletionOutcome's discriminator: it treated any owned task
as direct, so an empty channel completion wrote the no_response
fallback row and the outbound patcher — which forwards any non-empty
chat:done content verbatim — pushed the English fallback body to
Feishu/Slack, violating the MUL-4351 contract.

Silence is now decided by the immutable channel_ingested provenance of
the task's input batch, looked up by the batch OWNER id
(chat_input_task_id): auto-retry clones inherit the owner while their
sealed messages stay tagged with the parent's id, so keying off the
task's own id would misread a channel retry as direct. The cancel-path
provenance gates switch to the same owner key via chatInputOwnerID.
chat_input_task_id is back to meaning only "input batch owner".

Co-Authored-By: Claude Fable 5 <[email protected]>

* chore(migrations): renumber to 203/204 after upstream took 202

Upstream main merged 202_runtime_profile_add_qwen while this branch
held 202/203, tripping TestMigrationNumericPrefixesStayUniqueAfterLegacySet
on the CI merge tree. channel_media_pending becomes 203 and
channel_ingested becomes 204; no content changes.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(channels): gate outbound delivery on channel provenance, not owner

Merging main brought #5645 (keep direct chat replies in Multica),
whose outbound gate assumed channel tasks leave chat_input_task_id
NULL. Sealed channel tasks own an input batch too, so on the merge
tree every channel reply and failure notice was classified as direct
and silently dropped — agents stopped replying in Feishu/Slack.

Both outbound gates now call engine.TaskInputIsChannelIngested: a NULL
owner keeps #5645's deliver-by-default for pre-sealing tasks, an owned
batch delivers only when it carries the immutable channel_ingested
stamp (keyed by the owner id, so auto-retry clones inherit the
verdict). Direct replies stay in Multica; sealed channel replies reach
the platform. Tests cover both directions on both platforms.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(channel): discard media orphans on a fresh context, after finalize

DiscardMedia shared finalizeCtx with BindMedia, so a bind that failed
because the finalize deadline expired handed the storage deletes an
already-dead context — the compensation silently no-opped and the
orphans leaked anyway. The deadline path also ran S3 deletes before
the marker clear, eating the same 5s budget the user-facing
bind/promotion needed.

Collect the refs from both failure paths, run bind + promotion on the
finalize budget first, then delete on a fresh discard context. The
bind-failure test now pins that discard receives a live context.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(channel): compensate result-uncertain media uploads and commits

The compensation protocol treated "the call returned an error" as "the
side effect did not happen", which is wrong in both directions across
the result-uncertain windows:

- An upload error can follow a server-side write (lost response,
  deadline mid-write). The attempted key never reached the router, so
  nothing could reclaim it and dedup guarantees no re-resolve. The
  resolver now idempotently deletes the deterministic key on a fresh
  budget right at the failure site.

- A commit error is not a rollback guarantee: a lost ack can report
  failure after Postgres durably committed the attachment rows, and
  the router's discard would then delete objects those rows reference.
  BindMediaRefs now converges the ambiguity on a fresh budget — any of
  the batch's URLs present proves the atomic commit landed (bind
  reports success); none proves the rollback (discard stays safe); a
  failed verification returns ErrMediaBindResultUnknown and the router
  keeps the uploads, preferring a rare orphan over a broken attachment.

Fault-injection coverage: an upload error deletes the attempted key; a
lost-ack commit keeps the bound attachment and reports success; a
verified rollback stays a discardable error; the router keeps uploads
on the unknown-outcome sentinel.

Co-Authored-By: Claude Fable 5 <[email protected]>

* chore(migrations): renumber to 207/208 after upstream took 203-206

Co-Authored-By: Claude Fable 5 <[email protected]>

* docs(channel): note DiscardMedia self-invocation and the unknown-outcome skip

Co-Authored-By: Claude Fable 5 <[email protected]>

* chore(migrations): renumber to 212/213 after upstream took 207-211

Co-Authored-By: Claude Fable 5 <[email protected]>

* feat(channel): replace inline media compensation with an intent ledger and reconciler

Inline best-effort compensation cannot answer "did my side effect
happen?" at the moment it needs the answer — the DELETE/PUT reordering
and the empty-read-vs-in-flight-COMMIT gaps were both instances of the
same two-system atomicity problem. Persist the intent instead and let
an asynchronous reconciler settle it:

- channel_media_pending_object (migration 214; claim index 215 as its
  own single-statement CONCURRENTLY migration): a state machine row
  ('pending' -> 'deleting') with lease, attempt, and backoff columns.
- The resolver upserts the row BEFORE each PUT, state-guarded so a key
  the reconciler owns is never resurrected (the resource is skipped).
  ObjectURL is a pure function of configuration, so the row carries the
  attachment URL pre-upload.
- BindMediaRefs deletes the batch's rows INSIDE the attachment-insert
  transaction: commit landed <=> intents gone, atomically, so an
  ambiguous COMMIT never needs adjudication. A key already claimed to
  'deleting' is skipped (placeholder stays).
- Nothing is ever deleted inline. The reconciler — an independent
  worker so storage latency cannot starve other sweepers — claims due
  rows ('pending' past the settle delay, or expired leases) under a
  fresh lease, checks for a durable attachment reference only AFTER the
  claim (race-free: bind can no longer succeed on the key), deletes
  unreferenced objects outside any transaction, and backs off failed
  deletes with attempt-based retry. Crash windows converge for free.
- The settle delay is a fixed constant carrying NO correctness weight;
  invariant tests pin it at >=10x every pipeline budget. Metrics cover
  deletes, referenced clears, delete failures, and ledger backlog.

Removed: MediaResolver.DiscardMedia, ErrMediaBindResultUnknown, the
post-commit verification, and both router discard branches.

Tests: intent-before-upload ordering; upload error leaves the row and
deletes nothing; bind-wins vs reconciler-wins on the same key; lost-ack
and rolled-back commit injections (intent cleared iff the attachment
landed); reconciler three-state settle; expired-lease reclaim; delete
failure backoff and retry; settle invariants.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(channel): never build or sweep the media reconciler without storage

store is nil when S3 is unconfigured AND the local upload dir fails to
initialize, but the reconciler was constructed unconditionally and
main only gates the goroutine on the reconciler pointer — the first
unreferenced ledger row (rows can pre-exist from a boot where storage
worked) would nil-pointer panic a bare goroutine and take down the
process.

Construct the reconciler only when a storage backend exists, and guard
RunOnce defensively: with no deleter it skips the sweep without
claiming, so rows are not stranded in 'deleting' until lease expiry.
Test covers the pre-existing-row + missing-storage boot.

Co-Authored-By: Claude Fable 5 <[email protected]>

* chore(migrations): renumber to 213-216 after upstream took 212

Co-Authored-By: Claude Fable 5 <[email protected]>

* refactor(channel): remove the dead pre-resolved MediaRefs ingress path

lark.InboundMessage.MediaRefs and the resolver's early-returns for
pre-populated refs were vestiges of the pre-detached synchronous design
— no producer fills them before the router anymore. Worse, the intent
ledger made the path actively misleading: refs arriving without ledger
rows would be silently skipped at bind (with a log blaming the
reconciler), contradicting the field's "already persisted" contract.

Delete the field, its channelMessageFromLark mapping, and both
early-returns; channel.InboundMessage.MediaRefs is now documented as
what it actually is — ResolveMedia's output channel, always empty on
ingress, attachable only through a claimed ledger intent.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(channel): enforce workspace tenancy on every ledger query

The intent-ledger upsert's conflict branch guarded only on state, so a
cross-workspace storage_key collision could rewrite the row's
workspace/message/url ownership; release and delete keyed on
(storage_key, lease_token) alone. The derived key embeds the workspace
UUID so none of this is reachable today — but tenancy must be enforced
by the workspace column in every query, never derived from the key
string (MUL-3515 rule, restated in this PR's review).

The upsert now updates only within the same workspace (a cross-tenant
conflict updates nothing, returns no row, and the resolver skips the
upload — the fail-safe direction), and release/delete take
(workspace_id, storage_key, lease_token). Tests pin that a foreign
workspace can neither steal, release, nor delete a row.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(migrations): build the ledger primary key via a concurrent index

storage_key TEXT PRIMARY KEY created its unique index implicitly at
CREATE TABLE, against the repo convention that every migration index —
including a new table's unique index — is built CONCURRENTLY in its
own single-statement migration (the exact three-step pattern
client_usage_daily shipped in 207-209). The table now declares
storage_key NOT NULL, 216 builds the unique index concurrently, and
217 attaches the primary key USING INDEX; the claim index moves to
218. ON CONFLICT (storage_key) still resolves against the constraint,
and the full down/up round-trip is verified.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(channel): bound each reconciler object delete with its own timeout

DeleteObject ran on the worker-lifetime context and the SDK's default
HTTP client has no overall request timeout, so one black-holed
connection would wedge the sequential sweep loop — and with it every
later batch and the backlog gauge — forever; a single-replica
deployment has no other worker to reclaim the lease. Each delete now
gets a 30s timeout (well under the 2min lease), and a timed-out delete
takes the existing release/backoff path. Covered by a blocking-deleter
test with an injectable timeout.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(channel): anchor the media deadline to the DB clock and bound queue waits by it

Two deadline gaps from review:

- The persisted marker was an application-clock timestamp compared
  against SQL now() everywhere it is read, so a skewed app node could
  shrink the fallback window and hand the agent a placeholder before
  the resolver's local budget ended. The append transaction now anchors
  a relative budget (MediaPendingSeconds) with now() + make_interval,
  writer and readers sharing one clock; the local resolve budget stays
  monotonic app-side. A DB test pins that the remaining budget measured
  by the DB clock equals the requested one.

- enqueueMedia's waits (per-session order, global slot) only watched
  shutdown, so in a burst an already-expired job kept its goroutine and
  payload until it reached the front. Both waits now also watch the
  message's deadline; on expiry the job skips the resolver entirely and
  runs only the empty finalize (marker clear + promotion), which also
  unblocks the session's later messages. Covered by a queued-expiry
  test that finalizes while the only slot is deterministically held.

Co-Authored-By: Claude Fable 5 <[email protected]>

* chore(migrations): renumber to 216-221 after upstream took 213-215

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(channel): start the local media budget before the append transaction

The DB anchors the durable fallback at insert-time now(), but the
local monotonic budget started only after AppendMessage returned — so
the resolver outlived the fallback by the append/commit latency, a
window where the deferred task is already claimable while the resolver
still runs and the agent reads a placeholder that binds moments later.
Capture the local deadline before calling AppendMessage, restoring the
ordering local-gives-up <= durable-fallback-fires. A slow-append test
pins that the resolver's context deadline is measured from the
pre-append instant.

Co-Authored-By: Claude Fable 5 <[email protected]>

* chore(migrations): renumber to 224-229 after upstream took 216-223

Verified against the merged tree: the numeric-prefix uniqueness test
passes and the full migration set applies cleanly from scratch.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(channel): heartbeat the reconciler lease per row

One claim covers up to 50 rows under a single 2-minute lease, but the
batch is processed sequentially and each delete may run its full 30s
timeout — a few stalled deletes could outlive the lease mid-batch,
letting another replica reclaim the tail: duplicate concurrent
deletes, inflated attempt/backoff on rows whose owner was alive, and
skewed metrics.

The lease is now renewed before EACH row's settle work, so it only
ever needs to cover one row's worst case (invariant-tested: lease >=
2x the per-delete timeout). A renewal that matches no row means
another worker reclaimed it after a genuine expiry — the row is
skipped, leaving the new owner's state untouched. Test simulates a
mid-batch reclaim and pins the skip.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(channel): dedup post media resources and make local writes atomic

A rich post may reference the same image_key/file_key in several spans.
The object key derives from (message, type, key), so duplicates uploaded
to the SAME key twice: LocalStorage.UploadStream truncated the
destination up front and removed it outright on a copy error, so a
second failing attempt destroyed the object the first success had
produced — leaving an attachment row pointing at nothing. A second
succeeding attempt instead produced two attachment rows for one object.

Collapse duplicate spans by (fetch type, platform key) before the
upload loop, and write local uploads through a temp file renamed into
place so a failed write can only discard its own temp file. Tests cover
a duplicated span uploading once and a failed re-upload leaving the
previous object intact.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(channel): fence late-materializing PUTs with a tombstone schedule

A DELETE cannot be ordered against a PUT the client already abandoned:
the store may materialize the object after the delete completes. The
reconciler cleared the ledger row right after deleting, so such an
object had no row and nothing to reclaim it — which made the settle
delay the de-facto correctness barrier for the PUT/DELETE race, exactly
what the design says it must not be.

The row is now kept as a tombstone ('tombstoned' state, migration 226's
CHECK) and re-deleted on a widening schedule (15m, 1h, 6h, 24h, the
pass index carried in last_error), so a late materialization is
reclaimed by a later pass; only after the schedule is exhausted is the
row dropped. Claim, heartbeat, lease, and tenancy predicates are
unchanged — a tombstone is claimed exactly like any other due row. A
separate gauge reports tombstones so they cannot be mistaken for a
backlog of objects awaiting reclaim, and the header comment now states
precisely what state fences (bind/commit) versus what the schedule
fences (late PUTs).

Tests: the reviewer's interleaving — DELETE completes, the abandoned PUT
materializes right after, and the object is gone by the end of the
schedule — plus a full schedule walk asserting the object is counted
once and the row clears at the end.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(channel): tombstones must re-delete, not re-ask the reference question

A tombstone revisit ran the same reference check as a first settle, so
an attachment carrying the same URL — a re-ingested copy of the object —
sent the row down the "referenced, keep it" branch: the object was kept
and the row cleared, abandoning the re-delete schedule that fences the
ORIGINAL object against an abandoned PUT. A tombstone has already been
judged unreferenced and deleted; it exists only to re-delete whatever
materializes later, so it now goes straight to the delete + schedule
tail (extracted as settleDeletedObject, shared with the first settle).

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(channel): keep the tombstone schedule position in its own column

The re-delete pass index was encoded into last_error, which the failure
path also writes: one failed re-delete erased the position and restarted
the walk. A store failing intermittently could therefore keep a tombstone
alive indefinitely — every recovery would resume at pass 1 and the row
would never reach the end of the schedule to be dropped.

tombstone_pass is now its own column (the table is introduced in this PR,
so migration 226 carries it), advanced only by a successful delete, and
the tombstone write clears the now-stale last_error. Test walks the
schedule across a failed re-delete and asserts it resumes rather than
restarts, and that the row still terminates.

Co-Authored-By: Claude Opus 5 <[email protected]>

* fix(lark): derive media object keys per chat message

The object key was derived from the platform message alone, so a second
ingest of the same Feishu message reused the first ingest's ledger row.
That row can be a tombstone (up to ~31h while the re-delete schedule
runs), and the intent upsert refuses anything that has left 'pending', so
the second ingest skipped the upload and silently produced a placeholder
with no attachment. A re-ingest is reachable: the inbound dedup claim is
reclaimable once 60s stale and the dedup row is only vacuumed after 24h.

Keying on the chat message the object will attach to keeps the two
ingests independent, and nothing leaks: each one's objects are covered by
its own ledger row.

Co-Authored-By: Claude Opus 5 <[email protected]>

* refactor(storage): route both local upload paths through one atomic write

UploadStream wrote through a temp file and renamed into place, but the
buffered Upload path still truncated the destination up front — the
destructive shape the stream path exists to avoid, one caller away from
coming back. Both now share writeAtomic, which also restores the 0644 the
direct write used (CreateTemp makes files 0600).

Co-Authored-By: Claude Opus 5 <[email protected]>

* chore(channel): gofmt the media-pending append fields

Co-Authored-By: Claude Opus 5 <[email protected]>

* fix(storage): keep the local upload chmod best-effort

The rename-into-place rewrite made a failed chmod fail the whole upload.
CreateTemp's 0600 has to be widened to the 0644 the direct write used, but
an upload dir on a mount that ignores chmod (SMB/NFS/FUSE) accepted the
old direct write fine — turning those deployments' uploads into hard
errors would be a regression for a cosmetic property. Log and continue.

Tests pin 0644 on both upload paths, and that a failed buffered upload
leaves no temp litter and no damage to a previous object.

Co-Authored-By: Claude Opus 5 <[email protected]>

* fix(channel): never re-delete an object an attachment references

The tombstone pass skipped the reference check and deleted unconditionally,
so a durable attachment carrying that URL lost the only object it can read
— the dangling attachment the intent ledger exists to prevent, and the
opposite of the posture every other path here takes ("a reclaimable orphan
beats a broken attachment").

The check now runs on every pass. A positive result on a tombstone is
unreachable by design — keys are per (chat message, resource) and a bind
cannot attach a key that has left 'pending' — so reaching it means an
invariant broke: keep the object, clear the row, log it, and count it on
a dedicated reconciler_tombstone_referenced_total counter. The test's
contract is flipped to assert the referenced object survives.

Co-Authored-By: Claude Opus 5 <[email protected]>

* fix(storage): make the local staging file reclaimable after a crash

os.CreateTemp's random suffix meant a crash between the staging write and
the rename left a file nothing could name: the ledger records only the
final storage key, and DeleteObject removed only the object and its
sidecar. Each leftover can approach the 100 MiB resource cap and they
accumulate without bound.

The staging path is now derived from the object key, so DeleteObject
removes it alongside the object — which makes the media reconciler reclaim
it too, since the intent row is written before the upload. Opening it 0644
directly also drops the chmod the previous commit had to make best-effort.
Both read paths refuse the staging name (keys come from the request URL,
and a half-written body should not be readable); a user-supplied ".tmp"
extension is unaffected, since object keys are generated and never
dot-prefixed.

Co-Authored-By: Claude Opus 5 <[email protected]>

* chore(channel): renumber the media migrations after merging main

main took 224 (agent_task_session_rollout_missing), so the ledger group
moves to 225-230 and the cross-references inside the table migration follow.
main's CompleteTask also grew a sessionRolloutMissing parameter; the three
call sites this PR added to chat_input_ownership_test.go pass false.

Verified the way the numbering is meant to be verified: full migration set
applied from scratch on the merged tree, and the whole server suite run
against that database.

Co-Authored-By: Claude Opus 5 <[email protected]>

* fix(channel): let Postgres compute every reconciler deadline

The reconciler built settle cutoffs, lease expiry, backoff and re-delete
times from the process clock and compared them against the database's
now(). A replica whose clock had drifted would therefore settle rows whose
upload was still in flight (the object is deleted and the bind then refuses
to attach — media silently lost), hand out leases that are born expired
(rows churn between workers, attempt/backoff inflate), or compress the
tombstone schedule that fences a late-materializing PUT.

The four settle queries now take durations and derive their timestamps from
now(), so every replica reads one clock. The parameter types are the guard:
an app-side timestamp can no longer be passed. Test asserts the persisted
lease, backoff and re-delete deadlines all track the database's now().

The generated code also picks up main's new agent_task_queue column in the
two RETURNING task.* queries this PR adds.

Co-Authored-By: Claude Opus 5 <[email protected]>

* chore(lark): drop unrelated gofmt-only churn from this PR

Six files carried whitespace/comment-reformatting with no functional
change, unrelated to the inbound media pipeline. Reverting them to the
base revision keeps the diff focused on the feature (75 -> 69 files):

  server/internal/service/empty_claim_cache.go
  server/internal/integrations/lark/markdown_detect.go
  server/internal/integrations/lark/ws_chunk_assembler.go
  server/internal/integrations/lark/ws_chunk_assembler_test.go
  server/internal/integrations/lark/ws_frame_test.go
  server/internal/integrations/lark/registration_test.go

Verified: `git diff -w` against these files was already empty, so no
behavior is affected. go vet clean; tests covering these files pass.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Claude Fable 5 <[email protected]>
Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-07-27 15:44:39 +08:00
ecce589867 MUL-5265: GitHub API-snapshot PR cards — CI status + mergeability (#5889)
* feat(github): API-snapshot PR cards — CI status + mergeability (MUL-5265)

Fetch each linked PR's CI checks and mergeability from the GitHub GraphQL
API as the single source of truth (Plan C). Webhooks, page visits and a
bounded TTL sweep are refresh triggers only; nothing is inferred from
webhook payloads anymore.

Backend (server/internal/integrations/ghsnapshot):
- installation-token cache + GraphQL client (private key / tokens never logged)
- one paginated pullRequest query -> normalized per-check snapshot
- outbound queue: (installation,repo,PR) dedup + single in-flight per PR,
  bounded worker pool, Retry-After / rate-limit backoff, jitter
- head-SHA-guarded atomic batch replace (a slow response for an old head
  can never overwrite a newer head's snapshot)
- bounded chase window (30s->5m, stops on terminal/closed) + page-visit +
  TTL refresh; clean degradation when no App private key is configured

Removes the old suite-level webhook aggregation display path (query +
handlers + tests). check_suite / check_run / status are now pure triggers.

Frontend: PR card shows two independent tri-state elements (CI status +
mergeability). "Ready to merge" only when merge state is clean; no-checks
and unknown-mergeable never assert a positive verdict; progress strip
removed; four locales; stale marker.

Docs: github-integration + environment-variables (four languages) — now
required App private key, read-only Checks/Commit-statuses permissions,
new event subscriptions, capability boundaries and troubleshooting.

Co-authored-by: multica-agent <[email protected]>

* fix(github): address PR snapshot review blockers

Co-authored-by: multica-agent <[email protected]>

* fix(github): bound snapshot refresh scheduling

Co-authored-by: multica-agent <[email protected]>

* fix(github): concurrent check-run index migration + singleflight token mint

Address Elon's third-round review on the MUL-5265 PR snapshot pipeline.

Must-fix — migration built a non-concurrent index. The
github_pull_request_check_run table declared PRIMARY KEY (pr_id, ordinal)
inside CREATE TABLE, which builds a unique index synchronously and violates
the repo rule that every migration-created index (including on a new table)
use CREATE UNIQUE INDEX CONCURRENTLY in its own single-statement file. Split:
222 now creates the table without a primary key; new 223 adds the
(pr_id, ordinal) unique index CONCURRENTLY. The atomic delete-all/insert
write path already guarantees ordinal uniqueness, so a plain unique index is
sufficient; the index also serves the pr_id-prefix list aggregation and the
workspace/PR cleanup deletes.

Nit — token mint now singleflights per installation. installationToken
released the lock before minting, so the N workers of one installation could
mint N tokens on a cold cache or a simultaneous renew. Concurrent callers for
the same installation are now collapsed via singleflight into one HTTP mint;
added a -race concurrent-mint test asserting a single mint under 16 callers.

Verified: fresh DB migrates through 223 (table has no PK, concurrent unique
index present); ghsnapshot suite + new test pass under -race; migration lint
and handler github/workspace-delete tests pass; sqlc produced no diff;
go build / vet / gofmt / git diff --check clean.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-07-24 18:30:20 +08:00
73b0015475 feat(vcs): make self-hosted Git providers self-host-only (MUL-3772, MUL-5138) (#5888)
* feat(vcs): gate self-hosted Git providers to self-host deployments only (MUL-3772)

The Forgejo/Gitea/GitLab integration is intended for self-hosted Multica, where
Multica can reach a Git instance on the operator's own network. On the managed
multi-tenant cloud it adds an SSRF surface (connect validates a user-supplied
instance URL from the server) and would store third-party Git tokens for all
tenants under one key, while only serving the small subset of users whose
instance is publicly reachable. Product decision: offer it on self-host only.

- Add an explicit deployment switch MULTICA_VCS_INTEGRATION_ENABLED (default
  off). Connect, rotate, and webhook now require BOTH the switch on AND a valid
  MULTICA_VCS_SECRET_KEY — the switch is the product boundary, not key presence
  alone. When off, connect/rotate return 404 and the webhook returns a bare 404
  (no config leak), independent of the frontend.
- /api/config exposes vcs_integration_available (mirrors the switch, omitted
  when false) so the Settings UI hides the whole "Git providers" section on
  cloud instead of surfacing an operator-only "missing key" hint.
- docker-compose.selfhost.yml defaults the switch on; .env.example documents it.
- Docs (en/zh) lead with a callout: available on self-hosted Multica only, not
  Multica Cloud, and clarify "self-hosted" means Multica itself, not just Git.

#5006 / #5883 stay in place — the schema and backend capability are retained;
this only gates availability. No cloud VCS connection can exist (connect always
required the key, which the cloud never set), so nothing needs migrating.

Verified: go build/vet + VCS/config handler tests on a fresh migrated DB
(incl. a new disabled-deployment 404 test); pnpm typecheck (core + views) and
the integrations-tab + core schema/config vitest suites pass.

Co-authored-by: multica-agent <[email protected]>

* fix(vcs): complete self-host integration gating (MUL-5138)

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-07-24 16:39:22 +08:00
dixonl90andBohan 581d9527ba feat(vcs): self-hosted Git providers (Forgejo, Gitea, GitLab) alongside GitHub (MUL-3772) (#5006)
Adds self-hosted Git provider support (Forgejo, Gitea, GitLab) alongside GitHub:
per-workspace token connection, a provider-dispatched webhook, PR/MR and CI
mirroring, and the shared issue auto-link / auto-close machinery. Off until
MULTICA_VCS_SECRET_KEY is set, so existing deployments are unaffected.

Co-authored-by: Bohan <[email protected]>
2026-07-24 15:01:27 +08:00
Marcelo Cecin 7985699df9 feat(help): surface the running server version in the Help popover (#4959)
Surface the running server build version in the Help popover so self-hosted
operators can confirm what's deployed and include it in bug reports.

- Backend exposes it via /api/config's server_version (from main.version),
  omitempty so older/unstamped builds omit the field.
- Unstamped "dev" builds are normalized to empty and the row stays hidden.
- The row is suppressed on the managed cloud (frontend host multica.ai) and
  shown only on self-hosted deployments.
- Frontend renders a muted footer row in the Help popover only when the value
  is non-empty; i18n added for en/ja/ko/zh-Hans.
2026-07-14 20:11:52 +08:00