Commit Graph
51 Commits
Author SHA1 Message Date
86020b88a4 MUL-8047: chore(server): remove the pre-v3 desktop onboarding shim (#9291)
Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-10-16 16:41:46 +08:00
Jiayuan Zhangandmultica-agent 231dedb552 MUL-8034: chore: clean historical comments across the codebase (#9274)
* chore: clean historical comments (MUL-8034)

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

* docs: preserve pending Composio expiry contract

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

---------

Co-authored-by: multica-agent <[email protected]>
2026-10-15 21:54:33 +08:00
importcpp 6328e81834 fix(mobile): avoid seeding incomplete project lists (#8854) 2026-09-27 02:04:27 +08:00
Bing Free d5a25d7438 MUL-7608 feat(mobile): add Simplified Chinese localization (#8702)
Add a standalone Simplified Chinese UI to the mobile app, with a
Settings → Language picker (Follow system / English / 简体中文) whose
manual choice persists across restarts. Mobile keeps its own i18next
resources rather than reusing web/desktop copy, following the
conventions.mdx glossary: issue statuses and categories stay lowercase
English identifiers, Squad is 小队.

- Failure reasons: REASONS now mirrors web's REASON_LABEL, and a drift
  test pins it to the en/zh-Hans failure_reason keys.
- Production env: .env.production stays committed with the public
  multica.ai defaults; the *:prod scripts layer a gitignored
  .env.production.local on top for self-hosting or a custom bundle ID.
- Duplicate-mark activity rows from #8741 are localized too.
2026-09-24 13:07:10 +08:00
importcpp 024e365ee0 fix(mobile): avoid duplicate WebSocket recovery and reject invalid frames (#8690) 2026-09-22 16:30:53 +08:00
1fcdff78d0 MUL-7436 feat(auth): slide UI session expiry instead of forcing a re-login every 30 days (#8486)
* feat(auth): slide UI session expiry instead of fixing it at login

A session token's exp was stamped once at login and never moved, so every
continuously active user was logged out 30 days later, always mid-task. The
session now re-issues itself once it drops below half its TTL, turning that
scheduled logout into an idle bound.

Renewal is stateless: it copies the claims of a token we signed and moves
exp, so there is no session table and no DB read on the hot path.

- Browsers are renewed inline by middleware.Auth on safe requests, keeping
  the session HttpOnly — no client change and no multi-tab coordination.
- Clients holding the session as a string (Desktop, mobile) get
  POST /api/auth/refresh, which only ever accepts a UI JWT. PATs, agent task
  tokens and cloud-node PATs are refused: a machine credential must not
  become an interactive session. An expired JWT cannot be revived.
- CSRF tokens are now bound to a new sid claim, stable across renewals, so
  re-issuing the auth cookie no longer invalidates a CSRF token another tab
  already read. The pre-existing binding stays valid so sessions that
  predate this keep working until they renew.
- CloudFront cookies are re-signed alongside a renewal, so the CDN policy
  cannot outlive its session.

The idle bound is one-sided and the comments say so: a session last used
while it still had over half its TTL left is not extended, so the
guaranteed survivable gap is TTL/2, not TTL.

Refs MUL-7436

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

* feat(auth): renew the session on Desktop, mobile, and legacy token-mode web

Completes the client half of sliding sessions. Browsers on cookie auth need
nothing — the server re-issues their cookie inline — so this covers the
clients that carry the session as a string and have to persist a new one.

Renewal follows use, not a clock. Checks run at launch, on returning to the
foreground, and on interaction; there is deliberately no interval timer,
because a timer would keep an abandoned but still-open window (or a
backgrounded phone) signed in forever. The server decides whether a session
is renewable and tells the client when to ask again, derived from the
deployment's configured TTL — so no client reads exp, hardcodes a cadence,
or has to reason about clock skew.

Renewal is not a session event, and each step protects that:
- Concurrent triggers collapse into one request, so two valid tokens never
  race to be the one stored. On mobile the single-flight flag is set before
  the first await, since reading the Keychain is itself async.
- A response that lands after logout or an account switch is discarded by
  comparing against the session the attempt started from; writing it back
  would resurrect a session the app has already torn down.
- Offline, 5xx and an unreadable body all keep the current session, which is
  still valid for at least another half TTL. Only a real 401 ends a session,
  and that path is untouched — nothing here can clear tabs or drafts.
- WebSocket clients now read the token at connect time instead of pinning
  the one captured when they were built, so a reconnect after a renewal
  authenticates with the current credential.

Web also retries a rejected CSRF token once. Session-bound CSRF makes that
unreachable in the steady state; it covers the single transition where a
session predating that binding is renewed for the first time.

Refs MUL-7436

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

* fix(auth): address review blockers on sliding session renewal

Six fixes from review, each with a regression test that fails without it.

An expired session was answered 403, not 401. The CSRF gate runs before the
JWT is validated, and it read `sid` through the expiry-enforcing parser — so
an expired cookie lost its sid, its perfectly good CSRF token failed to
verify, and the user's first action after expiry was a rejected write instead
of a return to the login page. Reading the session id and deciding access are
now different questions with different parsers: the first only needs the
signature, the second still enforces expiry.

Rolling back was not survivable. A previous release can verify only the
token-bound CSRF binding, so once a session was issued or renewed by this
one, a rollback left every signed-in user able to read and unable to write,
with no client retry able to help. Both bindings are now issued: clients echo
both, the server accepts either, and the token-bound cookie is re-minted on
every renewal so a rollback taken at any moment still finds a usable pair.

Mobile could resurrect a session it had just ended. Keychain deletes are
async, so a renewal that sampled storage after logout began but before the
delete landed would write its replacement back. A session epoch, incremented
synchronously at the start of logout, a 401 teardown and a new sign-in, closes
the window comparing tokens cannot — including the case where logout lands
during the renewal's own write, which is now undone rather than left behind.

Mobile only checked at launch and on foreground, so an app held in the
foreground and used continuously would never check again and could expire
under the user's hands. Every touch now passes through a capture-phase
responder that declines the gesture it observes.

CloudFront cookies were re-signed by a middleware not every renewing route
mounts — the plugin bridge renews without it, and would have slid the session
forward while leaving the CDN policy behind. Re-signing moved next to the
renewal itself, so the two cannot diverge by route.

The renewal check interval could exceed the window it has to land in: a flat
five-minute floor meant any TTL under ten minutes produced a cadence longer
than the renewal window, logging out clients that followed it exactly. The
cadence is now capped at half the threshold, which outranks both clamps.

Refs MUL-7436

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

* fix(auth): close the CORS, credential-write and short-TTL gaps

Three blockers from the second review round.

The two CSRF values now travel in one header instead of two. A second header
name would have to be in the server's CORS allowlist, and that list is
version-specific: a browser holding a cookie this release issued, talking to a
rolled-back server, would send a header that server never allowlisted. The
preflight fails, the request never reaches a handler, and no retry helps —
precisely the half-logged-in state the second cookie exists to prevent. With
one header name the server just tries both bindings, and the client switches
to the token-bound value when a server rejects the session-bound one. It
switches back as soon as the cookie changes, keyed on the rejected value
rather than a flag, so it cannot oscillate once per renewal. A test now pins
the CSRF header to the allowlist so a future second header fails here rather
than in production.

Mobile credential writes are serialized, and a stale renewal is refused rather
than undone. Compensating after the fact meant deleting whatever was stored —
which, after a logout and a fresh sign-in, is the new account's token: the app
would be logged in in memory and signed out on the next launch. The check and
the write now happen inside one serialized unit, so a logout or a sign-in
cannot land between them and a refusal writes nothing at all.

AUTH_TOKEN_TTL has a documented one-minute minimum. Clients are told when to
check again in whole seconds, so a shorter TTL asks the wire to carry a
sub-second cadence: it truncates, and below about four seconds it serialises
to 0. Values under the minimum are clamped up with a startup warning rather
than honoured. Mobile's floors were never actually lowered in the previous
commit — 1h/1m, not the 5s/5m claimed — so both clients now floor at 5s, below
anything the server can send, and fall back to 30s, which fits the smallest
supported window. The cadence tests assert the serialised integer a client
actually receives, not the internal time.Duration.

Refs MUL-7436

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

* fix(auth): keep a failed renewal cheap and a renewed token shared

Two blockers from the full-PR review, plus the documentation the config
change owes.

A failed check no longer consumes the normal cadence. The two were one
number, so a single 503 spent whatever the server had last suggested — with
the 30-day default that is three days. A client told to check again in three
days on day 12, returning on day 29 with one day of session left and hitting
one 503, would not have been allowed to retry until day 32: two days after the
session it was trying to save had expired. Failures now buy a short backing-off
delay of their own, reset by the next success, so recovery follows the network
rather than the cadence.

The bearer token is read from shared storage per request instead of cached per
client. Desktop runs one ApiClient per window over one localStorage, so a
session renewed in one window left the others sending a credential on its way
out until it expired and took the whole session down. A 401 is also now matched
against the credential that produced it: a request that went out just before a
renewal can be answered after it, and treating that as expiry would tear down a
session that is demonstrably alive, clearing tabs and drafts with it.

Documentation catches up with both the behaviour and the new minimum, in all
four languages: sessions are an idle bound rather than a countdown from
sign-in, the bound is one-sided, and AUTH_TOKEN_TTL clamps up at 60s. The
rationale for that minimum is corrected too — it is the 5s floor every client
applies to the derived cadence, not whole-second serialisation, which only
binds far below it.

Also removes two claims that were not true: that a failed renewal leaves at
least half a TTL (it happens inside the window, so less), and that an open
untouched window never renews (cookie-mode sessions slide on any authenticated
request, background polling included).

Refs MUL-7436

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

* fix(auth): back off on a renewal response that carries no cadence

A 200 whose body fails schema validation does not throw — parseWithFallback
returns zeroes — so both clients reset the failure count without moving the
next-check deadline, and every subsequent interaction fired another request at
a server already answering badly. A missing cadence is now treated as a
failure and takes the same short backoff; a token that did arrive is still
applied.

Also settles the documentation wording: the 30 days is not an idle timer.
Renewal only happens past the halfway point, so an unused session can end
sooner than 30 days after its last use, and saying otherwise promised
precision the design does not have.

Refs MUL-7436

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

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-16 18:44:59 +08:00
7dafc0cd62 MUL-7240: Unify issue lifecycle into four stored categories (#8261)
* feat: simplify issue status categories

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

* feat: adopt four stored issue lifecycle categories

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

* fix: preserve visible work when upgrading saved category preferences

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

* docs: align lifecycle comments with stored and wire contracts

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

* test: align mobile grouping expectations with four categories

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

* fix(issues): group user views by concrete status keys

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

* fix(settings): simplify status editor copy and category selector

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

* feat(statuses): allow ordering built-ins and unify row actions

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

* feat(statuses): configure custom status icons independently of category

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

* fix(statuses): dismiss built-in status notice

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

* fix(issue-status): require moving issues before archive and polish controls

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

* fix(ci): renumber issue status migrations after main updates

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

* fix(statuses): isolate archive inspection and align catalog behavior

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

* fix(statuses): respect injectable catalog for table grouping

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

* refactor(statuses): simplify built-in skill and runtime guidance

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

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-14 12:57:24 +08:00
Bohan Jiang a9e82c7973 MUL-7282: fix(comments): delete only the selected comment and keep its replies (#8323)
Deleting a comment removed every reply under it, through the parent_id ON DELETE CASCADE. Now delete removes only that comment: one with replies becomes a tombstone (deleted_at set, body/attachments/reactions/resolution cleared) so the replies keep their parent, and one without replies is removed along with any tombstone ancestor left empty.

Tombstones are read-only and are never agent input. Every write to a comment's children takes its owners first (issue -> comment -> child), and a comment commits together with the attachments it was posted with. Clients only promise that replies are kept when the server declares comment_delete_keep_replies_supported, and then delete through DELETE /api/comments/{id}/keep-replies, which older servers do not route.
2026-09-13 19:13:43 +08:00
885ef12232 fix(inbox): stop the list falling behind the unread badge (#8290)
After #7924 the Inbox could show "2 unread" over a list with nothing unread.
The badge was right; the list was stale.

A change that lands while the inbox list's FIRST load is still in flight is
never re-read. TanStack only cancels an in-flight request on invalidation once
the query holds data — `Query.fetch` guards that branch on
`state.data !== undefined` and otherwise hands back the request already on the
wire. So every `inbox:new` arriving during the first load was answered by the
pre-change request, which then resolved successfully and cleared
`isInvalidated`. With `staleTime: Infinity` nothing asked again.

#7924 closed this hole for the unread summary but left the list on plain
invalidation, and it made the hole both visible and common:

- Visible: the badge used to be derived from the same list cache, so the two
  were stale together and always agreed. Now the badge is an independent,
  correct server read, rendered next to the rows it counts.
- Common: the sidebar used to observe the list permanently, so it was loaded
  once. Nothing outside the Inbox page observes it now, so on web it is
  collected while the user is elsewhere and every return is a first load —
  of a large unbounded list, with notifications arriving meanwhile.

Every inbox cache now refreshes through one cancel-first primitive. The list
refresh (`onInboxInvalidate`) joins the summary refresh on it, and every
freshness signal routes through those two entry points: inbox events, all
mutations, issue deletion and reconnect. Mobile gets the same treatment for
parity — its tab badge reads the summary too.

Found with a randomized interleaving check over the real QueryClient, hooks
and event handlers, where a fake server releases responses and events in
adversarial order and the Inbox page comes and goes. Against the pre-fix code
8 of 150 seeds fail, each with the reported shape — the badge above the
listed unread count; after the fix 1000 of 1000 pass. The check is kept at 25
seeds, which include three known pre-fix failures, and the mechanism itself is
pinned by deterministic tests that hold the first response open across a new
notification and a read event.

Refs MUL-6967.

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-11 13:40:08 +08:00
b36906e6d5 MUL-6967 perf(inbox): derive the unread badge from the summary endpoint, not the full list (#7924)
* perf(inbox): derive the unread badge from the summary endpoint, not the full list

The sidebar nav badge, the desktop dock badge and the mobile tab badge were
all computed by downloading the entire inbox with `GET /api/inbox` and
counting it client-side. That query has no LIMIT and no pagination, so every
app start pulled every unarchived notification the user has — each carrying a
full untruncated comment body — to render one number, whether or not the user
ever opened the Inbox.

`GET /api/inbox/unread-summary` already returns that number: one small row per
workspace, and its SQL applies the same newest-per-issue rule
`deduplicateInboxItems` applies before render, so the count is unchanged. Web
already fetches it for the workspace-switcher dot, so the badge there now
costs no request at all; mobile trades an unbounded list fetch for this one.

`GET /api/inbox/unread-count` is deliberately not used: it counts raw rows, so
one issue with three unread notifications would read as 3 where the inbox
shows a single row. Documented on the client method so the trap is visible.

The badge is now server-computed, so the optimistic list patches no longer
move it on their own. Each mutation that patches the list re-derives the
workspace's summary entry from the patched rows through the same dedup helper
the inbox renders through — the badge still moves in the same frame and
cannot disagree with the rows on screen. Mutations that cannot predict the
outcome invalidate the summary on settle instead, which they now all do: it
lives under an account-level key that `inboxKeys.all(wsId)` does not reach.

Refs MUL-6967.

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

* fix(inbox): make the unread badge server-owned and resolve tabs by object

Addresses review on #7924.

The badge no longer derives from the list cache. Recomputing a workspace-wide
count from `deduplicateInboxItems(listCache)` and writing it into the summary
read as instant, but a list cache proves only that the list was loaded once —
never that it is complete or concurrent with the summary — and the
account-level summary query is not covered by the workspace-scoped
`cancelQueries`, so a response already in flight lands on top of the local
value anyway. Under pagination it would be wrong by construction: one loaded
page cannot produce a global count. Rows stay optimistic; the badge follows
the server, which costs a round-trip and buys a single writer.

`issue:deleted` now refreshes the summary. It is an `issue:*` event, so no
`inbox:*` handler runs to pick it up, and the summary query is
`staleTime: Infinity` with no refetch on focus — deleting the last unread
issue emptied the list and left the badge lit with nothing to correct it. The
invalidation lives inside the updater on both platforms so no call site can
forget it.

Inbox tab titles resolve per object instead of through the list. `selectedKey`
IS the key the inbox groups by (`issue_id ?? id`), so for an issue-backed
notification it already IS the issue id — the hop through the list row was
redundant, and it was the only reason a restored tab needed the whole Inbox
fetched first. An issue-less notification still reads its row, since its title
exists nowhere else, and an unresolved selection now keeps the tab's persisted
title as its first frame rather than collapsing to "Inbox".

A selection whose row lives in the other view now titles by object rather than
resolving to "Inbox": treating "not in the page I loaded" as "does not exist"
is what this should not do, and the page already redirects such a deep link to
the issue.

Every added test was confirmed failing on b0d06e167 and passing after.

Refs MUL-6967.

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

* fix(inbox): cancel an in-flight summary request before invalidating it

Addresses the re-review on #7924.

Refreshing the unread summary by invalidation alone does not converge when it
races the summary's FIRST load. TanStack only cancels an in-flight request on
invalidation once the query already holds data — `Query.fetch` guards that
branch on `state.data !== undefined` and otherwise hands back the request
already on the wire:

    if (this.state.data !== undefined && fetchOptions?.cancelRefetch) {
      this.cancel({ silent: true })
    } else if (this.#retryer) {
      return this.#retryer.promise      // ← the pre-change request
    }

So the sequence "open Inbox, mark read before the first summary answers" is
served by the pre-change response, which resolves successfully and clears
`isInvalidated`. With `staleTime: Infinity` and no refetch on focus, nothing
asks again: the badge keeps a count the user's own action already invalidated
until some unrelated event happens to refresh it. This is not about restoring
an instant badge — it fails to converge after the server has confirmed.

Both platforms now route every summary refresh through one entry point that
cancels first, then invalidates: mutations, inbox events, issue deletion and
reconnect. Cancelling only matters on a first load, so this makes a refresh
behave identically whether or not the summary has loaded yet. The core
mutation helper no longer keeps its own copy — a mutation racing the first
load hits exactly the same hole a WS event does.

The convergence test this replaces did not create the race it claimed to
cover: it waited for the badge to read 1 first, so the first response had
already resolved and it only exercised an ordinary sequential update. The new
one holds that response open across the write and the event, and is
parametrized over cached/uncached — confirmed failing on the uncached case
before this change and passing on both after, matching the review's finding.

Refs MUL-6967.

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

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-11 01:36:56 +08:00
e04465be0b feat(transcript): tell the reader when a tool output was truncated MUL-7168 (#8212)
* MUL-7166 MUL-7167 feat(transcript): tell the reader when a tool output is incomplete

A tool_result preview is cut at 8 KiB and the remainder is never uploaded.
Nothing recorded that, so the run transcript rendered a truncated preview
exactly like a complete one — a reader checking what an agent did could take
the first 8 KiB of a 300 KB log for the whole thing.

The daemon now reports whether the preview dropped bytes, and the viewer says
so. Two separate claims, kept separate:

- Per step: a "truncated" badge next to the tool name when the record
  provably lost bytes.
- Per run, once: a caveat when the run contains output whose completeness
  nobody measured.

The flag is tri-state end to end (NULL/absent = unknown, false = complete,
true = truncated) and `output_truncated` is nullable with no default and no
backfill. Historical rows cannot be reconstructed, so they stay unknown; the
same applies to messages from an older installed daemon, which is why the
batch insert carries the value through the query's text[]/NULLIF encoding
rather than a []bool that cannot express the third state.

Display clipping is now worded as what it is. The inspector's 8000-character
clip said "... (truncated)", the same word the badge uses for bytes that are
gone, when copy still yields the whole stored text.

Verified: go test ./internal/handler ./internal/daemon ./internal/util
./internal/migrations, pnpm test (4981 views + core), typecheck, lint. Both
states captured in a local environment against real API + DB rows.

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

* refactor(transcript): move the completeness note to the end of the output

The truncation indicator was a warning badge beside the tool name and a
run-level banner above the step list. Both announced the caveat before the
reader had a reason to care, and the badge's colour gave a routine 8 KiB cut
the weight of a failure.

"Was that all of it?" is a question you only have once you reach the bottom of
an output, so the answer goes there: one muted line at the end of the body,
in the same place the display-clip line already sits.

The unknown case follows the same rule rather than keeping its own banner —
the inspector shows one step at a time, so a note there appears once per
opened step, not once per turn in a list. That was the whole objection to the
per-turn disclaimer this design replaced; a run-level bar was one way to avoid
it, and putting the note where the evidence is another.

`outputCompleteness` replaces `hasUnknownOutputCompleteness`: the same rules
(tool results only, empty output excluded, false is a measurement) now answer
per item instead of per run.

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

* fix(transcript): stop the display-clip line promising the whole output

"copy to get the whole record" reads as "the full output is in the database".
It is not: the daemon cuts at 8 KiB before reporting, and copy returns the
stored record — the same bytes, minus the 8000-character render clip. The
first reader of this string took it the other way, which is the whole reason
the truncation note exists.

Says what copy actually returns: everything that was saved.

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

* fix(transcript): leave a stored tool result with one remark, not two

The render clip cut at 8000 characters, but a stored tool result is already
capped at 8192 bytes upstream — so on an ASCII output the clip could only ever
shave a further ~190 characters, and it did so under a note that already said
the rest was not saved. Two grey lines, the second reporting a rounding error
on the first.

The clip now sits above the source budget, where a tool result cannot reach it.
Tool input keeps it: input is persisted whole and has no server-side budget, so
it is the body that actually needs bounding — and it never carries a
completeness note, so that path stays a single remark too.

The marker also stops mentioning copy. It described the clip's reversibility,
which is only interesting next to a truncation note that is no longer beside it.

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

* fix(transcript): say why the output is short, not where it stops

"Output ends here" reads two ways: the command produced this much, or we cut
it. Those are opposite facts about the agent's run, and the note exists to
settle exactly that question.

States the cause instead: the output was too long, so only the beginning was
kept. "Kept" rather than "shown" — the rest was never uploaded, and wording it
as a display limit invites the assumption that the whole output is in the
database and merely hidden.

The unknown note loses the same phrase for the same reason.

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

* fix(transcript): address review on the completeness note

Three fixes from review of #8212, plus the frequency question it reopened.

A malformed `output_truncated` deleted the whole run from the screen. The
field parsed with `z.boolean().optional()`, so a string or a number failed its
row, the row failed the array, and `parseWithFallback` handed the viewer an
empty transcript. It now catches at the field, degrading to unknown. The old
test claimed this but passed a malformed top-level object, which exercised a
different path; the replacements fail without the catch.

The note appeared before the reader could have wanted it. `ToolDetailSurface`
collapses its own body, so a footer rendered outside it sat over a faded,
half-shown output — answering "was that all of it?" to someone still reading.
The surface now owns the note and withholds it until the body is revealed.

The render clip went back to 8000 characters for tool input. Raising it to
20000 was collateral: the goal was to stop re-clipping a tool result, which is
already capped at 8192 bytes upstream, and skipping the clip for `tool_result`
alone does that without touching how an unbounded input renders.

Unknown-ness moves back to the run. One daemon produces every message in a run,
so either it measured that run's outputs or it measured none of them — a fact
about the run, stated once, rather than a remark repeated on each step opened.

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

* fix(transcript): drop the notice for records that predate the flag

A record from before the flag existed now says nothing. The viewer speaks only
when a daemon measured an output and found bytes missing.

The column stays tri-state. Nothing renders differently for NULL and false
today, but they are not the same fact, and defaulting NULL to false would write
"this daemon measured the output and stored all of it" onto rows where nobody
measured anything. The original lengths are gone, so that claim could never be
corrected — a lie the schema would keep telling long after this UI changed
again.

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

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-10 14:40:45 +08:00
70ea4c8f0d MUL-7050 perf(search): remove unused exact search counts (#8033)
* perf(search): remove unused exact issue count

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

* perf(search): remove unused project count

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-04 16:53:05 +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
d7247fa9fc MUL-6975: preserve departed member identity in timelines (#7931)
* fix(timeline): preserve departed member identity

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

* fix(timeline): preserve identity across live updates

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-02 17:58:14 +08:00
a2d819ea40 MUL-6962 fix(daemon): give environment preparation failures their own failure reason (#7915)
* fix(daemon): give environment preparation failures their own failure reason

A failed execenv.Prepare / Reuse is a filesystem error on the daemon's own
host, but handleTask routed it through taskfailure.Classify — a classifier
written to read agent and provider output — so a full disk, a denied
permission or a directory held open by another process all landed in
agent_error.*: the catchall today, provider_server_error on the report that
opened the issue and sent hours of diagnosis at an LLM vendor.

The daemon now tags those errors structurally (environmentSetupError) and
reports the new platform-side reason environment_prepare_failed. The four
existing sentinels still win: task identity, the prepare budget, skill
downloads and the OpenClaw CLI deadline each name a specific cause inside the
same phase.

Installed daemons update on their own cadence, and they report a non-empty
reason that FailTask deliberately leaves alone, so NormalizeDaemonReason
upgrades any agent_error.* value whose error text opens with the prepare /
reuse wrapper. That witness proves the run died before an agent existed, so
every label in that namespace is wrong by construction.

Not retryable and resume-safe: a full disk reproduces identically on the next
attempt, and no session was touched.

Closes #7913

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

* fix(mobile): parse failure_reason as an open string, and match copy to real scope

Review follow-up on #7915.

AgentTaskSchema pinned failure_reason to the six pre-MUL-1949 coarse values
with `.catch("")`, so every refined reason was erased to undefined before any
surface read it. run-row's badge map was widened in MUL-5370 and has been
unreachable ever since: agent_error.*, skill_bundle_unavailable and the new
environment_prepare_failed all rendered a bare "Failed". The field is an open
string on the wire (packages/core/types/agent.ts) and in the chat message
schema beside it; the task schema now matches, and the badge map moved to
lib/run-failure-badge.ts so mobile's node-only vitest lane can cover the
lookup a component test cannot reach.

execenv.Prepare is not only filesystem work — it also writes and validates the
per-provider local config (Codex home, Hermes overlay, Cursor MCP, and an
OpenClaw config it fails closed on), so a malformed local config lands on this
reason too. The Go docs, the chat copy in four locales and the docs table said
"disk, permission, directory in use", which would send the reader past half
the real causes — the same misdirection this reason exists to end. They now
point at the raw error first and name the config case, with regression tests
for a non-filesystem preparation failure on both the daemon and the legacy
wire path.

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

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-02 14:45:41 +08:00
3c23c491fa MUL-6924: feat(billing): notify workspaces about autopilot quota (#7681)
* feat(billing): notify workspaces about autopilot quota

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

* fix(billing): isolate and throttle quota notices

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

* fix(billing): tighten quota notice delivery

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

* fix(billing): reuse quota admission snapshot

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

* fix(autopilot): make quota notices actionable

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

* fix(billing): align quota recovery notifications

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

* fix(billing): align quota notice recipients

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

* fix(billing): finish quota notice review

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

---------

Co-authored-by: multica-agent <[email protected]>
Co-authored-by: Sol-Boy <[email protected]>
2026-09-02 12:59:26 +08:00
09a2410e88 refactor(agents): rename starter_prompts to conversation_starters (MUL-6709) (#7592)
* refactor(agents): rename starter_prompts to conversation_starters

The UI, docs and all four locales call this feature "Conversation starters"
(#7573), but every identifier behind it still said `starter_prompts` — the DB
column, the wire field, the `/api/config` capability flag, the TS types, the
i18n keys, and the `?focus=` token that a user can read in their own address
bar. The old name is also the one #7573 rejected on the way in: it promises a
prompt, while selecting a starter only prefills the composer.

Rename it everywhere so the identifier matches the product.

This is a breaking rename with no compatibility shim, and it is free exactly
once: the column arrived in migration 404 (#7261) after the last release tag,
so nothing has shipped. No deployed client reads the old name, no stored data
uses it, and `agent_conversation_starters_supported` has no version skew to
straddle. After this release, the same change would cost a wire alias, a
dual-written capability flag, and older Desktop/Mobile builds silently hiding
the editor.

Migration 404 stays as-shipped; 432 renames the column and its CHECK
constraint, so an already-migrated dev or staging database converges with a
fresh one.

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

* docs(skills): name the conversation-starters surface for agents

`multica-creating-agents` mentioned `conversation_starters` only as a wire
field in two tables. It never said what the feature is called in the product,
where a human configures it, or what selecting one does — so an agent reading
this skill would tell a user about "conversation starters" as a JSON field and
leave them with nowhere to go.

Add the product name (both languages), the Instructions-tab deep link, and the
behaviour that makes the name accurate: selecting one only fills the composer.
Also record that an agent with none configured still shows three built-in
defaults, so "the Chat shows suggestions" is not evidence this agent has any.

Document one gap the field table implied away: the accepted-body list is not a
CLI surface. `conversation_starters` has no `agent create` / `agent update`
flag — only `agent copy` carries an existing value forward — so an agent
following the old text would build a create command that silently drops it.

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

* fix(onboarding): keep the released starter_prompt field on the pre-v3 shim

The rename in ec44cc0ef swept up `starter_prompt` (singular) on
`bootstrapOnboardingRuntimeRequest`. That is a different contract from the
per-agent `starter_prompts` this PR renames: it is a field on the endpoint
marked "DEPRECATED, kept for desktop < v3", and unlike the per-agent one it
HAS shipped — it is in v0.4.34, released since fbd965e5bf.

Renaming it breaks the clients it exists for, and breaks them silently: the
handler decodes with a plain json.Decoder, so an old Desktop build's
`starter_prompt` would be dropped as an unknown field, the request would still
return 200, and the onboarding issue would quietly fall back to the default
description instead of the text the user typed. The test change hid it by
moving the fixture to the new name, removing the only coverage of the old one.

Revert both files. The two features share a word root and nothing else — the
free-rename argument for the per-agent field does not extend to a contract
that is already in the wild.

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

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-26 19:01:01 +08:00
Yuri GuiandAgent Op f8ec870f30 MUL-6629: feat(agents): add per-agent starter prompts for chat (#7261)
* feat(agents): add per-agent starter prompts

Co-authored-by: Agent Op <[email protected]>

* fix(agents): default omitted starter prompts

Co-authored-by: Agent Op <[email protected]>

* fix(migrations): renumber agent starter prompts

Co-authored-by: Agent Op <[email protected]>

* fix(chat): make starter prompt prefills reliable

Co-authored-by: Agent Op <[email protected]>

* fix(agents): preserve starter prompt writes

Keep dirty starter-prompt edits across agent refetches and fail closed when an older server cannot persist them. Clarify that copied starter prompts have no dedicated CLI override.

Co-authored-by: Agent Op <[email protected]>

* fix(migrations): avoid starter prompt prefix collision

Main now uses migration prefixes through 403, so move the starter-prompts migration to the next available prefix before requesting another review.

Co-authored-by: Agent Op <[email protected]>

* fix(agents): preserve edits after failed save

Ignore optimistic instruction snapshots while a save is pending so a rejected update can roll back the cache without erasing the user's submitted starter prompts. Cover the delayed optimistic-update and rollback sequence for both label and prompt fields.

Co-authored-by: Agent Op <[email protected]>

---------

Co-authored-by: Agent Op <[email protected]>
2026-08-25 22:43:16 +08:00
YYClaw 9e2d9b6d1c MUL-6661 feat(channels): add /new and /clear conversation controls (#7468)
* feat(channels): add new and clear conversation controls

* fix(dingtalk): resolve bot identity before dispatch

* feat(chat): expose channel-created chats consistently

* refactor(channels): snapshot task delivery routes
2026-08-25 18:28:01 +08:00
cb6b0c885f MUL-6396: remove unused task message clipping flag (#7292)
* refactor(realtime): remove unused task message clipping flag

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

* refactor(realtime): remove task message clipping consumers

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

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-20 15:24:15 +08:00
e10287cf1b MUL-6457 feat(mobile): resolve custom issue statuses by category (#7286)
* feat(mobile): resolve custom issue statuses by category (MUL-6457)

Mobile had no awareness of the workspace status catalog, so the custom
statuses web can now define (MUL-6243) degraded badly on iOS: an issue on a
custom status disappeared from My Issues and the workspace Issues list
entirely, the status picker could not offer one, and filter rows rendered a
raw key or nothing at all. `custom_issue_statuses` is a deployment-level
flag, so creating a status on web hit iOS users of the same deployment
immediately.

The root cause is one mistake repeated: mobile bucketed and labelled issues
by status KEY, while a status key's presentation and behavior come from its
CATEGORY. A `qa` bucket existed and no section ever read it.

- Fetch the catalog once per workspace (`GET /api/issue-statuses`, archived
  included so an issue left on an archived status keeps its real name).
- Group lists by category via `groupIssuesByCategory`, extracted from the two
  screens that held the same fifteen lines — and the same bug — twice.
  Grouping reads the server-resolved `status_category`, so it never waits on
  the catalog and never guesses while one is in flight.
- Offer `statusOptions()` in both the picker and the status filter, so a
  status settable in one can never be missing from the other.
- Resolve name and colour through the catalog wherever a status key is shown
  (detail chip, search, inbox row and detail label, timeline). Built-ins keep
  mobile's own copy and their category token; the glyph is per category, so a
  custom status reads as the same kind of thing.
- Keep `status_category` consistent with an optimistic status write, so the
  detail cache cannot feed a stale category to the recent-issues rows.

A workspace with no custom statuses renders exactly as before: a built-in key
is its own category, so every lookup resolves without the catalog.

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

* fix(mobile): name custom statuses on rows and judge closed by category

Addresses review on #7286.

A section header names a CATEGORY, so "Code Review" and "QA" both land under
In Review and the row was the only place left to tell them apart — and it said
nothing. `IssueRow` now renders a `<CustomStatusChip>` next to the title,
mirroring web's `list-row.tsx`. It stays silent for a status that already is
its category's built-in, so a workspace with no custom statuses renders exactly
as before. Covering it in `IssueRow` reaches all four surfaces at once: My
Issues, the workspace Issues list, project detail, and pins.

The icon-only entry points had the same identity loss with no room for a chip,
so they get the catalog colour instead — `IssueRow`'s `showStatus` path (pins),
the mention suggestion bar, and the mention picker.

Separately, the mention bar dimmed closed issues with `status === "done"`, so
an issue on a custom status in the done category rendered at full opacity as
though the work were still open. It now asks the question every status-coupled
rule actually asks — does this issue BEHAVE as done or cancelled — through
`issueBehavesAsAny`, mirroring the core helper of the same name.

Also adds the `// @vitest-environment node` pragma the repo asks for on the two
new pure-logic suites. Mobile's vitest config is already node-only, so this is
documentation rather than a behavior change.

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

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-20 14:23:59 +08:00
0c0baf0505 perf(realtime): stop the task:message fanout from flooding every client (MUL-6396) (#7227)
* perf(realtime): stop the task:message fanout from flooding every client (MUL-6396)

`task:message` is broadcast to every client in the workspace for every run,
and the client cached all of it: the handler's `(old = [])` default built a
timeline entry on first sight, so each client accumulated the full transcript
of runs its user would never open, held for the life of the run. Tool input is
unbounded on the wire, so a single large Write shipped its whole body to every
open client and was retained there.

Three changes, each useful on its own:

- Client drops frames for tasks no mounted view is rendering. Presence in the
  query cache is the signal, which registers when a view mounts and before its
  fetch resolves, so a frame landing mid-backfill is still kept. Rows are
  persisted before they are broadcast, so a dropped frame costs nothing —
  whoever opens the task next fetches it.

- Server clips oversized `input` / `output` out of the broadcast copy and marks
  it `truncated`. Persisted rows and the REST list responses are untouched. A
  client seeing the flag repairs that task's timeline from the DB, once per
  flush window rather than once per frame.

- Client coalesces surviving frames into one cache write per 100ms window,
  turning a burst of streamed messages into one merge and one render. The
  window is fixed, not a resetting debounce, so a continuous stream still
  flushes instead of being starved until it pauses.

Mobile keeps its own per-session updater and was already scoped to mounted
sessions, but shares the wire format, so it refetches on `truncated` too.

The remaining half of the batching idea — one WS frame per reported batch
instead of one per message — is deliberately not here. The client identifies
itself on the upgrade URL but the server does not retain that version, so a
shape change would silently break every older client. It belongs with the
client scope-subscription work that `listeners.go` is already waiting on.

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

* fix(realtime): keep broadcast clipping off the wire, and stop a late fetch dropping live seqs

Addresses both must-fix findings from review.

1. Clipping the broadcast copy is not something an existing client can ignore:
   it changes what `input` / `output` mean, and every deployed client already
   consumes them. A build without the `truncated` reader caches the clipped
   copy under staleTime:Infinity and never refetches, so its execution log
   would stay incomplete until the window is reloaded — the PR even proposed
   shipping the server half first, which is exactly backwards.

   Clipping now sits behind MULTICA_CLIP_TASK_MESSAGE_BROADCAST, off by
   default, so the reader ships first and the flip is a later, revertible ops
   step. Capability routing would be the principled fix, but the hub does not
   retain the client_version it is handed at upgrade and frames cross nodes
   through the Redis relay as serialized bytes — the same protocol work
   per-task scope routing is already waiting on.

2. Not caching unopened tasks made first open of a live task fetch where it
   previously found a pre-seeded cache. That fetch replaced the array
   wholesale, so any frame written while it was in flight was dropped — and
   with staleTime:Infinity nothing would ever fetch it again.

   Every write to the timeline cache now folds through one rule, installed as
   the query's own structuralSharing: union by seq, server data winning on
   conflict, rows the response never mentioned kept rather than deleted. That
   subsumes the backfill's hand-rolled eviction, so the three write paths
   (fetch, backfill, realtime batch) can no longer disagree.

Also documents why mobile's truncated branch is unreachable today: its
task:message handler gates on chat_session_id, which the server's payload does
not carry, so mobile appends nothing at all. Pre-existing, reported separately,
and the branch stays so whichever side is fixed first is not left caching
clipped rows.

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

* fix(realtime): re-check the timeline is still held before writing a batch

Holding was verified when a frame entered the batch, but the write happens up
to a window later — and `setQueryData` does not postpone garbage collection.
query-core arms that timer when the last observer leaves and never re-arms it
on write, so closing a transcript while its run keeps streaming has the entry
collected mid-window with a batch already in flight.

Writing then rebuilt the entry holding only that batch. Under the app-wide
`staleTime: Infinity` the next open reads the stub as fresh and never fetches,
so everything before it is missing until the window is reloaded — silent,
permanent, and not self-healing. Agent runs routinely outlive the 10 minute
gcTime, so "opened a running task, closed it, it kept going" reaches this on
an ordinary day rather than in a race.

The flush now re-checks immediately before each write and drops the batch if
the entry is gone, dropping its repair fetch with it. `backfillTaskMessages`
guards its own post-await write for the same reason. Dropping costs nothing:
the rows are persisted, so the next open fetches the whole timeline — which
the regression test asserts rather than just checking the entry stays absent.

Also corrects the doc comment on isTaskMessageTimelineHeld, which described
the entry as a mounted view. It means "opened at some point and not yet
collected", and the bounded tail is one gcTime.

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

* docs(realtime): describe the task:message gate as it actually behaves

The overview still said frames are dropped unless a mounted view holds the
task, and that the old behaviour retained a transcript for the life of the
run. Neither matches the code: the gate is timeline-entry presence (opened at
some point, not yet collected), and writes do not postpone GC, so retention is
bounded by gcTime rather than the run.

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

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-19 19:25:05 +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
d09019cd95 MUL-5839: preserve media in /issue descriptions (#6536)
* fix(channel): preserve images in issue descriptions

* fix(views): hide channel-media provenance in board card previews

descriptionPreview strips image Markdown but not HTML comments, so a
`/issue` description whose media was materialized rendered its provenance
marker as the card's visible preview text. cardProperties.description
defaults to true, so this showed on the default board view for every issue
created from a channel message carrying an image.

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

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-07 15:35:59 +08:00
YYClaw 24d209794e fix(channel): attach /issue media to created issues (#6388) 2026-08-06 12:39:46 +08:00
73283556d7 MUL-5750: fix(chat): keep idle sends out of follow-up queue (#6418)
* fix(chat): keep idle sends out of follow-up queue

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

* fix(chat): preserve the positional queue head

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

* fix(chat): polish deferred queue states

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

* fix(migrations): resolve pending index prefix collision

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

---------

Co-authored-by: Eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-05 15:06:42 +08:00
TeAmoandTeAmo a4dc54f2ed MUL-5570 / MUL-5493: feat(chat): add a managed follow-up queue (#6211)
* feat(chat): queue follow-up messages during active runs

* fix(chat): preserve queued message compatibility

* fix(chat): harden queued task consistency

* fix(chat): protect legacy and mobile queue state

* fix(mobile): refresh stop handler on task promotion

* fix(mobile): preserve queued chat state

* fix(chat): hide queued prompts from legacy clients

* test(chat): read queued channel input from paged transcript

* fix(chat): protect legacy and mobile queue state

* fix(mobile): refresh stop handler on task promotion

* fix(mobile): preserve queued chat state

* fix(chat): hide queued prompts from legacy clients

* test(chat): read queued channel input from paged transcript

* fix(chat): hide queued inputs from paged transcripts

* test: prove queued messages do not consume cursor pages

---------

Co-authored-by: TeAmo <[email protected]>
2026-08-05 13:15:57 +08:00
Naiyuan Qing 11e2906fee Revert "fix(mobile): refresh active chat when realtime stalls (#6325)" (#6374)
This reverts commit 08abec1873.
2026-08-04 17:42:01 +08:00
LiaoyuanNing@TTC 08abec1873 fix(mobile): refresh active chat when realtime stalls (#6325) 2026-08-04 09:20:19 +08:00
b06af2ae17 feat(runtime): unbind agents on runtime delete instead of destroying them (#6220)
* feat(runtime): unbind agents on runtime delete instead of destroying them

Deleting a runtime archived its agents and then hard-deleted the rows, so the
agents and every conversation with them disappeared — while the confirmation
dialog said "archive", which a user reasonably reads as recoverable. Retiring a
laptop is an ordinary action; losing the agents configured on it is not an
ordinary consequence.

An agent is now a persistent business object and a runtime is replaceable
execution capacity: deleting a runtime unbinds its agents. `runtime_id IS NULL`
means unbound — orthogonal to archived — and the agent keeps its instructions,
skills, chats, labels, channel installations, autopilots and task history.
service.AgentReadiness already refused an agent with no runtime, so the
scheduling safety gate needed no change.

Two columns become nullable, not one. Without `agent_task_queue.runtime_id`,
deleting the runtime still cascades the task history away (and task_message /
task_usage / task_token with it), so the agents would survive with no record of
anything they did — the same class of loss. A NOT VALID CHECK keeps NULL confined
to history: an active task must always have a runtime, so claim / dispatch /
delivery-CAS paths can never observe one without. It is written against
completed_at rather than a status list so a future non-terminal status fails
closed instead of slipping through.

Two prerequisites this depends on:

- 'deferred' (migration 128) was missing from CancelAgentTasksByRuntimeOrAgent.
  It went unnoticed because the delete used to cascade those rows away; with the
  new CHECK it would abort the delete and make the runtime undeletable.
- The channel-installation / label / chat-pin / invocation-target / draft-restore
  cleanups were scoped to "archived agents on this runtime". Archived user agents
  now survive, so that scope is narrowed to kind='system' — otherwise the fix
  would produce a subtler loss: agent alive, configuration wiped.

Also removes the squad guard that refused (409) when an active squad's leader was
an archived agent on the runtime, plus the archived-squad delete that existed
only to get past squad.leader_id's RESTRICT FK. The leader is no longer deleted,
so nothing needs to be given up to retire a machine. Autopilots are no longer
paused either: their assignee survives, and a rebind restores them without the
owner having to remember to re-enable.

Reason codes: an unbound agent reports agent_runtime_required, not
runtime_offline. The copy for runtime_offline tells users to reconnect a machine;
an unbound agent has no machine to reconnect, and the fix is to bind a runtime.
Chat's bare 409 string gains the same code so the composer can offer that action.

API: agents gain runtime_bound. runtime_id stays a string (empty when unbound) so
installed clients keep parsing and no gated two-release rollout is needed. The
confirmed-delete endpoint is /unbind-agents-and-delete; /archive-agents-and-delete
still routes to it, and the compared expected_active_agent_ids set is unchanged —
widening it would 409 every older client forever.

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

* fix: make runtime unbinding recoverable

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

* fix: address runtime unbind review nits

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

* fix: resolve runtime unbind review blockers

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

* fix(migrations): renumber runtime unbind after main merge

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

* test(daemon): avoid late-request lease flake

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

* test(autopilots): bind validation fixture runtime

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

---------

Co-authored-by: Eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-03 12:39:27 +08:00
LiaoyuanNing@TTC 3d0e1324c6 fix(mobile): recover from zombie WebSocket connections 2026-08-03 10:19:45 +08:00
Jiayuan Zhang 28b6105edc fix(subscribers): notify the human an agent files sub-issues for (MUL-5483) (#6209)
When an agent created a sub-issue while working on a human's behalf, that human
received no notifications for it at all. issue_subscriber modelled ACTOR
identity, so an agent-created, agent-assigned issue had a full subscriber list
and zero members to deliver to. The platform already knew who the work was for
(agent_task_queue.originator_user_id, MUL-4302); notification never asked.

- attribution.DelegatedSubscriber: one shared rule over the same origin
  waterfall ClassifyDirect uses. agent_create subscribes the originator as
  'delegated'; quick_create keeps the direct 'creator' tier; autopilot and
  degraded attribution subscribe nobody.
- Delegated is a reduced delivery tier: in_review/done/cancelled/blocked plus
  failures and mentions. Routine churn is suppressed, and the parent bubble
  cannot re-deliver what the tier dropped.
- Unsubscribe becomes stateful: an unsubscribed_at tombstone survives later
  rule passes, and opt_out_scope distinguishes "this issue" from "this subtree"
  so a narrow opt-out no longer silently suppresses future children.
- Subtree unsubscribe is its own endpoint. A body flag cannot fail loudly
  against an older backend (Go drops unknown fields); an unknown route 404s,
  which the UI now surfaces with a distinct message.
- Eligibility and the write share one statement under a (workspace, user)
  advisory lock that subtree unsubscribe and member revoke also take, closing
  the check-then-insert races. Revoke additionally clears the departing
  member's subscriptions in the same tx.
- UI explains a delegated subscription and offers both unsubscribe scopes.

Migrations 249/250 add the delegated reason, the opt-out tombstone, and the
opt-out scope, using NOT VALID + VALIDATE CONSTRAINT so the widened CHECK does
not scan issue_subscriber under an exclusive lock.

Reviewed across eight rounds; an earlier write-time subtree roll-up was built
and then removed in full once it proved unfixable without serializing every
topology mutation. The parent's own status transition already carries that
signal.

Closes MUL-5483.
2026-07-31 16:52:17 +08:00
0fdc38704e MUL-5149: add agent-generated Chat quick actions (#5766)
* feat(chat): add agent-generated quick actions

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

* fix(chat): preserve mid-response quick-action fences

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

* fix(chat): drop quick actions on empty reply to keep no_response fallback

An actions-only completion — a quick-actions footer with no visible text —
wrote an empty-content assistant message (message_kind=message). Older
Desktop/mobile clients ignore the quick_actions field and render that as an
empty bubble, breaking the MUL-4351 contract that an empty turn always gives
old clients a visible no_response fallback.

Drop the quick actions when the visible body is empty so an actions-only turn
falls through to the visible no_response outcome, and revert the completion
switch to gate the message row on visible text only. Update the completion
test to pin the corrected behavior.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(chat): generate quick actions via daemon suggestion pass

Replace the in-band runtime-brief instruction with a dedicated post-completion
provider turn: after a direct chat reply finishes, the daemon resumes the same
session with a JSON-only suggest prompt and forwards the raw output on the
complete callback. The server parses it leniently and reuses the existing
sanitize/redact/store/broadcast pipeline; the stripped in-band footer stays as
a fallback for older daemons and pre-upgrade sessions. The footer strip now
covers every chat completion, fixing the intro-turn protocol leak. Adds a
Settings → Chat toggle (client-persisted, default on) that hides the chips.

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

* feat(chat): deliver quick actions async with skeleton placeholders

Decouple suggestion generation from the turn: the daemon reports completion
immediately (chat:done carries quick_actions_pending as a per-turn capability
signal) and runs the suggestion pass in the background, delivering results
through a new supplement endpoint + chat:quick_actions broadcast. A new turn
on the same session cancels the stale pass. Clients render pill skeletons
under the finished reply until the supplement resolves them (entrance
animation on arrival, 30s safety timeout); older daemons never raise the flag
so no skeleton dangles. Suggest usage re-reports merged totals because
task_usage upserts replace per (task, provider, model). Prompt now asks for
exactly 3 actions.

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

* feat(chat): make the quick-actions toggle stop generation, not hide pills

The Settings → Chat toggle previously only hid rendered pills while the
daemon kept burning a suggestion call every turn. It now travels with each
send (quick_actions_enabled, absent = enabled for older clients), is stamped
on the chat task (migration 213), forwarded on the claim, and gates the
daemon's suggestion pass at the source — no call, no pending flag, no
skeleton. Existing suggestions stay visible; settings copy now says
'generate' instead of 'show'.

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

* fix(migrations): renumber quick-action migrations onto current main

Merging current origin/main brought the vcs migrations to their canonical
216-221 prefixes, which collided with the quick-action migrations that were
sitting at 219/220 (backend CI red in
TestMigrationNumericPrefixesStayUniqueAfterLegacySet). Renumber them to the
next unused prefixes:

- 219_chat_message_quick_actions        -> 222_chat_message_quick_actions
- 220_agent_task_quick_actions_disabled -> 223_agent_task_quick_actions_disabled

Contents are unchanged; sqlc regeneration produces no drift since the added
columns are independent of the vcs tables.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(mobile): render async chat quick actions via chat:quick_actions

The daemon generates quick actions in a background pass after the turn
finishes, delivering them on a separate chat:quick_actions event. Mobile
only handled chat:done (which invalidates + refetches an actions-less
message list) and keeps the messages query at staleTime: Infinity, so an
active mobile session never rendered async-generated quick actions until a
manual pull-to-refresh or refocus.

Add applyChatQuickActionsToCache — mirroring web's patcher — which patches
the supplement onto the targeted assistant message in the flat messages
cache, and subscribe to chat:quick_actions in use-chat-session-realtime.
Patch-only (no invalidate), matching web and mobile's cellular
patch-over-invalidate rule; an empty supplement is a terminal no-op. Covered
by chat-ws-updaters.test.ts.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(chat): cancel in-flight messages refetch before quick-actions patch

The chat:done invalidate can leave a messages refetch in flight that read the
assistant row before the daemon persisted the quick actions. If that refetch
resolves after the chat:quick_actions setQueryData patch, it overwrites the
freshly-patched actions with an actions-less row. Both message caches are
staleTime: Infinity, so the overwrite never self-heals and the actions vanish
permanently (MUL-5149, Howard review).

applyChatQuickActionsToCache now awaits cancelQueries for the affected caches
(web: flat messages + messagesPage, mobile: flat messages) before patching, so
a stale in-flight refetch is cancelled and cannot land after the patch. Cancel
must precede setQueryData because cancelQueries reverts to the pre-fetch state.
WS handlers call it via `void` (fire-and-forget).

Adds an active-query race regression test on both web and mobile that holds a
refetch open across the supplement and asserts the patched actions survive;
verified to fail without the cancel.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(chat): quick-actions refresh/regenerate + review hardening (MUL-5149)

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

* fix(chat): address quick-actions re-review (MUL-5149)

- Ack alignment: refresh request carries the target message_id; server
  atomically confirms it is still the session's latest turn (409 stale
  otherwise), so the client marker always matches the resolving
  chat:quick_actions — no response reconciliation. Adds a regression test.
- Converge the pending marker on every terminal path: HandleFailedTasks
  (sweeper/orphan) now resolves it, and the daemon reports a failed supplement
  so FailTask resolves it instead of leaving a completed-but-unresolved task.
- Timeout fallback now clears the real query state (useQuickActionsPendingTimeout)
  instead of a component-local flag that only masked the UI; drop the skeleton's
  and pill row's local timers.
- frontend-test type-scale: text-xs -> text-caption. Strip EOF blank line.

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

* fix(chat): close quick-actions refresh races and failure feedback (MUL-5149)

Third-round review of the refresh button surfaced three issues; all three
are addressed here.

§1/§2 Session-busy race + concurrent-refresh double-spend: a newer reply
that is queued/running but whose assistant row hasn't landed leaves the old
turn as latest-persisted, so the stale check passes and the regen resumes
the newer provider state — attaching suggestions to the wrong turn. And two
concurrent refreshes each enqueue a quota-spending pass. Add
HasActiveChatTaskForSession and refuse a refresh (ErrChatQuickActionsBusy →
409) whenever the session has any task in flight, checked under the same
session lock as the enqueue so no sibling insert slips past.

§3a Timeout re-arm on surface switch: the pending marker now carries an
absolute expires_at deadline instead of a per-mount timer, so switching
between the floating window and the chat tab resumes the same deadline
rather than restarting a fresh 30s window each remount.

§3b Generation failure masked as success: runChatSuggestPass now returns ok
so an explicit refresh distinguishes a failed pass (didn't start / didn't
complete / timed out) from a completed-but-empty one. On failure the regen
task reports failure, resolveFailedRegenerateQuickActions broadcasts a
FAILED chat:quick_actions, and the client resolves the spinner AND toasts
"couldn't refresh" instead of silently stopping on unchanged pills.

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

* fix(chat): count deferred tasks in refresh busy check; solid refresh icon tone (MUL-5149)

Two re-review blockers on 2dc9404d.

§1 (deferred window): HasActiveChatTaskForSession only treated
queued/dispatched/running/waiting_local_directory as in-flight, so a chat
auto-retry armed with a backoff fire_at — inserted 'deferred' by
CreateRetryTask, as provider_network's ~5s final attempt is — slipped past
the busy check. In that window the failed turn has no assistant row yet, so
the old turn is still latest-persisted and refreshable; the regen would then
resume a session the retry is about to advance and pin the new turn's
suggestions onto the old one. Add 'deferred' so the set matches the
canonical in-flight status list the rest of the queries already use
(agent.sql has-active-task checks). New regression test covers a deferred
active turn.

CI (text-contrast gate): the refresh icon button used
text-muted-foreground/70 (transparency standing in for a text tone), which
the frontend-test contrast gate rejects. Switch to the solid
text-faint-foreground token — the tone the gate recommends for icons/glyphs,
already used repo-wide and clearing WCAG 1.4.11.

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

* test(chat): assert quick-actions pending marker carries expires_at (MUL-5149)

The chat:done supplement-flow test still expected the 2-field marker from
before the absolute-deadline change; applyChatDoneToCache now stamps
expires_at, so the deep-equal failed on frontend-test. Assert the deadline is
present (expect.any(Number)) rather than a wall-clock-dependent value — its
timing semantics are covered by the pending-timeout hook.

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

* fix(db): renumber regenerate-quick-actions migration 237 -> 240 (MUL-5149)

main merged Issue Quick Actions (MUL-5465) taking migrations 237/238/239
(quick_action, quick_action_workspace_index, comment_quick_action). This
branch independently took 237 for agent_task_queue.regenerate_quick_actions_for.
The two 237s do not textually conflict (different filenames) so the PR reads
mergeable, but the merged tree would carry two migration 237s. Renumber this
one to 240 so it applies after main's chain. The migration is a standalone
`ALTER TABLE agent_task_queue ADD COLUMN IF NOT EXISTS` — order-independent,
touches a column none of main's migrations reference.

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

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
Co-authored-by: Walt <[email protected]>
Co-authored-by: Claude Opus 4.8 <[email protected]>
Co-authored-by: Naiyuan Qing <[email protected]>
Co-authored-by: NevilleQingNY <[email protected]>
2026-07-30 21:25:03 +08:00
f0110da555 feat(inbox): mark a notification unread from the row context menu (MUL-5496) (#6137)
The inbox auto-marks a notification read the moment it is selected, so
"opened" and "handled" were the same signal — a row you glanced at and
meant to come back to was gone from the unread count with no way back.

Right-click any inbox row for a shared context menu: Mark as read /
Mark as unread, plus Archive (Unarchive in the archived view).

- POST /api/inbox/{id}/unread + MarkInboxUnread query, publishing
  inbox:unread. Item-scoped, mirroring mark-read: the list renders one
  row per issue carrying that group's newest item, so flipping the whole
  group would resurrect siblings the user already dealt with.
- useMarkInboxUnread patches both lists optimistically and re-pulls the
  cross-workspace unread summary on settle.
- One shared menu per list rather than a Base UI root per row (the same
  shape IssueContextMenuProvider uses): only one is ever open, and a
  per-row root would unmount with its menu when the row scrolls out of
  the virtualized viewport.
- The read toggle is main-view only — archived rows deliberately render
  as read and the unread count excludes them, so a toggle there would
  report success and change nothing on screen.
- Parking the row that is currently open holds the auto-read effect off
  that one item while it stays selected; re-opening it later marks it
  read again.
- Mobile subscribes to inbox:unread so the unread dots agree across
  clients.

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-07-30 19:18:36 +08:00
6b2097ccbb feat(inbox): archived notifications sub-view (MUL-3736) (#5518)
Adds an "Archived" sub-view to the Inbox, reachable from an entry at the
bottom of the main list, with per-row unarchive. Mirrors chat's archived
sub-view so the two surfaces share one mental model.

Backend:
- GET /api/inbox/archived and POST /api/inbox/{id}/unarchive. Kept off the
  existing GET /api/inbox so installed clients keep their contract and the
  unbounded archive never rides along with the main list.
- The archived query excludes any issue that still has an active row. Archiving
  is issue-level, so a new notification on an archived issue leaves old archived
  rows beside a fresh active one — without the guard the issue renders in BOTH
  lists. The exclusion lives in SQL so neither list depends on the other's cache.
- Unarchive is issue-level (mirroring archive) and leaves `read` untouched, so a
  restored unread item raises the unread badge again.
- v1 ships no pagination: LIMIT 200, newest-first, so truncation drops the
  oldest rows and never hides a group's newest one.
- inbox:unarchived event, fanned out to the recipient like the other personal
  inbox events.
- Two CONCURRENTLY-built indexes; inbox_item previously had none covering
  workspace/archived/created_at.

Frontend:
- Separate TanStack cache per list; every inbox event invalidates the workspace
  prefix, since any of them can move an item across the boundary.
- View persisted as ?view=archived, so refresh, back/forward, and the mobile
  detail-back all return to the list the user was in.
- Batch actions stay main-view only — they archive from the MAIN inbox, so
  offering them over the archived list would do the opposite of what it reads.
- Mobile subscribes to inbox:unarchived (its list gains the restored row); its
  own archived view remains follow-up.

Known debt: no pagination, so an archive past ~200 rows is truncated silently
in the UI; the entry's count is the deduplicated count of the rows returned.

Verified: pnpm typecheck/test/lint (0 errors), go build/vet, Go inbox suite
against a real Postgres, migrations up+down, and EXPLAIN confirming both new
indexes serve the query.

Co-authored-by: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-07-16 14:58:42 +08:00
19e52e007c MUL-4798: make Inbox notification preference updates atomic (#5451)
* fix(notifications): make preference updates atomic

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

* fix(notifications): serialize preference mutations

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

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-07-15 17:52:10 +08:00
b85bb71a58 feat: custom issue properties — typed workspace-defined fields with list-surface support (MUL-4463) (#5335)
* feat(server): custom issue properties — definitions, typed values, CLI (MUL-4463)

Workspace-level property definitions (issue_property table; 7 types:
text/number/select/multi_select/date/checkbox/url) plus a typed value bag
on each issue (issue.properties JSONB keyed by definition UUID, mirroring
the metadata machinery: single-key atomic writes, 16KB cap, GIN index).

- Definitions: owner/admin only; agent actors rejected (agents propose,
  humans confirm). 20 active per workspace, 50 options per select,
  reserved built-in names blocked, archive instead of delete.
- Values: any member or agent; per-type validation with self-correcting
  error messages that enumerate legal option ids.
- API: /api/properties CRUD + PUT/DELETE /api/issues/{id}/properties/{propertyId};
  issue responses always emit the properties bag.
- CLI: multica property list/get/create/update/archive/unarchive and
  multica issue property list/set/unset with name→id translation.
- Events: property:created/updated, issue_properties:changed.

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

* feat(web): custom properties settings tab + issue sidebar editors (MUL-4463)

- Settings → Properties: definition management mirroring the Labels tab
  (list with type badges/option chips/usage counts, create/edit dialog
  with option editor, archive/restore, 20-cap indicator). Admin-gated;
  members see a read-only catalog.
- Issue detail sidebar: custom properties join the built-in optional
  props' progressive disclosure — set values render as rows with
  type-appropriate editors (select/multi-select pickers, calendar,
  yes/no, inline input for text/number/url), unset ones live in the
  same '+ Add property' menu behind a separator. Archived definitions
  render read-only until cleared.
- Core: property types, zod schemas (lenient type strings for forward
  compat), api client methods, React Query hooks with optimistic
  single-key value writes, ws-updaters + realtime wiring for
  property:created/updated and issue_properties:changed.
- Locales: en/zh-Hans/ja/ko strings; Issue fixtures gain properties: {}.

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

* fix(properties): address MUL-4463 review round 1 — mobile CI, option guard, mutation safety, schema tolerance

- mobile: EMPTY_ISSUE_FALLBACK gains the required properties field (mobile
  typecheck was the red CI check).
- server: PATCH /api/properties/{id} rejects config updates that remove
  select options still referenced by issues (409 with a per-option usage
  census via jsonb ?); renames keep ids and pass. Integration test included.
- core: property value mutations are serialized per workspace via mutation
  scope, snapshot the bag from detail OR list caches (board surfaces have no
  detail cache — the old path overwrote whole bags with one key), roll back
  to the snapshot or invalidate on error, and the last settled mutation does
  an authoritative detail+catalog invalidate (usage counts reconcile).
- schemas: unknown-shaped property values (future server types) are dropped
  per-entry in a preprocess step instead of failing the whole IssueSchema
  and blanking lists through parseWithFallback; test updated to lock the
  tolerant behavior.
- realtime: reconnect invalidation covers the property catalog; every
  issue_properties:changed event also refreshes catalog usage counts.
- ui: number editor accepts decimals (step=any); settings usage count
  pluralizes (issue/issues) with CJK-safe plural keys.

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

* fix(migrations): renumber issue properties to 179 and build the GIN index concurrently

main's migration sequence advanced twice under this PR (167 collision, then
an upstream renumber wave that claimed 178), so issue properties now sits at
179 — verified against main's current tip by the prefix-uniqueness lint.

The properties GIN index moves to its own single-statement migration (180)
using CREATE INDEX CONCURRENTLY — a plain CREATE INDEX on the hot issue
table would block writes for the duration of the build. Mirrors the
119_user_created_at_index pattern; full-chain dry-run on a fresh database
passes through 180.

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

* feat(web): custom-property list surfaces — filter, cards, sort, board grouping (MUL-4463 M2)

Brings custom properties to the issue list surfaces on top of the M1
definitions/values core:

- Filter: per-definition sections in the Filter dropdown (select /
  multi_select options with color dots and counts; checkbox as Yes/No
  pseudo-options). OR within a definition, AND across definitions;
  client-side in applyIssueFilters, mirrored into filterAssigneeGroups
  for the assignee-grouped board. Included in active-filter count and
  Clear all.
- Cards: per-property Display toggles (cardPropertyIds) render value
  chips on board cards and list rows via CustomPropertyValueDisplay.
- Sort: SortField gains property:<id> for number/date definitions.
  Server keeps position order (fixed sort enum); the surface controller
  re-sorts client-side, swimlane/gantt reuse the same comparator.
  Date-only strings compare lexically; missing values sort last.
- Board grouping: IssueGrouping gains property:<id> for select
  definitions — one column per option (definition order) plus a
  trailing No-value column, option-colored headings. Drag-drop moves
  position via UpdateIssue and applies the value through
  useSetIssueProperty/useUnsetIssueProperty (properties are not part
  of UpdateIssueRequest). Stale persisted property groupings fall back
  to status columns.

View-store: propertyFilters + cardPropertyIds persisted via the
partialize allowlist; clearFilters resets property filters; new fields
deep-merge cleanly into pre-existing persisted snapshots.

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

* fix(properties): address MUL-4463 review round 2 — desc sort, option bucketing, archived-state reconciliation

- sort: direction now applies to value comparison only; issues without a
  value sort last in BOTH directions (the whole-array reverse flipped them
  to the front on desc). Test covers the desc+missing case.
- board: values referencing an option removed from the definition bucket
  into the No-value column instead of vanishing (unmatched column ids
  dropped the issue entirely). Defense-in-depth behind the new server-side
  in-use guard; drag-utils test locks both behaviors.
- controller: persisted propertyFilters keyed by archived/deleted
  definitions are stripped before reaching the filter predicates, and a
  persisted property sort on a non-active definition degrades to manual
  order — previously both kept silently applying while the header claimed
  otherwise. The filter badge counts only active-definition filters.

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

* feat(properties): server-side property filtering and sorting on list endpoints

Property filter/sort now execute in the database, so results are correct
across the full issue set — not just the loaded 50-per-status window
(closes MUL-4493 item 1's filter/sort half; requested on MUL-4463).

- New `properties` query param on ListIssues and ListGroupedIssues:
  JSON {definitionId: [values]} compiled to an AND-of-ORs containment
  check (double NOT EXISTS over jsonb_array_elements). One value expands
  to every storage shape it could match — string (select), array element
  (multi_select), boolean (checkbox) — so the handler stays type-agnostic.
  Guarded at 20 definitions / 50 values.
- `sort=property:<definitionId>` resolves the definition and orders by a
  typed expression (numeric CASE cast for number, NULLIF text for
  date/text/url); missing values sort last in both directions. Malformed
  ids 400; unknown/archived definitions degrade to position order instead
  of breaking stale clients.
- Frontend: the property filter and property sort ride the IssueSortParam
  window bag, so every surface (workspace + my-issues variants), query
  key, and per-status load-more page carries them automatically. The
  client-side re-sort layer is gone; applyIssueFilters keeps its property
  predicate as an optimistic-update backstop.
- Regression test seeds 55 issues and proves a match at position 55 is
  returned by a filtered 50-row page, plus sort order/missing-last,
  AND-across-definitions, and the 400/fallback sort paths.

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

* fix(properties): address review round 3 — cache reconcile, merged-scope order, GIN-indexable filter, pool loader

- Cache reconciliation: property value writes (mutation settle + WS event)
  now invalidate every issue window whose server-side shape depends on
  property values — queries filtered by `properties` or sorted by
  `property:<id>` (detected via query-key predicate), covering flat lists,
  assignee groups, and my-issues variants. Windows without property params
  keep the cheap in-place patch. Fixes stale ordering/membership/counts
  under staleTime:Infinity.
- My Issues "All" scope: merged assigned/created/involves results are
  re-sorted with a comparator mirroring the server ORDER BY semantics
  (including property sorts and missing-last, created_at DESC tiebreak) in
  both the flat and assignee-grouped merge paths — relation concatenation
  no longer overrides the user's sort.
- Filter predicate rebuilt as plain bind-parameter containment ORs
  (AND across definitions): EXPLAIN now shows BitmapOr over
  idx_issue_properties_gin (the correlated jsonb_array_elements form
  defeated the index). Alternatives capped at 256 bind params.
- Property-grouped board gains a pool loader strip: one sentinel per
  status that still has server rows, keeping every issue reachable until
  per-column pagination lands (MUL-4493).
- Windowing regression test hardened: explicit positions + an assertion
  that the unfiltered first page excludes the target (the old fixture tied
  at position 0 and the created_at DESC tiebreak put the target on page
  one, proving nothing).
- Rollback safety: /api/properties 404 (old server) degrades to an empty
  catalog instead of a query error, which also keeps property params from
  ever being sent to pre-property servers; migration 179's CHECK
  constraints switch to NOT VALID + VALIDATE so the exclusive lock is
  instantaneous.

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

* fix(properties): harden concurrency and cache coordination from clean-room review

Backend (MUL-4762 F1/F4/F5):
- withPropertyLock: pg_advisory_xact_lock helper; definition create/update
  and value writes now serialize config-vs-value and cap-vs-insert races
  (workspace-level 'props:' lock + per-definition 'prop:' lock, ordered).
- propertySortExpr degrades archived definitions to position sort.

Frontend (F2/F3/F6):
- onIssuePropertiesChanged invalidates plain assignee-group caches too.
- Property value mutations cancel list refetches in onMutate and roll back
  only the touched key against the current bag (concurrent WS writes to
  other keys survive a failed write).
- useUpdateIssue reconcile drops the stale properties bag from the server
  snapshot; the property pipeline owns that field.
- Surface controller passes persisted property filters/sorts through
  until the catalog query settles (cold cache no longer strips them).

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

* fix(properties): open_only branch honors the properties filter

ListOpenIssues takes the parsed AND-of-ORs containment groups as a single
jsonb properties_filter param and unrolls them with a static double
NOT EXISTS; previously the open_only path parsed the properties param and
silently dropped it (clean-room review F7a).

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

* fix(properties): toast on failed board drag to a property column

Property-column drags rolled the card back silently on failure; mirror
the status/assignee drag path (use-issue-surface-actions) so the
snap-back is explained (clean-room review F3, drag half).

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

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-07-15 12:00:28 +08:00
bf288349f6 feat(project): add start_date and due_date fields (MUL-4388) (#5313)
Projects become schedulable planning objects alongside their issues: add
optional start_date / due_date, mirroring issue.start_date / issue.due_date.
This is only the first slice of #5227 — labels, metadata, and the editable
metadata UI are still out of scope.

- migration 166: two nullable DATE columns on `project` (calendar days, no
  FK/index — matches the issue end-state after migration 112)
- sqlc CreateProject / UpdateProject carry the dates; UpdateProject uses
  narg so an explicit null clears
- handler: parse YYYY-MM-DD (400 on bad format), rawFields-presence clear on
  update, and the hand-scanned SearchProjects query returns the columns
- CLI: `project create/update --start-date/--due-date` (empty clears on update)
- frontend + mobile types/zod schemas: the two new schema fields are
  nullable().default(null) so a project from an older backend (frontend
  deploys before backend) parses to null instead of degrading the batch to
  the empty fallback; added a search schema drift test
- projects skill / CLI docs

Part of #5227

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-07-13 13:22:02 +08:00
a19e60a9e6 feat(chat): support images/files in agent chat replies (MUL-4287) (#5164)
* feat(chat): support images/files in agent chat replies (MUL-4287)

Agents can now attach images/files to their chat replies, matching how
comment attachments already work. The write-side gap was that the assistant
chat_message is synthesized server-side from the completion callback's text
output and never bound any attachments.

Backend:
- migration 150: nullable attachment.task_id (+ partial index), the transient
  handle that ties an agent's in-run upload to the reply it produces.
- POST /api/upload-file accepts task_id: gated to the task's own agent, in
  this workspace, on a chat task; tags the row with task_id + chat_session_id.
- CompleteTask (chat branch) binds the task's still-unclaimed attachments to
  the assistant message via BindChatAttachmentsToMessage (rejects rows already
  owned by an issue/comment/chat_message). An empty-output reply that produced
  files still creates a message so the images have an owner. FailTask binds
  nothing.

CLI:
- `multica attachment upload <path>` uploads a file for the current chat task
  (task from MULTICA_TASK_ID or --task) and prints id / markdown_url / a
  ready-to-paste markdown snippet.

Prompt:
- web/mobile chat prompt tells the agent how to attach a file to its reply.

Mobile:
- chat:done handler now always invalidates the messages list so attachments
  (absent from the event payload) refetch; mirrors web's self-heal.
- chat bubbles render standalone attachment cards via the existing
  CommentAttachmentList (dedup vs inline references), matching web.

Web/desktop needed no change — they already render message.attachments inline
and via AttachmentList, and self-heal on chat:done.

Tests: upload permission/isolation, bind-on-complete, empty-output+attachments,
FailTask no-bind, null task_id untouched, already-owned not stolen, CLI output
contract, mobile refetch-on-done.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(chat): address review blockers on chat reply attachments (MUL-4287)

Two final-review blockers on PR #5164:

1. Mobile inline dedup only checked raw `url`, so an attachment referenced
   inline via `markdown_url` (exactly what the CLI snippet emits) rendered
   twice — once inline, once as a standalone card. Reuse the core
   `contentReferencesAttachment` helper so dedup covers every real reference
   form (stable /api/attachments/<id>/download path, url, download_url,
   markdown_url), matching web's AttachmentList. Extracted the filter into a
   pure `lib/attachment-dedup.ts` so it is unit-testable, and added a
   regression test covering `content` containing `attachment.markdown_url`
   (plus the other URL forms and same-identity sibling dedup).

2. CLI `attachment upload` emitted `![...]` image markdown for every file,
   producing a broken-image snippet for non-images. Emit image markdown only
   for image/* content types and a plain link otherwise, with a CLI contract
   test for both.

Approved scope otherwise unchanged.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(chat): renumber attachment_task_id migration 150 -> 157 after main merge (MUL-4287)

Merged latest main; main renumbered its migrations and now occupies 150-156,
so 150_attachment_task_id collided with 150_agent_task_coalesced_comments and
would fail TestMigrationNumericPrefixesStayUniqueAfterLegacySet. Renamed to the
next unique prefix (157). No content change; migrate up applies cleanly.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(chat): render agent-produced files as attachment cards, not raw links

The chat upload command handed the agent a bare `[name](url)` markdown
snippet. Pasted mid-sentence it renders as a plain text link (not a card),
and the referenced URL hides the auto-bound standalone attachment — so a
file the agent produced could end up showing as nothing.

Return the block-level `!file[name](url)` card syntax instead (images keep
`![name](url)` inline), and markdown-escape the filename so names with `[`/`]`
don't truncate the label. The prompt and CLI help now state the file
auto-attaches below the reply and the snippet is optional, only for placement.

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

* fix(chat): soften message-list scroll fade (32px → 16px)

The 32px edge fade washed out full-bleed content (HTML / image previews)
at the list edges. Halve the fade distance so it barely grazes previews
while still hinting at more content above/below.

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

* fix(chat): renumber attachment_task_id migration 157 -> 158

main landed 157_agent_task_delivered_comments while this branch was open,
colliding on prefix 157 and failing TestMigrationNumericPrefixesStayUniqueAfterLegacySet.
Bump this PR's migration to the next free prefix (158). Rename only; the
migration body (nullable attachment.task_id + partial index) is unchanged.

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

* fix(chat): pin attachment upload to the token's task; build index concurrently

Two code-review findings on the chat-attachment path (MUL-4287):

- Isolation/privacy: POST /api/upload-file only checked the form task_id
  belonged to the caller's agent, not that it matched the task-scoped token's
  authoritative X-Task-ID. A run authorized for task A could tag an attachment
  onto task B (another chat task of the same agent, possibly another user's
  session), binding it into that reply on completion. Require the form task_id
  to equal the server-set X-Task-ID; add a same-agent/other-task 403 regression.

- Migration: split the task_id lookup index into its own migration (159) built
  with CREATE INDEX CONCURRENTLY (repo convention) — it cannot share a
  multi-command file with the ADD COLUMN in 158.

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

* fix(chat): enforce task-token source on attachment upload; drop transient task_id FK (MUL-4287)

Addresses the two remaining Preflight BLOCKERs on PR #5164.

Security (file.go): the task_id upload path compared the form task_id to
X-Task-ID but did not require X-Actor-Source=task_token. A normal JWT/mul_ PAT
leaves that header empty and the middleware does NOT strip a client-forged
X-Task-ID; resolveActor's fallback accepts a valid X-Agent-ID+X-Task-ID pair.
So a member who learned a task ID could forge both and inject an attachment
onto another chat task's assistant reply (cross-session/privacy leak). Now the
branch requires X-Actor-Source=task_token first (mirrors chat_history.go's
load-bearing boundary), then pins to the middleware-injected X-Task-ID. Tests
now go through the real task-token headers and add a forged-JWT-403 regression.

Migration (158): task_id is a transient binding handle (written once at upload
against an already-validated task, read only during that task's own
completion; durable owner is chat_message_id). There is no app-layer path that
hard-deletes agent_task_queue rows, and orphan uploads are already reaped by
attachment.chat_session_id's ON DELETE CASCADE — so an FK here would only add a
cascade dependency the app never relies on plus write overhead on the hot
attachment table. Drop the FK; task_id is now a plain UUID column. Added a
regression test that an unbound task-tagged upload is reaped on chat_session
delete. Index (159, CONCURRENTLY) unchanged.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(mobile): align !file card preprocess with web parser + CLI escaped labels (MUL-4287)

Howard final-review blocker: mobile's `!file[...]` preprocess didn't keep up
with the CLI's file-card output, so agent-produced non-image files rendered
nowhere on mobile.

- `FILE_LINE_RE` used `[^\]]+` for the label, so the CLI's escaped-bracket
  output `!file[a\]b.pdf](url)` (cmd_attachment.go escapeMarkdownLabel) never
  matched — the line stayed literal AND `standaloneAttachments` still hid the
  fallback card (the URL is in `content`), so the file showed nowhere.
- Align the matcher with web's `packages/ui/markdown/file-cards.ts`: label
  allows backslash-escaped metacharacters (ReDoS-safe class), and the URL is
  restricted to the same allowlist (site-relative /uploads + /api/attachments/
  <UUID>/download, plus absolute http(s)); disallowed schemes stay plain text.
- Unescape the label to the real filename, then re-escape only the chars that
  would break a markdown LINK label (mobile emits `[📎 name](url)`, re-parsed
  by the renderer — unlike web's HTML data-filename), so a raw `]` never
  truncates the link text.

No dedup change: once the inline `!file` renders, hiding the standalone card is
correct. Added focused unit tests covering the escaped-label case, parens/
backslash unescape, the site-relative URL form, and disallowed-scheme rejection.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Claude Opus 4.8 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-07-13 09:15:36 +08:00
e10bb3fff0 fix(chat): unify unread badge counting across sidebar, mobile, and thread list (MUL-4286) (#5239)
The aggregate chat unread badge used two different definitions: web/
desktop's sidebar summed unread messages while mobile's tab badge (and
the since-removed ChatFab badge it mirrored) counted sessions — the same
account state showed e.g. 5 on web and 2 on iOS. The sidebar also summed
ALL sessions while the thread list zeroes the row being viewed, so a
reply landing in the open conversation flashed a sidebar count with no
matching row.

- packages/core/chat/unread.ts: countUnreadChatMessages() as the single
  shared definition (IM-style message total, optional viewed-session
  exclusion); pure so mobile can import it.
- app-sidebar: use the helper and exclude the actively-viewed session,
  but only while a chat surface is actually showing it (chat route or
  floating window open) — a remembered selection with both surfaces
  closed still counts, since nothing will auto mark-read there.
- mobile: tab badge switches to the shared message count (99+ cap like
  the sidebar), ChatSessionSchema parses unread_count, and markRead's
  optimistic patch zeroes unread_count alongside has_unread.

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-07-11 02:46:12 +08:00
cb87dd106b feat(chat): task-owned direct-chat input batches + explicit no_response outcome (MUL-4351) (#5195)
* feat(chat): task-owned direct-chat input batches + explicit no_response outcome (MUL-4351)

Direct (web/mobile) chat no longer uses the last-assistant-row as an implicit
input cursor. Each direct send now owns an immutable input batch:

- agent_task_queue.chat_input_task_id makes a task the owner of the user
  messages it must consume; the send path creates the task + user message +
  attachment bindings + session touch in one transaction, and the daemon is
  notified only after commit. A claim reads exactly that batch, so a message
  that arrives mid-run belongs to the next task and is never absorbed.
- Auto-retry inherits the root input owner and is queued at a bumped priority,
  created inside FailTask's transaction so no newer chat task can jump ahead.
- CompleteTask writes exactly one assistant outcome inside the completion
  transaction: a normal message, or a visible no_response outcome (with a
  non-empty English fallback) when the final output is empty. The write failing
  rolls the completion back and the handler returns 5xx so the daemon retries;
  the status CAS keeps it idempotent. chat:done carries message_kind.
- Web/desktop/mobile render no_response as a localized 'no text reply' state
  (keeping the tool timeline), suppress Copy, keep it unread, and keep the
  session-list preview non-blank.
- Legacy/channel tasks (chat_input_task_id NULL) keep the trailing-message
  selector, so a rolling deploy never replays Slack/Lark history.

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

* fix(chat): scope no_response to direct tasks; don't cancel task on input read error (MUL-4351)

Addresses PR review (Niko):
- writeChatCompletionOutcome only writes a no_response row for task-owned
  direct tasks (chat_input_task_id set). Legacy/channel (Slack/Lark) tasks keep
  the prior behavior: empty output writes no assistant row, so chat:done carries
  empty content and the channel outbound silently drops it — the no_response
  fallback body never reaches an external channel.
- The daemon claim distinguishes a genuine zero-input batch from a failed
  input read: on ListChatInputMessages / ListChatMessages error it returns 5xx
  and preserves the dispatched task for redelivery instead of cancelling a valid
  task on a transient DB error.

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

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-07-10 16:22:42 +08:00
cb68669c73 feat(composio): gate MCP apps behind feature flag (#4876)
* feat(composio): server-side connect flow + connections REST (Notion MVP) (MUL-3720) (#4608)

* feat(composio): server-side connect flow + connections REST (Notion MVP) (MUL-3720)

Compose the merged server/pkg/composio SDK into a user-facing connection
manager: signed-state connect handshake, local user_composio_connection
mirror, idempotent disconnect, and a per-user MCP session helper (not yet
wired into task dispatch).

- migration 127_user_composio_connection (no FK/cascade, per DB rules)
- sqlc queries: upsert (idempotent on user_id+connected_account_id), list
  active, owner-scoped get, mark revoked
- internal/integrations/composio: signed HMAC-SHA256 state, BeginConnect,
  CompleteCallback (idempotent upsert), ListConnections, Disconnect
  (upstream 404 = idempotent success), CreateMCPSession (no-op when empty,
  pins connected_accounts per toolkit), CallbackRedirect
- REST handlers under /api/integrations/composio (user-scoped, 503 when
  COMPOSIO_API_KEY unset): connect/init, callback (302), connections list,
  delete
- router wiring gated by COMPOSIO_API_KEY; COMPOSIO_AUTH_CONFIGS_JSON maps
  toolkit->auth_config (MVP: notion); state secret from COMPOSIO_STATE_SECRET
  or derived from JWT_SECRET; callback base from COMPOSIO_CALLBACK_BASE_URL
  or MULTICA_PUBLIC_URL
- tests: state (expire/tamper/wrong-secret), service (mapping, callback
  idempotency, non-success, disconnect owner/404 idempotency, MCP pin),
  handlers (httptest), redact regression for Bearer mcp_ tokens

MVP scope: Notion only; no task-dispatch overlay, sharing, or webhook
event handling (later stages).

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

* fix(composio): bind callback account to user + idempotent revoked disconnect (MUL-3720)

Address PR 4608 review (CHANGES_REQUESTED):

- callback: verify connected_account_id with Composio before mirroring it.
  The signed state only proved user/toolkit/exp, so a valid state paired with
  a tampered connected_account_id would be written verbatim. CompleteCallback
  now calls ListConnectedAccounts and fails closed (ErrAccountVerification)
  unless the account belongs to the state's user (composio_user_id == multica
  user id) and was created under the toolkit's auth config. No row is written
  on mismatch / unknown account / upstream error.

- disconnect: short-circuit to a no-op when the local row is already revoked,
  before touching upstream. Previously a second DELETE re-hit Composio and a
  non-404 upstream error surfaced as a 502, breaking the 204-idempotent
  contract.

- CreateMCPSession: document the v1 single-active-connection-per-(user,toolkit)
  constraint and make duplicate selection deterministic (newest-wins, rows are
  connected_at DESC) instead of order-dependent map overwrite. Stage 3 owns the
  real single-account-enforcement vs multi-account-shape decision.

Tests: tampered/wrong-auth-config/unknown-account callback rejection, revoked-row
disconnect no-op (asserts upstream not re-hit). composio pkg 85% coverage; all
green.

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

* feat(composio): list all toolkits + dynamic auth-config resolution (MUL-3720)

Yushen's follow-up to the Notion MVP: surface the full Composio toolkit
catalog, render it in Settings, and drop the static env mapping in favor of
dynamic auth-config discovery.

Config correctness (per Composio docs):
- Remove COMPOSIO_AUTH_CONFIGS_JSON entirely. The toolkit→auth_config mapping
  is now resolved at request time from the project's /auth_configs (cached,
  5-min TTL), so enabling a toolkit is a dashboard action, not a redeploy.
- Do NOT add COMPOSIO_PROJECT_ID. The project API key (x-api-key) authenticates
  to exactly one project; the project is resolved from the key. Only org-level
  endpoints use x-org-api-key, which this integration never calls.

Backend:
- SDK: server/pkg/composio/auth_configs.go — ListAuthConfigs (toolkit_slug,
  is_composio_managed, show_disabled, limit, cursor).
- service: dynamic resolver (authConfigMap cache; betterAuthConfig prefers a
  custom/white-label config over Composio-managed, newest wins); BeginConnect
  and CompleteCallback resolve via it; ListToolkits fetches the full catalog
  (paginated, capped) annotated with connectable = has an enabled auth config,
  connectable-first ordering.
- handler + route: GET /api/integrations/composio/toolkits (user-scoped, 503
  when COMPOSIO_API_KEY unset) returning slug/name/logo/category/connectable.

Frontend:
- core: ComposioToolkit/ComposioConnection types, api client methods, and
  composio query options (@multica/core/composio).
- views: Settings → Integrations now has a Composio section rendering every
  toolkit as a card with search. Connect is gated on `connectable`;
  non-connectable toolkits show a muted "not configured" hint instead of a
  dead button. Connected toolkits show a badge + Disconnect (with confirm).
- i18n: composio block added to en/zh-Hans/ja/ko settings.

Tests: SDK + service (dynamic resolution, custom-over-managed preference,
connectable flag, resolver-error soft-degrade) and handler toolkits endpoint;
composio pkg 85.7% coverage. go build/vet/gofmt clean; core+views typecheck,
core+views lint, and core tests (691) all green.

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

* fix(composio): close cross-toolkit callback fail-open by signing auth_config_id into state (MUL-3720)

Re-review blocker: CompleteCallback resolved the toolkit's auth config at
callback time and ignored a resolve error/empty result, while
verifyAccountOwnership skipped the auth-config comparison when the expected
value was empty. A user could then pass another toolkit's connected_account_id
into this toolkit's callback — the owner check passed and it was written under
the wrong toolkit_slug/account binding.

Fix: the auth_config_id is already resolved in BeginConnect (before the state
is signed), so sign it into the state and compare it exactly at callback. No
re-resolve, no fail-open. verifyAccountOwnership now fails closed when the
expected auth config is empty (rejects instead of skipping) and requires an
exact match — closing the cross-toolkit binding gap.

Tests: state round-trips auth_config_id; BeginConnect signs it; callback
rejects wrong/cross-toolkit auth config and an empty (no-mapping) auth config
fails closed. composio pkg 85.2% coverage, all green.

Frontend (non-blocking): the Composio settings tab now surfaces an error when
the connections query fails instead of silently rendering everything as
unconnected.

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

* fix(composio): hide Settings section entirely when integration unconfigured (MUL-3720)

Decision (option 2, hide-then-merge): don't show a card that leaks the internal
COMPOSIO_API_KEY env-var name to every end user. IntegrationsTab now gates the
whole Composio section (heading + body) on the toolkits query — a 503 means the
key is unset, so the section is withheld instead of rendering the not-configured
card. Admin-only setup guidance is a later, role-gated affordance.

Removed the notConfigured card (and now-unused ApiError import) from
ComposioTab; it only mounts when configured. views typecheck + lint clean.

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

---------

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

* feat(composio): Stage 2 frontend polish — callback toast, last_used & expired UI, e2e (MUL-3718) (#4688)

* feat(composio): callback toast + refresh, last_used & expired UI, e2e (MUL-3718)

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

* fix(composio): real callback redirect route + StrictMode-safe toast dedup (MUL-3718 review)

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

---------

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

* fix(composio): callback endpoint should not require Multica auth (MUL-3843) (#4709)

* fix(composio): move OAuth callback out of the Auth group (MUL-3843)

Composio 302-redirects the browser to /api/integrations/composio/callback
at the end of the OAuth flow, but PR #4608 mounted it inside the cookie-auth
middleware group. When the session cookie is absent (expired session,
SameSite=Strict / Safari ITP, private window, self-hosted callback subdomain)
the Auth middleware returned a hard 401 and a JSON blob instead of the
settings redirect, breaking the flow.

Identity never came from the cookie anyway: it is carried by the HMAC-signed
state param that CompleteCallback verifies (signature, expiry, replay) and
cross-checked by verifyAccountOwnership; h.Composio == nil still 503s. So the
callback is registered alongside the other public OAuth/webhook routes; the
other four composio endpoints stay session-gated.

Refs MUL-3843, MUL-3715.

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

* fix(composio): correct stale callback routing comments (MUL-3843)

The package header and ComposioCallback doc comments still described the
callback as sitting under the Auth middleware group. After the route was
moved out (this PR), update both to state it is a public route whose identity
comes from the signed state — addressing review nit from 张大彪.

Refs MUL-3843.

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

---------

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

* feat(composio): inject MCP overlay into agent runtime at task dispatch (MUL-3721) (#4704)

Stage 3 of the Composio epic. Wires the per-user Composio MCP session into
every agent task so the agent process sees the initiator's connected tools
without any prompt-time plumbing.

Server side
  - Migration 128 adds agent_task_queue.runtime_mcp_overlay JSONB plus a
    BEFORE-UPDATE trigger that wipes the column on any transition into a
    terminal status (completed / failed / cancelled). A trigger is the single
    source of truth — future queries that flip status cannot bypass it.
  - composio.Service.BuildTaskOverlay(userID) reuses CreateMCPSession and
    emits the Claude-style { mcpServers: { composio: { type: http, url,
    headers } } } shape the daemon's existing sidecar generators consume.
    Returns (nil, nil) on zero active connections so we never burn a
    Composio session for a user with nothing to call.
  - TaskService grows a Composio ComposioOverlayBuilder seam, wired in
    router.go after composiointeg.NewService succeeds. Five enqueue paths
    (issue / mention / quick-create / chat / auto-retry) attach the overlay
    after CreateAgentTask returns and before the daemon is notified — so
    every claim reads a settled row, with no second daemon hop. Best-effort:
    a builder failure logs and proceeds with no overlay.
  - resolveInitiatorFromTriggerComment derives the initiator user from the
    trigger comment when it was authored by a member. Agent-authored
    triggers are not treated as initiators (their connected-apps view is
    empty by construction).

Daemon side
  - handler/daemon.go claim path merges task.runtime_mcp_overlay onto
    agent.mcp_config via mergeMCPOverlay before populating
    TaskAgentData.McpConfig. Overlay wins on server-name collisions
    because it carries the live user-scoped session URL. Errors fall back
    to the agent config unchanged — a bad overlay must not surprise-disable
    saved MCP tools. The existing execenv sidecar generators (cursor /
    codex / openclaw / opencode / hermes-kiro) need no changes: they keep
    consuming the merged result through TaskAgentData.McpConfig.

Tests
  - 9 merge cases (mcp_overlay_test): both-nil short-circuit, agent-only
    pass-through, overlay-only canonicalization, two-side merge, name
    collision (overlay wins), top-level key preservation, malformed agent
    fallback, malformed overlay fallback, non-object server rejection.
  - 4 dispatch cases (composio): zero-connections returns nil without
    CreateSession, happy-path emits the right shape with the right user
    id, empty-URL defensive branch, SDK error surfacing.
  - 4 TaskService helper cases: nil Composio is a no-op (Queries-safe),
    invalid initiator does not call the builder, nil overlay skips the
    UPDATE, builder error swallowed without panic.
  - Migration 128 verified to roll up + down + up cleanly against the test
    database.

Out of scope (deferred): assignment-triggered enqueue paths with no
trigger comment get no overlay attached today (no initiator UUID flows
through enqueueIssueTask in that case). Retry paths recompute the overlay
fresh from the parent's initiator_user_id instead of inheriting the bearer
from the parent row, so a stale token can never resurface on a retry.

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

* feat(composio): per-agent allowlist + originator-scoped MCP overlay (MUL-3869) (#4736)

* feat(composio): per-agent allowlist + originator-scoped MCP overlay (MUL-3869)

Stage 3.1 of the Composio epic (MUL-3721 parent). PR #4704 wired in the
runtime_mcp_overlay column and a per-task dispatch hook; this change
inverts the default from "all-on" to opt-in and locks the overlay to the
agent owner's own connected apps:

- Agents carry composio_toolkit_allowlist TEXT[]. NULL or [] => no MCP.
  Owner-only read/write; non-owner GET/PUT silently redacts/drops the
  field (same shape as mcp_config).
- agent_task_queue carries originator_user_id UUID. Set from the
  top-of-chain HUMAN at every enqueue path:
    * issue/mention comment by member  -> author_id
    * issue/mention comment by agent   -> inherit via comment.source_task_id
                                          -> parent task originator_user_id
    * quick-create                     -> requester_id
    * chat                             -> initiator_user_id
    * retry                            -> SQL-inherited from parent row
    * autopilot                        -> NULL (system-driven)
- BuildTaskOverlay (composio dispatch) now takes (ctx, originatorUserID,
  agent) and short-circuits on five gates: invalid originator,
  originator != agent.owner_id, empty allowlist, empty intersection of
  allowlist ∩ active connections, defensive empty session URL. Composio
  CreateSession is called with BOTH `toolkits.slugs` (the intersection)
  AND `connected_accounts` (the pinned account ids), narrowing the
  tool-router twice.
- The originator-vs-owner gate closes the agent-fanout privacy hole: any
  workspace member who can @-mention a public agent used to project the
  owner's connected apps into their run. Now the overlay only mounts
  when the human at the top of the chain IS the agent owner.

Tests:
- dispatch_test.go covers all 5 gates plus uppercase/whitespace slug
  normalisation.
- task_runtime_mcp_overlay_test.go covers the no-op gates of the new
  applyRuntimeMCPOverlay signature.
- agent_composio_allowlist_test.go (handler): owner roundtrip
  (list/empty/null), workspace-admin silent-drop, owner-only GET
  visibility, pure normaliseComposioToolkitAllowlist.
- resolve_originator_test.go (service, DB-backed): member-authored,
  agent-authored inherits via comment.source_task_id, invalid id.

Migration 129 up/down/up verified against docker postgres.

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

* chore(composio): gofmt + regenerate sqlc with v1.31.1 (MUL-3869 review nits)

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

---------

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

* fix(composio): accept nested connected account auth config

* feat(views): creator-only MCP tab for per-agent Composio allowlist (MUL-3870) (#4743)

Stage 3.2 frontend on top of the Stage 3.1 backend (MUL-3869, 4708dba97).
Adds an agent-detail tab that lets the agent owner pick which of their own
active Composio connections this agent may mount as MCP servers, writing the
selection to agent.composio_toolkit_allowlist via the existing PUT /api/agents.

- core/types: composio_toolkit_allowlist (+ _redacted) on Agent; tri-state
  composio_toolkit_allowlist on UpdateAgentRequest (omit/no-change, null/clear,
  array/replace), matching the backend contract.
- core/agents: useUpdateAgentAllowlist - optimistic mutation hook (patches the
  cached workspace agent list, rolls back on error, invalidates on settle).
- views: AgentMcpTab renders the owner's active connections as checkboxes;
  empty state links to Settings -> Integrations; defensive redacted state.
- views: wired into AgentOverviewPane as tab "composio_mcp", labeled "MCP Apps"
  to disambiguate from the existing raw-JSON "MCP" (mcp_config) tab. The entry
  is gated to the creator (currentUserId === agent.owner_id), matching the
  backend's owner-only read/write of the allowlist.
- i18n: tabs.composio_mcp + tab_body.composio_mcp.* in en/ja/ko/zh-Hans.
- tests: agent-mcp-tab.test.tsx (gating, toggle->allowlist body, active-only,
  empty, redacted); e2e/agent-mcp.spec.ts (creator sees tab + PUT body,
  non-creator hidden) with Composio + agent endpoints mocked at the boundary.

Note: the product spec says "creator"; the schema has no creator_id - the
backend gate and redaction are keyed on owner_id, so the tab uses owner_id.

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

* fix(composio): mount remote MCP for codex

* feat(agents): agent invocation permission system (MUL-3963) (#4844)

* feat(agents): agent invocation permission system (permission_mode + invocation targets)

MUL-3963: split who may INVOKE an agent out of the overloaded visibility
column into an explicit, extensible model on feature/composio-integration.

- DB: agent.permission_mode (private|public_to) + agent_invocation_target
  table (workspace/member/team targets) + lossless backfill from visibility
  (migration 130).
- canInvokeAgent: owner-only for private (NO admin bypass, NO A2A bypass);
  public_to honours the allow-list; A2A judged by the top-of-chain originator.
- All trigger paths rewired: issue assign, comment @agent/@squad, chat,
  quick-create, autopilot, squad leader, child-done.
- Agent API: permission_mode + invocation_targets on responses and
  create/update (owner-only writes); legacy visibility kept as a derived field
  so old clients never see a permission widening.
- Composio: BuildTaskOverlay now FOLLOWS invocation permission and uses the
  agent OWNER connection (removed the originator==owner gate); front-end warns
  when a shared agent enables Composio apps.
- CLI: --permission-mode / --public-to-workspace / --public-to-member (legacy
  --visibility still mapped).
- Frontend: AccessPicker (Private / workspace / specific people / team soon),
  permission rules mirror canInvokeAgent, Composio warning banner.
- Tests: migration backfill, admin cannot invoke others private, public_to
  workspace/member whitelist, A2A by originator, Composio overlay uses owner
  connection.

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

* feat(agents): stackable, mixed public_to invocation targets (MUL-3963)

Follow-up on PR #4844: public_to now supports selecting MULTIPLE, MIXED
targets on one agent (e.g. Public to workspace + specific people + team),
with canInvokeAgent admitting on ANY matching target (OR).

- Frontend AccessPicker: reworked from a single exclusive kind into a
  stackable multi-select — an "Everyone in workspace" toggle, a member
  multi-select checklist, and a (disabled, v1) team placeholder can be
  combined freely. Emits the full union of selected targets; empty union
  collapses to Private. Existing team targets are preserved across saves.
  Added the access.public_group locale string (en/zh-Hans/ja/ko).
- Backend already supported this (agent_invocation_target is multi-row per
  agent; create/update take a target ARRAY and batch-replace the whole
  allow-list; canInvokeAgent OR-matches). Added tests to lock it in:
  mixed member+team targets, overlapping-member batch replace, and
  workspace+member stacking then narrowing.

Refs MUL-3963.

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

* fix(agents): address review on invocation permission (MUL-3963)

张大彪 review on PR #4844 — three blockers + product ruling + nits:

1. Migration 130: drop the FK/cascade on agent_invocation_target
   (agent_id, created_by) per the Multica no-FK rule; relationships are now
   maintained in the app layer (matching MUL-3515 §4). Added
   DeleteAgentInvocationTargetsByArchivedRuntimeAgents and call it before
   DeleteArchivedAgentsByRuntime in all three runtime-delete paths
   (runtime.go x2, runtime_profile.go) so hard-deleting agents can't orphan
   target rows.
2. revokeAndRemoveMember: prune the leaving member's member-target grants
   (DeleteAgentInvocationTargetsByMember) in the same tx as the member-row
   delete, so a re-invited user can't reclaim a stale invocation grant.
3. Empty public_to is a phantom — parsePermissionInput now normalises a
   public_to with no resolvable targets to a single workspace target, so
   `--permission-mode public_to` alone (and any empty target array) means
   "public to workspace" instead of "shared but nobody can run it".

Product ruling: the system/no-human-originator → workspace-target path in
canInvokeAgent is a deliberate, documented exception (webhook/system/
workspace-wide automation); member/team targets still fail closed without a
resolved originator. Documented in code + locked with a test.

Nits: refreshed the stale "originator must be owner" comments — models.go
(via migration 130 COMMENT ON COLUMN + sqlc regen for composio_toolkit_allowlist
and originator_user_id) and agent-mcp-tab.tsx — to the owner-connection +
invocation-permission rules.

Tests: member remove/re-add regression, system workspace exception + member
fail-closed, empty public_to → workspace (plus the earlier mixed/overlap/
batch-replace suite). Migration 130 applied to the test DB; Go handler/service/
composio suites green; views typecheck clean.

Refs MUL-3963.

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

* fix(agents): scope member invocation-target cleanup to one workspace (MUL-3963)

张大彪 3rd review — cross-workspace permission bug + comment nits:

- DeleteAgentInvocationTargetsByMember was a GLOBAL delete by user id, so
  removing a user from workspace A also wiped their member-target grants on
  agents in workspace B. Scoped it to a single workspace by joining through
  agent.workspace_id; revokeAndRemoveMember now passes (workspaceID, userID).
- Regression test TestRevokeMember_InvocationTargetCleanupIsWorkspaceScoped:
  same user allow-listed by agents in two workspaces; removal from one leaves
  the other workspace's target intact.
- Nits: refreshed the remaining stale "originator == agent.owner_id" /
  "owner-vs-originator" comments — CreateRetryTask (agent.sql, regenerated),
  and the AgentResponse allowlist doc + ListAgents/UpdateAgent redaction
  rationale in agent.go — to the owner-connection + invocation-permission rule.

Migration 130 applied to the test DB; Go handler/service/composio suites green;
go vet clean.

Refs MUL-3963.

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

---------

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

* fix(agents): agent access owner-only editable, read-only for others (MUL-3963) (#4853)

* fix(agents): make agent access owner-only editable, read-only for others (MUL-3963)

Interaction bug: a non-owner (incl. workspace admin) could open the AccessPicker
and set an agent public — the backend silently ignored it and the UI bounced
back to private. Access is owner-only, so non-owners must see a read-only state
and the backend must reject real changes explicitly.

Frontend:
- AccessPicker renders a static, non-interactive read-only state when the
  viewer is not the owner: the current access value + a lock affordance + a
  tooltip "Only the agent owner can change who can run this agent." No clickable
  trigger is rendered, so a non-owner can never open a control the backend would
  reject (the GitHub/Notion pattern for permission settings you can see but not
  edit). The editable multi-select picker is unchanged for the owner.
- agent-detail-inspector gates the picker on ownership specifically
  (currentUserId === agent.owner_id), NOT the general canEdit (which also admits
  admins, who may edit other fields but not access).
- New locale key access.owner_only_readonly (en/zh-Hans/ja/ko).

Backend:
- UpdateAgent now returns an explicit 403 when a non-owner submits a REAL
  permission change (permissionInputChangesAgent compares requested mode +
  target set against the persisted state); a no-op resubmit (admin PATCH-as-PUT
  echoing unchanged permission) is still tolerated so admin edits of other
  fields keep working. Replaces the previous silent-drop that caused the bounce.

Tests:
- access-picker.test.tsx: non-owner gets a non-interactive read-only display
  with the owner-only tooltip; owner gets an interactive picker; owner can pick
  a member and stack workspace + member.
- TestUpdateAgent_AccessChangeIsOwnerOnly: admin real change → 403; admin no-op
  resubmit → 200; admin editing other fields → 200; owner change → 200.

Incidental: fixed a pre-existing base typecheck break in
slash-command-suggestion.test.tsx (stray `signal` arg not in the suggestion
items type) that otherwise fails the whole @multica/views typecheck.

Refs MUL-3963.

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

* fix(agents): compare legacy visibility, not expanded permission, for no-op detection (MUL-3963)

PR #4853 review: permissionInputChangesAgent expanded a legacy-only
visibility:"private" into a real private permission and compared it against the
agent's actual permission. A member-only public_to agent derives legacy
visibility "private", so an admin PATCH-as-PUT echoing visibility:"private"
while editing another field was misread as a public_to→private downgrade and
rejected with 403 — contradicting the "unchanged permission no-op is allowed"
contract.

Fix (per review): when a request carries ONLY legacy `visibility` (no
permission_mode / invocation_targets), derive the agent's CURRENT legacy
visibility from its real targets and compare the legacy string values. Equal =
no-op (allowed); a real legacy change (e.g. "workspace") still returns 403.
Requests that carry permission_mode / invocation_targets keep the precise
mode+target comparison.

Regression test TestUpdateAgent_LegacyVisibilityNoOpForMemberOnlyPublicTo:
member-only public_to agent — admin submitting visibility:"private" + a
non-permission field → 200 with targets unchanged; admin submitting
visibility:"workspace" → 403.

Go handler/composio suites green; migration 130 applied; go vet clean.

Refs MUL-3963.

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

---------

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

* feat(composio): brief agents on connected apps

* feat(composio): gate MCP apps behind feature flag

* fix(mobile): parse agent invocation permissions

* fix(tests): update agent fixtures for access fields

---------

Co-authored-by: multica-agent <[email protected]>
Co-authored-by: Multica Eve <[email protected]>
Co-authored-by: Eve <[email protected]>
Co-authored-by: Eve <[email protected]>
2026-07-03 14:18:43 +08:00
a123dfc2df MUL-3508: stage sub-issues so the parent wakes per stage, not per child (#4410)
* feat(issues): stage sub-issues so the parent wakes per stage, not per child

Sub-issues under a parent can be grouped into ordered stages (issue.stage).
The child-done -> parent notification + assignee wake now fire only when a
stage barrier closes: every sub-issue in the lowest unfinished stage has
reached a terminal status (done/cancelled). An unstaged sibling set is one
implicit stage, so the parent is woken once when the last sub-issue finishes
instead of on every child — the default fix for the fire-on-every-child
cascade reported in discussion #4320 / MUL-3508.

Stage advancement stays agent-driven: the server only detects the closed
barrier and wakes the parent assignee, who decides whether to promote the
next stage.

- DB: nullable issue.stage (CHECK >= 1) + sqlc regen
- API: stage on issue create/update/response and batch update
- CLI: `issue create`/`issue update` --stage; new `issue children` command
  that lists sub-issues grouped by stage (table + json)
- stageBarrierClosed / stageProgressSummary in issue_child_done.go, with the
  wake comment now stage-aware, plus unit tests
- skill docs (multica-working-on-issues SKILL.md + source map)

Web UI (create-form stage picker, sidebar edit, group-by-stage display) is a
follow-up; the API already returns stage for it to consume.

MUL-3508

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

* fix(issues): address review on stage barrier (cancel, batch, unstaged)

Resolves the three blockers from the PR review:

1. Cancel can close a stage. The child-done barrier now fires on any
   non-terminal -> terminal transition (done OR cancelled), not just done.
   isTerminalChildStatus already treats cancelled as terminal (a cancelled
   sibling never finishes, so it must not hold a stage open), so a cancelled
   last-open child now closes its stage and wakes the parent. Keying on the
   transition also makes a later cancelled -> done edit a no-op, avoiding a
   lagging duplicate wake.

2. Batch update of stage no longer no-ops. `hasMutation` now includes
   "stage", so `{"updates":{"stage":N}}` persists instead of returning
   {"updated": 0}.

3. Unstaged children no longer participate in the staged frontier. In a
   staged sibling set, NULL-stage children neither hold a stage open nor fire
   on their own completion, and the wake comment no longer renders "Stage 0".
   This matches migration 123 ("NULL does not participate in staged
   grouping") and the CLI's separate unstaged group, removing the footgun
   where an unstaged backlog child silently blocked Stage 1.

Tests: cancellation closes a stage (staged + unstaged), unstaged ignored in a
staged set, stage summary skips unstaged, and a stage-only batch update
persists.

MUL-3508

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

* feat(web): stage UI — create picker, sidebar edit, group sub-issues by stage

Frontend for the sub-issue stage feature (web + desktop, shared via packages):

- core: `stage` on the Issue type + create/update request types; zod
  IssueSchema parses it (defaults to null for older backends) with schema
  tests for the numeric and omitted cases.
- StagePicker component (mirrors the other property pickers): "No stage" +
  Stage 1..N, offering one beyond the current/sibling max.
- Create-issue modal: a Stage pill, shown only when a parent is selected,
  threaded into the create payload.
- Issue detail sidebar: an editable Stage row + "add property" entry, gated to
  sub-issues (issues with a parent).
- Sub-issue list grouped by stage with per-stage headers (flat when unstaged).
- i18n: stage keys across en / zh-Hans / ja / ko (parity test passes).

Verified: full typecheck (6/6), core (591) + views (1433) vitest suites,
lint clean (no new findings). Backend/CLI shipped earlier in this PR.

MUL-3508

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

* test(issues): add stage to Issue fixtures merged from main

The merge brought in new Issue fixtures that predate the required `stage`
field: core issues/batch.test.ts, views batch-action-toolbar.test.tsx, and
the mobile EMPTY_ISSUE_FALLBACK sentinel. Add `stage: null` so they satisfy
the Issue type (mobile reuses core's IssueSchema for parsing, so only the
sentinel needs it).

MUL-3508

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

* fix(web): feed StagePicker the sibling max stage so higher stages stay selectable

The StagePicker accepts maxStage to extend its option list beyond the
floored Stage 1-3, but neither call site passed it, so a parent with an
existing Stage 4/5 child could not pick that stage when creating a new
sub-issue or editing one in the sidebar.

- Compute the sibling max stage at both call sites: the create modal now
  loads the parent's children (childIssuesOptions) and the detail sidebar
  reuses the already-loaded parentChildIssues.
- Extract maxSiblingStage + stageOptions as pure helpers on stage-picker
  and unit-test them (the regression: a Stage 5 sibling keeps Stage 5
  selectable and offers Stage 6).

MUL-3508

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

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-06-23 00:14:42 +08:00
63b9b10df5 MUL-3328: add retry button for failed agent comments (#4217)
* MUL-3328: add retry button for failed agent comments

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

* MUL-3328: cover child-done system comments

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

* MUL-3328: restrict retry affordance to failures

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

* MUL-3328: clean migration whitespace

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

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-06-17 15:18:44 +02:00
abf99eb700 fix(attachments): server-driven markdown_url + legacy compat (MUL-3192) (#3991)
Comment / issue / chat images uploaded inside the Desktop app rendered
as the broken-image fallback. The editor was persisting a site-relative
`/api/attachments/<id>/download` URL into markdown — that path only
resolves when the document origin proxies /api to the API host (apps/web
via Next.js rewrite). On Electron's file:// origin it never resolved.

Per GPT-Boy's plan, move the durable-URL choice from the client to the
server so the persisted shape is correct regardless of which client
performed the upload.

Server:
- AttachmentResponse gains a markdown_url field, computed by
  buildMarkdownURL from the deployment policy:
  • storage URL is already absolute + unsigned (public CDN, S3 public
    bucket, LocalStorage with MULTICA_LOCAL_UPLOAD_BASE_URL on https) →
    use it verbatim;
  • CloudFront-signed mode → never expose the raw S3 URL (private
    bucket); return cfg.PublicURL + /api/attachments/<id>/download so
    the server can re-sign on every request;
  • LocalStorage relative + cfg.PublicURL set → same prefixed API
    endpoint;
  • cfg.PublicURL unset → fall back to site-relative path so web's
    Next.js rewrite still works.
- isDurablePublicURL helper rejects URLs carrying CloudFront / S3
  signature query params, so a freshly-signed download_url can never
  leak into persistence — the original MUL-3130 bug stays closed.

Frontend:
- Attachment type + AttachmentResponseSchema (and apps/mobile mirror)
  carry markdown_url. Schema lenient-defaults to '' so a backend old
  enough to predate this field doesn't break clients.
- useFileUpload picks markdownLink with three-layer fallback:
  (1) att.markdown_url (modern server),
  (2) attachmentDownloadPath(att.id) — legacy site-relative shape,
      retained for backends old enough to omit markdown_url,
  (3) att.url — no-workspace avatar branch with no attachment-row id.
- attachment.tsx keeps the relative→absolute absolutize pass, but
  reframed as the legacy-compat fallback for already-persisted
  /api/attachments/<id>/download or /uploads/<key> URLs in old
  bodies. New content writes absolute URLs and skips this path.
- ContentEditor still tracks freshly-uploaded records into
  AttachmentDownloadProvider so Quick Create's editor can swap the URL
  via the resolver during the same session even before the server-side
  binding lands.

Tests:
- server/internal/handler/file_test.go: 5 new buildMarkdownURL matrix
  tests (public CDN passthrough, CloudFront-signed swap, relative
  prefixing, PublicURL unset fallback, trailing-slash strip) + 15
  table-driven isDurablePublicURL cases.
- packages/core/hooks/use-file-upload.test.ts: new file, 4 cases
  covering modern server / legacy server / no-id avatar / oversize.
- packages/views/editor/attachment.test.tsx + content-editor.test.tsx:
  10 cases for the absolutize matrix and in-session attachment merge.
- 6 existing test fixtures updated to include markdown_url.

Verification: 1236 @multica/views tests pass; 514 @multica/core tests
pass (4 new); server handler package tests pass for the new matrix
plus all pre-existing TestAttachmentToResponse* and TestDownload*
cases. Typecheck green for views/core/web/desktop. Lint clean on
touched files.

Quick Create attachment_ids binding (orphaned attachment relationship
on the resulting issue) is a follow-up — it requires a new --attachment-id
CLI flag and daemon prompt-template work and is intentionally scoped
out of this PR.

Refs: MUL-3192

Co-authored-by: Eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-06-10 16:00:40 +08:00
Antoine GIRARD 9f21d0b634 feat(transcript): add timestamps to run transcript entries (MUL-3174) (#3951)
Threads the existing task_message.created_at column through the full stack (Go protocol -> REST/WS handlers -> TS types -> transcript dialog) so agent run transcripts show per-entry timestamps, helping users spot stalled runs. Additive, no migration.
2026-06-09 19:53:30 +08:00
Matt VoskaandMatt Voska 700cd97407 feat(workspace): add per-workspace logo upload (#2760)
Adds avatar_url column to workspace, threads it through the API +
WorkspaceAvatar component, and adds a click-to-upload editor in the
workspace settings tab. Mirrors the squad avatar pattern (migration 086);
UI strings use "logo" while the schema/code uses avatar_url for codebase
consistency with user.avatar_url and squad.avatar_url.

- migration 093: ALTER TABLE workspace ADD COLUMN avatar_url TEXT
- UpdateWorkspace SQL + handler accept avatar_url (auth gated to
  owner/admin at the router via RequireWorkspaceRoleFromURL)
- WorkspaceAvatar renders <img> when avatar_url is set, falls back to
  the initial-letter span otherwise
- workspace-tab.tsx adds a 16x16 click-to-upload logo editor at the
  top of the general settings card, using useFileUpload + accept=
  image/png,image/jpeg,image/webp (server stores under workspaces/{id}/)
- en + zh-Hans settings i18n strings added

Co-authored-by: Matt Voska <[email protected]>
2026-06-01 16:48:05 +02:00
Naiyuan QingandClaude Opus 4.7 bd1fb10afa chore: react-doctor cleanup — button types, useContext→use(), toSorted, error fixes (#3350)
- Add explicit type="button" to 61 <button> elements missing the attribute
- Replace useContext() with React 19 use() across 16 context consumers
- Replace [...arr].sort() with arr.toSorted() in 12 web/desktop files
  (mobile excluded — Hermes lacks toSorted support)
- Fix rules-of-hooks violation: useSidebar try/catch → useSidebarSafe null check
- Fix nested component definition: useMemo wrapping HeaderRight → useCallback
- Fix missing ARIA: add aria-expanded + aria-controls to combobox in create-squad

React Doctor score: 23 → 30. No behavioral changes, no business logic modified.

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-27 14:57:07 +08:00
Bohan Jiangandmultica-agent 13f74e651a feat(agents): remove custom_env from agent resources, add audited env endpoint (MUL-2600) (#3209)
* feat(agents): remove custom_env from agent resources, add audited env endpoint (MUL-2600)

The agent resource shape (list / get / create / update / archive /
restore responses + WebSocket events) no longer carries `custom_env`
values. Reads/writes of env now flow exclusively through a dedicated
`/api/agents/{id}/env` endpoint that is owner/admin-only, rejects
agent-actor sessions, applies a "****" sentinel preserve guard on
PUT, and writes a persistent audit row per reveal/update.

Why
- `multica agent list --output json` historically returned plaintext
  `custom_env` for owner/admin callers (the redaction gate gave only
  members the masked map). Any agent token running on the workspace
  inherits its owner's role and could read every other agent's
  secrets just by listing.
- Patching list/get redaction alone (PR #3175 direction) left
  symmetric leaks via mutation responses, WS events, the "reveal"
  path itself (no actor-aware auth), and a `****` overwrite footgun
  on UpdateAgent.

What changed
- Backend: drop `custom_env` from AgentResponse; add coarse
  `has_custom_env` + `custom_env_key_count`. Strip env handling from
  UpdateAgent (silently ignored if sent). Keep CreateAgent's
  custom_env acceptance.
- Backend: new GET/PUT `/api/agents/{id}/env` handlers in
  `internal/handler/agent_env.go`:
  - resolveActor → 403 for agent actors (closes the lateral-movement
    path).
  - Owner/admin role gate via existing helper.
  - PUT honours value == "****" as "preserve existing value".
  - Both write to `activity_log` with `agent_env_revealed` /
    `agent_env_updated` actions. Audit details record key names only,
    never values.
- Daemon claim path (`ClaimAgentTask`) unchanged — `TaskAgentData`
  still carries plaintext env for runtime injection.
- SQL: new `UpdateAgentCustomEnv` query; sqlc regenerated (v1.31.1).
- CLI: new `multica agent env get|set` subcommands. `--custom-env*`
  flags removed from `multica agent update`; the no-fields error
  now points to the new path.
- Frontend: drop env fields from `Agent` + `UpdateAgentRequest`; add
  `getAgentEnv` / `updateAgentEnv` client methods; rewrite env-tab
  to show "N variables configured" + explicit "Reveal & edit"
  button, fetching values only on intentional reveal.
- Locales: parity-safe additions to en + zh-Hans.
- Docs: agents-create.{mdx,zh.mdx} reflect the new threat model and
  endpoint.
- Mobile: schema drops `custom_env` / `custom_env_redacted`, adds
  metadata fields.

Tests
- Handler tests pinned the new invariants: no env in list/get
  responses, owner reveal happy-path + audit row, agent-actor 403,
  `****` sentinel preserves real values, UpdateAgent silently
  ignores `custom_env`, pure `mergeAgentEnv` cases.
- CLI tests pivot to the new flag surface: `agent update` MUST NOT
  expose the env flags; `agent env set` MUST expose
  --custom-env-stdin/--custom-env-file.
- Frontend test fixtures updated; pnpm typecheck / test / lint
  pass cleanly.

This is a breaking API change. Scripts that read `custom_env` from
`/api/agents` must migrate to `GET /api/agents/{id}/env`.

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

* fix(agents): close actor-spoofing + audit fail-closed in env endpoints (MUL-2600)

Addresses Elon's review of #3209:

* Mint a task-scoped `mat_` token per claim, bound to (agent, task,
  workspace, owner). Daemon injects it into the agent process in place
  of its own credential. Auth middleware authoritatively rebuilds
  X-User-ID / X-Agent-ID / X-Task-ID from the token row and sets
  X-Actor-Source=task_token; that header is server-set only — incoming
  values are stripped before any auth branch runs. resolveActor honors
  the header so an agent that strips X-Agent-ID / X-Task-ID still
  resolves as actor=agent.
* GetAgentEnv / UpdateAgentEnv are now fail-closed on audit-log
  failures: GET refuses to return plaintext, PUT persists inside the
  same tx as the audit row so they commit/roll back together.
* PUT /api/agents/{id} returns 400 when the body carries custom_env
  instead of silently dropping it — directs callers to the audited env
  endpoint.
* Agent actors never see mcp_config, even when the underlying member
  is owner/admin; mutation broadcasts go through a redaction shim so
  WS subscribers don't pick it up either.
* Fix backend test that asserted dense JSON (jsonb::text renders
  whitespace) and frontend test that assumed a unique "Test User"
  match.

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

* fix(agents): close residual MUL-2600 gaps from review (MUL-2600)

Migration 108 FK now correctly references agent_task_queue(id) instead
of the non-existent agent_task table; the previous name blocked CI
backend migrations.

Task-token-authenticated requests can no longer be re-routed at a
different workspace by passing workspace_slug / workspace_id /
?workspace_id / a URL workspace param. ResolveWorkspaceIDFromRequest
and resolveWorkspaceUUID both short-circuit on X-Actor-Source=task_token
and return only the token-bound X-Workspace-ID; buildMiddleware adds a
defence-in-depth 403 if any URL-resolved workspace disagrees with the
token binding.

mcp_config no longer leaks back to agent actors through UpdateAgent /
CreateAgent / ArchiveAgent / RestoreAgent HTTP responses — the same
redactAgentResponseForActor helper that GetAgent/ListAgents use is now
applied to mutation responses too. WS broadcasts were already redacted
via broadcastAgentResponse.

FailTask and every TaskService cancel path (CancelTask /
CancelTasksForIssue / CancelTasksForAgent / CancelTasksByTriggerComment
/ BroadcastCancelledTasks) now eagerly DeleteTaskTokensByTask so the
mat_ token's 24h window doesn't outlive a terminated task. Failure is
non-fatal — the FK cascade and expiry remain durable guards.

Doc-only: clarify that PUT /api/agents/{id} now hard-rejects bodies
that carry custom_env (was previously "silently ignores").

Tests:
- middleware: TestResolveWorkspaceIDFromRequest gains a task_token
  case asserting client-supplied slug/id/query cannot override the
  bound workspace.
- handler: TestUpdateAgent_RedactsMcpConfigForAgentActor and
  TestUpdateAgent_KeepsMcpConfigForMemberActor pin the mutation-
  response redaction contract per actor type.

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

* fix(agents): match redacted mcp_config as JSON null, not Go nil (MUL-2600)

`AgentResponse.McpConfig` is `json.RawMessage` without `omitempty`, so
the redacted response serialises as `"mcp_config": null`. On decode,
`json.RawMessage` keeps the literal bytes `null` rather than collapsing
to Go nil, which made the assertion fire on a non-leak.

The product contract (field always present, distinguished from "no
config" via `mcp_config_redacted`) is intentional, so adjust the test
to check for "no secret-bearing content" instead of weakening the
contract via `omitempty`.

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

---------

Co-authored-by: multica-agent <[email protected]>
2026-05-25 18:42:48 +08:00
lucccfandClaude Opus 4.7 077bc055f7 fix(views): sort timeline entries by created_at on WebSocket append (MUL-2582) (#3139)
* fix: sort timeline entries by created_at on WebSocket append

When multiple agents post comments concurrently, WebSocket events may
arrive out of chronological order. The handlers blindly appended new
entries to the end of the cached timeline array, causing display
misordering. This fix sorts the array by created_at (with id as
tie-breaker) after each insert.

Changes:
- use-issue-timeline.ts: sort after comment:created and activity:created
- issue-ws-updaters.ts: sort in appendTimelineEntry

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

* fix(views): extract sortTimelineEntriesAsc helper, cover mutation onSuccess

Review feedback from @Bohan-J: useCreateComment.onSuccess also appends
unsorted (mutations.ts:558). When the local user posts a comment whose
HTTP response returns after a concurrent WS event, the unsorted append
leaves the cache misordered and the subsequent WS dedup skips re-sort.

Extract sortTimelineEntriesAsc helper and reuse it in all three web
cache writers:
- comment:created WS handler
- activity:created WS handler
- useCreateComment.onSuccess

Mobile keeps its own inline sort (apps/mobile/CLAUDE.md boundary).

Add regression tests for sort position (mid-insert and oldest-insert).

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

---------

Co-authored-by: Claude Opus 4.7 <[email protected]>
2026-05-25 12:54:09 +08:00