mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-10 10:26:46 +08:00
admin-workspace-create2
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]> |
||
|
|
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]> |
||
|
|
6328e81834 | fix(mobile): avoid seeding incomplete project lists (#8854) | ||
|
|
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. |
||
|
|
024e365ee0 | fix(mobile): avoid duplicate WebSocket recovery and reject invalid frames (#8690) | ||
|
|
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]> |
||
|
|
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]> |
||
|
|
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.
|
||
|
|
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]> |
||
|
|
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
|
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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
|
||
|
|
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]> |
||
|
|
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 |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
4b8873df3a |
feat(issues): add revision-aware concurrency guards (#7038)
* feat(issues): add revision-aware concurrency guards * fix(concurrency): decouple text edits from aggregate revisions |
||
|
|
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]> |
||
|
|
24d209794e | fix(channel): attach /issue media to created issues (#6388) | ||
|
|
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]> |
||
|
|
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]> |
||
|
|
11e2906fee |
Revert "fix(mobile): refresh active chat when realtime stalls (#6325)" (#6374)
This reverts commit
|
||
|
|
08abec1873 | fix(mobile): refresh active chat when realtime stalls (#6325) | ||
|
|
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]> |
||
|
|
3d0e1324c6 | fix(mobile): recover from zombie WebSocket connections | ||
|
|
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. |
||
|
|
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
|
||
|
|
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]>
|
||
|
|
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]>
|
||
|
|
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]> |
||
|
|
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]>
|
||
|
|
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]> |
||
|
|
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 `` 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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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,
|
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]>
|
||
|
|
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. |
||
|
|
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]>
|
||
|
|
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]> |
||
|
|
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]>
|
||
|
|
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]> |